news 2026/10/29 20:11:58

JavaWeb教务管理系统CRUD全链路与事务权限避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JavaWeb教务管理系统CRUD全链路与事务权限避坑

简介:这是一套面向JavaWeb初学者的教务管理系统项目源码,采用MVC设计模式与DAO模式,结合Servlet、JSP、Filter及MySQL数据库实现,运行于Windows下的Tomcat 7与JDK 1.8环境,开发工具为Eclipse与Navicat。系统按登录、学生、教师、教务员、系统管理员四类角色划分,覆盖登录与找回密码、修改密码、注销,学生成绩查询、选修与考级报名、学籍信息查看修改、考级成绩查询,教师个人信息维护,教务员成绩管理与报名名单管理,以及管理员用户管理等功能,适合作为课程设计、毕业设计或JavaWeb入门练手参考。压缩包共223个文件,以82个Java源文件与82个class文件、38个JSP页面为主,另含6个jar依赖、4个XML配置及1个SQL脚本,整体约2.6MB,并附README.txt说明启动流程。已有11658人学习下载。项目结构清晰,部分功能留有完善空间,读者可据此理解分层设计、Servlet请求流转与数据库访问方式,并在现有模块上继续扩展新功能。

1. 教务管理系统 javaweb 项目:一份能跑通全链路的 CRUD 参照

期末前两周,实验室里最常见的场景是:某人抱着一份教务管理系统 javaweb 源码,登录页能打开,菜单能点,一到选课就报 500,成绩导出直接白屏。这类项目的价值不在于功能多花哨,而在于它把「建表 → 分层 → 事务 → 权限拦截」这条完整链路摆在一份可运行的代码里。这套资源以 Servlet/JSP 为骨架、MySQL 为存储,覆盖学生、教师、课程、选课、成绩、角色权限六条主线,适合要交课程设计的人,也适合想拿一个真实业务场景练分层和事务的后端新手。它不做高并发,也不拆微服务,边界很清楚。

2. 环境与启动:Tomcat 与 MySQL 的版本对齐和三层自检

2.1 版本矩阵与目录约定

教务系统这类 CRUD 项目,业务代码本身不复杂,真正卡住人的是环境。我见过太多人解压完源码直接往 IDE 里一丢,满屏红叉,然后开始怀疑代码有问题。先把版本对齐,后面八成的启动故障不会发生。

组件建议基线选择理由常见冲突
JDK8老项目普遍用javax.servlet包名换成 17 后编译期就报找不到符号
Tomcat8.5 或 9仍是javax.*命名空间Tomcat 10 起改为jakarta.*,老 import 全废
MySQL5.7 或 8.0窗口函数、CTE 用得少,5.7 足够8.0 默认认证插件变化,老驱动连不上
JDBC 驱动与 MySQL 主版本匹配5.1.x 配 5.7,8.0.x 配 8.0驱动类名不同,URL 参数要求不同
Servlet API3.1 及以上支持@WebServlet注解,省 web.xml低于 3.0 只能靠 XML 配置

Tomcat 10 是第一个大坑。它把 Servlet API 的包名整体迁到jakarta.servlet,源码里所有import javax.servlet.http.HttpServlet都会编译失败。不是代码写错了,是运行容器换代了。想少折腾就锁在 Tomcat 9,或者把源码里的 import 全量替换——但替换完 JSTL 标签库、Filter 配置也可能跟着出问题,得不偿失。

目录结构上,传统 JavaWeb 项目有两种布局:Eclipse 风格的WebContent和 Maven 风格的src/main/webapp。拿到源码先看有没有pom.xml,有就按 Maven 导入,没有就按 Dynamic Web Project 建。

# 典型的非 Maven 目录结构,先确认这几个位置 edu-system/ ├── src/ │ └── com/demo/edu/ │ ├── controller/ # Servlet,负责收参数、返响应 │ ├── service/ # 业务规则,事务边界在这一层 │ ├── dao/ # SQL 与 ResultSet 映射 │ ├── entity/ # 与表字段一一对应的 POJO │ └── util/ # JDBCUtils、分页工具、MD5 ├── WebContent/ │ ├── WEB-INF/ │ │ ├── web.xml # 没有注解时,Servlet 映射写这里 │ │ └── lib/ # 驱动、JSTL、JSON 库放这里 │ ├── jsp/ # 视图层 │ └── css/ js/ images/ # 静态资源 └── edu_ms.sql # 建库建表脚本

WEB-INF/lib这个目录值得单独说。很多人把 jar 包放在项目根目录或者 IDE 的 Build Path 里,本地跑得好好的,一打包部署就报ClassNotFoundException。原因是WEB-INF/lib才是 Web 应用运行时的类路径,IDE 的 Build Path 只影响编译期。驱动、JSTL、JSON 工具这三类包必须落在WEB-INF/lib下。

2.2 建库导入与连接参数落地

数据库这一步,建议直接用命令行导入,别依赖图形工具,出错时能看到具体行号。

-- 建库语句,字符集一次定死,后面少一半乱码问题 CREATE DATABASE edu_ms DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
# 导入脚本,注意 -p 后面不要跟密码,交互输入更安全 mysql -u root -p edu_ms < edu_ms.sql # 导入后确认表数量,正常在 8 到 10 张之间 mysql -u root -p -e "USE edu_ms; SHOW TABLES;"

连接参数写在db.properties里,不要硬编码进 Java 代码。硬编码的直接后果是换台机器就得改源码重新编译,部署时极容易漏改。

# src 根目录下的 db.properties jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/edu_ms?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=your_password jdbc.initialSize=5 jdbc.maxActive=20 jdbc.maxWait=3000

几个参数值得解释。serverTimezone=Asia/Shanghai不加的话,MySQL 8.0 驱动会抛The server time zone value is unrecognized,因为驱动读不到服务器时区。useSSL=false是本地开发用,生产环境要开并配证书,否则握手阶段会持续告警。allowPublicKeyRetrieval=true配合 MySQL 8.0 的caching_sha2_password认证插件使用,不加会报Public Key Retrieval is not allowed。maxWait=3000表示从连接池取连接最多等 3 秒,超时抛异常,比无限等待更容易定位问题。

加载配置的代码放在JDBCUtils的静态块里,只执行一次:

public class JDBCUtils { private static final Properties PROPS = new Properties(); private static String url; private static String username; private static String password; static { // 用类加载器读 classpath 下的配置文件,避免相对路径依赖 try (InputStream in = JDBCUtils.class.getClassLoader() .getResourceAsStream("db.properties")) { if (in == null) { throw new IllegalStateException("db.properties 不在 classpath 中"); } PROPS.load(in); // 反射加载驱动,Class.forName 会触发驱动的静态注册 Class.forName(PROPS.getProperty("jdbc.driver")); url = PROPS.getProperty("jdbc.url"); username = PROPS.getProperty("jdbc.username"); password = PROPS.getProperty("jdbc.password"); } catch (Exception e) { throw new ExceptionInInitializerError("数据库配置初始化失败: " + e.getMessage()); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } // 关闭顺序必须是 ResultSet -> Statement -> Connection public static void close(Connection conn) { if (conn != null) { try { conn.close(); } catch (SQLException ignored) { } } } }

静态块里抛ExceptionInInitializerError是有意为之。配置错了就该在类加载阶段直接炸掉,而不是等到第一次请求时抛一个含糊的NullPointerException。Class.forName在 JDBC 4.0 之后其实可以省略,驱动会通过 SPI 自动注册,但显式写出来能保证在老的容器环境下也正常。

2.3 启动失败的三层定位法

应用起不来,别急着改代码,按「容器 → 应用 → 数据库」三层往下查,每层都有明确的观察点。

第一层看 Tomcat 本身有没有起来。启动脚本执行后,logs/catalina.out里出现Server startup in xxx ms才算成功。如果卡在Deploying web application然后超时,大概率是web.xml里的 DTD 地址连不上外网,或者某个 Servlet 的load-on-startup初始化方法里死循环了。端口占用也很常见,Address already in use: bind说明 8080 被占,改conf/server.xml里的 Connector 端口即可。

第二层看应用有没有部署成功。访问http://127.0.0.1:8080/能看到 Tomcat 首页,说明容器没问题;访问项目路径返回 404,说明 context path 不对。Eclipse 部署默认用项目名做 context,IDEA 默认可能是/,两边行为不一致。返回 500 但页面只有一句The server encountered an internal error,就得去logs/localhost.yyyy-mm-dd.log里找完整堆栈,那个文件里才有真实异常类名。

第三层看数据库通不通。用mysql -h 127.0.0.1 -P 3306 -u root -p从应用所在机器连一次,排除账号权限和防火墙问题。再用一条最小 SQL 验证驱动:

// 五分钟排查脚本,放在一个 main 方法里直接跑 public static void main(String[] args) throws Exception { Connection conn = JDBCUtils.getConnection(); try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery("SELECT COUNT(*) FROM t_course")) { rs.next(); System.out.println("课程表记录数: " + rs.getInt(1)); } finally { JDBCUtils.close(conn); } }

这条脚本能跑通,说明驱动、URL、账号、库名、表名五样东西全对,剩下就是业务代码的问题。跑不通的话,报错信息基本能直接定位:Unknown database是库名错,Access denied是账号权限,No suitable driver是驱动没进WEB-INF/lib,Communications link failure是服务没起或端口不通。

3. 库表结构:五张主表如何撑起选课与成绩两条业务线

3.1 实体表与关系表的字段取舍

教务系统的表不难设计,难在设计完之后能不能经得住业务规则的检验。我把核心表拆成两类:实体表存「是谁」,关系表存「做了什么」。

-- 班级表:冗余年级和专业,避免为了查一个班级名去 join 三张表 CREATE TABLE t_class ( id BIGINT PRIMARY KEY AUTO_INCREMENT, class_name VARCHAR(50) NOT NULL, grade VARCHAR(10) NOT NULL COMMENT '如 2021', major VARCHAR(50) NOT NULL, UNIQUE KEY uk_class (class_name, grade) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 学生表:sno 是学号,业务唯一键,必须加唯一索引 CREATE TABLE t_student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sno VARCHAR(20) NOT NULL, name VARCHAR(50) NOT NULL, gender TINYINT DEFAULT 0 COMMENT '0未知 1男 2女', class_id BIGINT NOT NULL, phone VARCHAR(20), status TINYINT DEFAULT 1 COMMENT '1在读 0离校', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_sno (sno), KEY idx_class (class_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 课程表:capacity 是容量,selected_count 是已选人数,两个字段分开存 CREATE TABLE t_course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, cno VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL, credit DECIMAL(3,1) NOT NULL DEFAULT 2.0, teacher_id BIGINT NOT NULL, capacity INT NOT NULL DEFAULT 50, selected_count INT NOT NULL DEFAULT 0, semester VARCHAR(20) NOT NULL COMMENT '如 2024-2025-1', status TINYINT DEFAULT 1 COMMENT '1开放 0停开', UNIQUE KEY uk_cno_sem (cno, semester), KEY idx_teacher (teacher_id), KEY idx_semester (semester) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 选课表:这是关系表,一条记录代表一次选课动作 CREATE TABLE t_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 1 COMMENT '1已选 0退选', UNIQUE KEY uk_stu_course (student_id, course_id), KEY idx_course (course_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 成绩表:与选课记录一对一,用 selection_id 做外键关联 CREATE TABLE t_score ( id BIGINT PRIMARY KEY AUTO_INCREMENT, selection_id BIGINT NOT NULL, usual_score DECIMAL(4,1) DEFAULT NULL COMMENT '平时分', exam_score DECIMAL(4,1) DEFAULT NULL COMMENT '考试分', total_score DECIMAL(4,1) DEFAULT NULL COMMENT '总评', input_by BIGINT DEFAULT NULL, input_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_selection (selection_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个字段的取舍值得展开。t_course里capacity和selected_count分开存,而不是每次选课时SELECT COUNT(*) FROM t_selection现算。现算的写法在数据量小的时候看不出问题,选课高峰一到,每次请求都全表扫一遍选课表,响应时间直接从几十毫秒涨到几秒。冗余一个计数器,配合条件更新,能把这个开销压到一条 UPDATE。

t_selection的status字段是给退选留的。直接DELETE物理删除也能实现退选,但退选后重新选同一门课会撞唯一索引,还得额外处理。用状态位标记,唯一索引保持不变,退选就是UPDATE status = 0,再选就是UPDATE status = 1,逻辑干净很多。

t_score挂selection_id而不是student_id + course_id,是为了保证「有选课记录才有成绩」。外键约束在应用层校验,数据库层面加唯一索引,避免同一条选课记录被录入两次成绩。

3.2 联合唯一索引与选课容量控制

选课这个动作,本质是两个约束同时成立:同一个学生不能重复选同一门课,课程人数不能超过容量。前者靠唯一索引兜底,后者靠条件更新兜底,两条都不能只写在 Java 代码里。

-- 唯一索引已在建表时加上,这里单独验证一下是否存在 SHOW INDEX FROM t_selection WHERE Key_name = 'uk_stu_course';

为什么不能靠应用层「先查再插」?两个请求几乎同时到达,都执行SELECT * FROM t_selection WHERE student_id = ? AND course_id = ?,都返回空,然后都执行 INSERT,最后数据库里出现两条一样的记录。这是典型的检查后使用竞态,单机压测不一定复现,一上多人选课就露馅。唯一索引把判断权交给数据库,第二条 INSERT 直接抛Duplicate entry,应用层捕获后返回「已选过」。

容量控制同理。错误写法是先SELECT selected_count, capacity FROM t_course WHERE id = ?,Java 里比较大小,再UPDATE ... SET selected_count = selected_count + 1。两个请求读到同样的selected_count = 49,都判断 49 < 50 成立,都执行加一,最终变成 51。正确写法是把判断塞进 WHERE:

-- 一条语句完成「检查余量 + 扣减」,影响行数为 0 就代表没抢到 UPDATE t_course SET selected_count = selected_count + 1 WHERE id = ? AND status = 1 AND selected_count < capacity;

这条 UPDATE 在 InnoDB 行锁保护下执行,同一行的并发更新会串行化,不存在两个事务同时通过selected_count < capacity判断的情况。返回值是受影响行数,等于 1 说明扣减成功,等于 0 说明名额满了或者课程停开,应用层据此抛业务异常。

注意:条件更新和唯一索引解决的是并发正确性,不是性能。真到了几千人同时抢一门课的量级,行锁竞争会成为瓶颈,那时候要考虑的是排队或预扣减,不是继续加索引。

3.3 用 SQL 做业务规则验收

表建完,别急着写 Java。先用 SQL 把几条核心业务规则跑一遍,确认数据结构撑得住。这步做完,后面写 DAO 时心里有底。

-- 规则一:查询某学生本学期可选课程(未选过、有余量、课程开放) SELECT c.id, c.cno, c.name, c.credit, c.capacity - c.selected_count AS remain FROM t_course c WHERE c.semester = '2024-2025-1' AND c.status = 1 AND c.selected_count < c.capacity AND NOT EXISTS ( SELECT 1 FROM t_selection s WHERE s.course_id = c.id AND s.student_id = 1001 AND s.status = 1 ) ORDER BY c.id; -- 规则二:某学生的成绩单,未录入的成绩显示为 NULL SELECT c.cno, c.name, c.credit, sc.usual_score, sc.exam_score, sc.total_score FROM t_selection s JOIN t_course c ON c.id = s.course_id LEFT JOIN t_score sc ON sc.selection_id = s.id WHERE s.student_id = 1001 AND s.status = 1 ORDER BY c.cno; -- 规则三:某教师本学期课程的平均分与选课人数 SELECT c.cno, c.name, COUNT(s.id) AS stu_count, ROUND(AVG(sc.total_score), 1) AS avg_score FROM t_course c LEFT JOIN t_selection s ON s.course_id = c.id AND s.status = 1 LEFT JOIN t_score sc ON sc.selection_id = s.id WHERE c.teacher_id = 2001 AND c.semester = '2024-2025-1' GROUP BY c.id, c.cno, c.name;

规则一里的NOT EXISTS比LEFT JOIN ... IS NULL更直观,MySQL 5.7 之后的优化器对两者的执行计划处理得差不多,选可读性好的写法。规则二用LEFT JOIN是为了让没录成绩的课程也出现在成绩单里,学生能看到「这门课还没出分」,而不是整行消失。规则三的LEFT JOIN条件里带s.status = 1,是为了把退选记录排除在统计之外——如果把条件写到 WHERE 里,LEFT JOIN 会退化成 INNER JOIN,没选课记录的课程直接不显示。

3.4 权限模型:三张表砍到最小可用

教务系统有三类角色:管理员、教师、学生。完整的 RBAC 要五张表(用户、角色、权限、用户角色、角色权限),对课程设计来说过重。砍到三张:用户表挂角色码,角色表定义角色,角色菜单表控制可见菜单。

CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL COMMENT '加盐后的哈希', salt VARCHAR(16) NOT NULL, role_code VARCHAR(20) NOT NULL COMMENT 'ADMIN/TEACHER/STUDENT', ref_id BIGINT DEFAULT NULL COMMENT '关联学生或教师表主键', status TINYINT DEFAULT 1, UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(20) NOT NULL, name VARCHAR(50) NOT NULL, UNIQUE KEY uk_code (code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_role_menu ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(20) NOT NULL, menu_code VARCHAR(50) NOT NULL COMMENT '如 course:list、score:input', UNIQUE KEY uk_role_menu (role_code, menu_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

ref_id这个字段是这套设计的关键。学生登录后,t_user.ref_id指向t_student.id,查成绩、查选课直接用这个 id,不需要再用用户名去学生表反查一次。教师在t_teacher.id同理。管理员ref_id为空,因为管理员不绑定具体业务实体。

菜单权限只控制「看不看得见」,接口权限还得在 Filter 里按menu_code再校验一次。只隐藏菜单不拦接口,属于典型的假权限——前端把按钮藏了,用户手动敲 URL 照样能调。这一点在第 4 章的过滤器里会落到代码。

4. 后端主线:Servlet 分层与选课接口的事务边界

4.1 从 JSP 脚本块到 Controller-Service-Dao

拿到老项目源码,先看 JSP 里有没有<% Connection conn = DriverManager.getConnection(...) %>。有的话,说明业务逻辑全写在视图层,改一个字段要同步改三四个 JSP,这种结构没法维护。分层改造的原则很简单:Servlet 只负责收参数和返响应,Service 负责业务规则和事务,DAO 只碰 SQL。

层职责不该做的事
Controller(Servlet)取参、校验格式、调 Service、写响应写 SQL、开事务
Service业务判断、事务边界、组合多个 DAO直接操作 HttpServletRequest
DAO执行 SQL、映射 ResultSet抛业务异常、做权限判断
Entity字段容器携带连接、携带 Session

分层的实际收益在改需求时才体现。比如「选课要先检查学生是否欠费」,这个规则只需要在 Service 里加三行,Controller 和 DAO 一行不动。如果逻辑散在 JSP 里,就得把每个相关页面翻一遍。

4.2 选课接口的完整实现

先看 Controller。参数校验放在最前面,非法参数不进 Service,减少无谓的数据库往返。

@WebServlet("/api/selection/select") public class SelectionServlet extends HttpServlet { private final SelectionService selectionService = new SelectionServiceImpl(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("application/json;charset=UTF-8"); Map<String, Object> result = new HashMap<>(); // 学生 id 从 Session 取,不从请求参数取,防止越权替别人选课 Object userId = req.getSession().getAttribute("userId"); String courseIdStr = req.getParameter("courseId"); if (userId == null) { result.put("code", 401); result.put("msg", "登录已失效"); } else if (courseIdStr == null || !courseIdStr.matches("\\d+")) { result.put("code", 400); result.put("msg", "课程参数不合法"); } else { try { selectionService.selectCourse((Long) userId, Long.parseLong(courseIdStr)); result.put("code", 0); result.put("msg", "选课成功"); } catch (BizException e) { // 业务异常是预期内的,返回具体提示,不打堆栈 result.put("code", e.getCode()); result.put("msg", e.getMessage()); } catch (Exception e) { // 系统异常对外模糊化,日志里留完整堆栈 log("选课失败 studentId=" + userId + ", courseId=" + courseIdStr, e); result.put("code", 500); result.put("msg", "系统繁忙,请稍后重试"); } } resp.getWriter().write(toJson(result)); } }

userId从 Session 拿而不是从请求参数拿,这条规则必须守住。参数里带studentId的写法,改个数字就能替同班同学选课,属于最基础的越权漏洞。courseId用正则校验是否纯数字,挡住1 OR 1=1这类注入尝试——虽然 PreparedStatement 已经能防住,但参数格式校验能提前返回清晰的 400,比让 SQL 层报错更好排查。

Service 层是事务的核心,同一个 Connection 必须贯穿整个业务动作:

public class SelectionServiceImpl implements SelectionService { private final CourseDao courseDao = new CourseDaoImpl(); private final SelectionDao selectionDao = new SelectionDaoImpl(); @Override public void selectCourse(Long studentId, Long courseId) throws Exception { Connection conn = null; try { conn = JDBCUtils.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,事务从这里开始 // 第一步:条件更新扣减容量,返回 0 表示名额已满或课程停开 int affected = courseDao.increaseSelected(conn, courseId); if (affected == 0) { throw new BizException(1001, "课程名额已满或已停开"); } // 第二步:插入选课记录,唯一索引兜底重复选课 try { selectionDao.insert(conn, studentId, courseId); } catch (SQLIntegrityConstraintViolationException dup) { throw new BizException(1002, "你已经选过这门课"); } conn.commit(); // 两步都成功才提交 } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) { } } throw e; // 异常必须继续往上抛,否则 Controller 拿不到 } finally { JDBCUtils.close(conn); // 连接归还,自动提交状态由连接池重置 } } }

顺序不能反。先扣容量再插记录,是因为扣容量失败时什么都不用回滚,直接抛异常退出;如果先插记录再扣容量,扣减失败时得回滚一条已经写入的记录,多一次 IO。两步都在同一个事务里,任何一步失败,另一步的影响都会被撤销,不会出现「名额扣了但记录没插」的中间态。

DAO 层的条件更新是这段逻辑的地基:

public class CourseDaoImpl implements CourseDao { @Override public int increaseSelected(Connection conn, Long courseId) throws SQLException { String sql = "UPDATE t_course SET selected_count = selected_count + 1 " + "WHERE id = ? AND status = 1 AND selected_count < capacity"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, courseId); return ps.executeUpdate(); } } }

try-with-resources保证 PreparedStatement 一定关闭,异常路径也不例外。注意这里没有关闭 Connection——Connection 由 Service 层管理生命周期,DAO 关掉它,Service 后面的 commit 就没法执行了。这个边界要划清楚:谁开的连接谁关,DAO 只关自己创建的 Statement 和 ResultSet。

BizException是一个自定义运行时异常,携带 code 和 message:

public class BizException extends RuntimeException { private final int code; public BizException(int code, String message) { super(message); this.code = code; } public int getCode() { return code; } }

继承RuntimeException而不是Exception,是为了不强制每层都写throws,Service 方法签名能保持干净。代价是编译器不检查,得靠代码评审保证它确实被捕获并转成了响应。

4.3 登录拦截与静态资源放行

过滤器写成/*拦截全站,白名单用前缀匹配。这里最容易翻车的地方是静态资源——登录页的 CSS 被拦了,页面能打开但样式全丢,看起来像前端问题,实际是过滤器把/css/login.css也重定向到登录页了。

@WebFilter(urlPatterns = "/*") public class AuthFilter implements Filter { // 白名单用前缀,不是全等,否则 /css/ 下的每个文件都要单独列 private static final List<String> WHITE_PREFIX = Arrays.asList( "/login", "/api/login", "/css/", "/js/", "/images/", "/favicon.ico" ); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; // 去掉 contextPath,拿到应用内的相对路径 String path = req.getRequestURI().substring(req.getContextPath().length()); boolean white = WHITE_PREFIX.stream().anyMatch(path::startsWith); boolean logged = req.getSession().getAttribute("userId") != null; if (white || logged) { chain.doFilter(request, response); return; } // 接口请求返回 JSON,页面请求跳登录页,分开处理 if (path.startsWith("/api/")) { resp.setStatus(401); resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":401,\"msg\":\"请先登录\"}"); } else { resp.sendRedirect(req.getContextPath() + "/login.jsp"); } } }

getRequestURI()返回的是带 contextPath 的完整路径,比如/edu/api/selection/select。直接用startsWith("/api/")判断永远不成立,必须先减掉 contextPath。这个细节在本地用根路径部署时不会暴露,一放到/edu这种子路径下立刻失效。

接口请求和页面请求分开响应,是为了让前端能区分。AJAX 收到 302 重定向后,浏览器会自动跟随到登录页,前端拿到的是登录页的 HTML,JSON.parse直接报错,排查半天以为是接口问题。返回 401 加 JSON,前端拦截器判 code 就能跳转,链路清晰。

4.4 分页查询的两种写法

课程列表这类页面必须分页,两种写法各有适用场景。第一种是LIMIT offset, size,写法简单,但 offset 很大时性能会掉——MySQL 要扫描前 offset 条记录再丢弃。

-- 写法一:传统 LIMIT,适合页码不深的场景 SELECT c.id, c.cno, c.name, c.credit, c.capacity, c.selected_count, t.name AS teacher_name FROM t_course c LEFT JOIN t_teacher t ON t.id = c.teacher_id WHERE c.semester = ? AND c.name LIKE CONCAT('%', ?, '%') ORDER BY c.id DESC LIMIT ?, ?;

第二种是游标分页,用上一页最后一条的 id 做锚点,不依赖 offset:

-- 写法二:游标分页,翻到多深都是索引定位 SELECT c.id, c.cno, c.name, c.credit, c.capacity, c.selected_count FROM t_course c WHERE c.semester = ? AND c.id < ? -- 上一页最后一条的 id ORDER BY c.id DESC LIMIT ?;

教务系统的课程总量通常就几百到几千条,第一种写法完全够用,别过度设计。真正需要注意的是 count 语句:它必须和列表语句用同一套 WHERE 条件,但绝对不能带LIMIT和ORDER BY。

-- count 语句单独写,只保留过滤条件 SELECT COUNT(*) FROM t_course c WHERE c.semester = ? AND c.name LIKE CONCAT('%', ?, '%');

复制列表 SQL 改 count 是最常见的低级错误。带LIMIT 0, 10的 count 永远返回 10,前端分页器只显示一页,用户以为数据丢了。带ORDER BY的 count 虽然结果对,但排序对聚合毫无意义,纯属浪费。

5. 避坑排查:六个让项目跑不起来的现场

5.1 中文乱码:POST 与 GET 是两条链路

现象:表单提交学生姓名,数据库里存成????;列表页查询条件带中文,后端收到乱码。

原因通常有三层。第一层是请求体编码,Tomcat 默认按 ISO-8859-1 解析 POST 请求体,request.setCharacterEncoding("UTF-8")必须在第一次调用getParameter之前执行,写在后面无效。第二层是 GET 参数编码,Tomcat 8 之后默认 URIEncoding 已经是 UTF-8,但 7 及以前是 ISO-8859-1,需要在server.xml的 Connector 上显式配置。第三层是数据库链路,连接串没带characterEncoding=utf8,或者建库时用了 latin1,Java 里再怎么设都白搭。三层里任何一层漏掉,乱码都会出现,而且现象一模一样,很难区分。

解决顺序是从下往上:先确认库和表的字符集是utf8mb4,再确认连接串带useUnicode=true&characterEncoding=utf8,最后在过滤器里统一setCharacterEncoding("UTF-8")并设置响应类型。三层都对齐,乱码不会再有。

5.2 连接池耗尽:连接没关的三种写法

现象:系统跑十几分钟后开始报Cannot get a connection, pool exhausted,重启后恢复,过一阵又出现。

原因基本是连接泄漏。最常见的有三种:DAO 方法里getConnection之后,某个 if 分支直接return,close 被跳过;用try-catch包住但finally里忘了写 close;ResultSet 或 Statement 没关,虽然连接关了,但游标资源没释放,累积到一定数量同样拖垮连接池。这三种写法在功能测试阶段都不会报错,因为连接池初始容量够用,只有持续跑才暴露。

解决方式是全部改成try-with-resources,资源声明在 try 的括号里,无论正常返回还是抛异常都会关闭。连接池再加两个配置:removeAbandoned=true和removeAbandonedTimeout=60,超过 60 秒没归还的连接强制回收并打日志,日志里能看到泄漏的调用栈,定位到具体方法。

5.3 事务不回滚:异常被吞掉是最常见的死法

现象:选课失败,页面提示「系统繁忙」,但课程表的selected_count已经加了一,数据对不上。

原因有三个可能。第一,Service 里两次调用JDBCUtils.getConnection(),扣容量用连接 A,插记录用连接 B,两个连接各自自动提交,事务根本不存在。第二,catch块里写了e.printStackTrace()但没throw,异常被吞,外层以为成功,照样 commit。第三,setAutoCommit(false)写在获取连接之后、执行 SQL 之前,但某条 SQL 用的是另一个连接。第一种和第三种本质是同一个问题:事务的边界必须和 Connection 的生命周期完全重合。

解决办法是把 Connection 作为参数在 DAO 方法间传递,Service 统一开关事务,catch里先rollback再throw,绝不吞异常。检查是否生效有个土办法:故意在插记录之后抛一个运行时异常,看selected_count有没有回退,回退了说明事务生效,没回退说明连接不是同一个。

5.4 静态资源被拦截:登录页样式全丢

现象:访问登录页,HTML 出来了,但没有任何样式,控制台里 CSS 请求全是 302。

原因:过滤器配了/*,白名单里只放了/login,没放/css/、/js/、/images/。浏览器加载登录页时会并行请求这些静态资源,每个请求都经过过滤器,Session 里没有 userId,全部被重定向到登录页。重定向后的响应内容是 HTML,浏览器按 CSS 解析失败,页面就裸了。

解决是把静态资源目录按前缀放行,/css/、/js/、/images/、/fonts/各加一条。用前缀匹配而不是全等匹配,否则每加一个静态文件都要改配置。/favicon.ico单独放行,这个请求浏览器会自动发,被拦了会在控制台刷一屏 302,看着很烦。

5.5 分页总数不对:count 语句抄错了

现象:列表第一页显示 10 条,分页器只有 1 页,实际数据有 200 条。

原因:count 语句是从列表 SQL 复制过来改的,SELECT c.id, c.name ...改成了SELECT COUNT(*),但后面的LIMIT ?, ?忘了删。COUNT(*)加LIMIT 10的结果就是 10,分页器按 10 条算总页数,自然只有一页。

解决是把 count 语句单独维护,只保留 FROM 和 WHERE,ORDER BY和LIMIT一律不写。另一个隐藏问题是排序字段不唯一:ORDER BY create_time DESC在时间相同的记录上顺序不稳定,翻页时可能重复或漏掉。加主键做第二排序字段,ORDER BY create_time DESC, id DESC,顺序就固定了。

5.6 部署后 404:contextPath 与注解扫描

现象:IDE 里跑得好好的,打成 war 丢进 Tomcat 的webapps目录,访问所有接口都是 404。

原因通常是两个。第一,contextPath 变了。IDE 部署时可能把 context 设成/,war 包部署默认用 war 文件名做 context,访问路径从/api/xxx变成/edu-system/api/xxx。第二,@WebServlet注解没生效。注解扫描依赖web.xml的metadata-complete属性,如果它是true,容器会跳过所有注解,只认 XML 配置。老项目模板里经常带着这个属性。

解决是先访问http://127.0.0.1:8080/看 Tomcat 管理页,确认应用列表里的 context 是什么,再按实际路径访问。注解不生效的话,把web.xml里的metadata-complete="true"删掉,或者在web.xml里显式补上 servlet 映射。两种方式选一种,别混着来,混着来容易出现同一个 Servlet 注册两次、启动报重复映射的问题。

6. 成绩批量导入:批处理事务与一次可复现的验证

单条录入成绩在页面上点一点就行,一到期末,教师手上是几十个 Excel 或 CSV 文件,几百上千行数据。逐条 INSERT 的问题是网络往返次数太多,一条一毫秒,一千条就是一秒,加上事务开销更慢。可行的做法是分批提交,每批 200 到 500 条,批与批之间独立事务,某批失败只影响那一批。

CSV 格式约定成四列:学号、课程号、平时分、考试分。总评按 0.3 和 0.7 加权算,不用教师手填。

public class ScoreImportService { private static final int BATCH_SIZE = 200; public ImportResult importCsv(Path file, Long operatorId) throws IOException { List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8); ImportResult result = new ImportResult(); List<String[]> buffer = new ArrayList<>(BATCH_SIZE); for (int i = 1; i < lines.size(); i++) { // 跳过表头 String line = lines.get(i).trim(); if (line.isEmpty()) continue; buffer.add(line.split(",")); if (buffer.size() == BATCH_SIZE) { flush(buffer, operatorId, result); buffer.clear(); } } if (!buffer.isEmpty()) { flush(buffer, operatorId, result); // 收尾的零头 } return result; } private void flush(List<String[]> rows, Long operatorId, ImportResult result) { Connection conn = null; try { conn = JDBCUtils.getConnection(); conn.setAutoCommit(false); String sql = "INSERT INTO t_score (selection_id, usual_score, exam_score, total_score, input_by) " + "SELECT s.id, ?, ?, ROUND(? * 0.3 + ? * 0.7, 1), ? " + "FROM t_selection s " + "JOIN t_student st ON st.id = s.student_id " + "JOIN t_course c ON c.id = s.course_id " + "WHERE st.sno = ? AND c.cno = ? AND s.status = 1 " + "ON DUPLICATE KEY UPDATE usual_score = VALUES(usual_score), " + "exam_score = VALUES(exam_score), total_score = VALUES(total_score)"; try (PreparedStatement ps = conn.prepareStatement(sql)) { for (String[] row : rows) { double usual = Double.parseDouble(row[2]); double exam = Double.parseDouble(row[3]); ps.setDouble(1, usual); ps.setDouble(2, exam); ps.setDouble(3, usual); ps.setDouble(4, exam); ps.setLong(5, operatorId); ps.setString(6, row[0]); ps.setString(7, row[1]); ps.addBatch(); } int[] counts = ps.executeBatch(); conn.commit(); for (int c : counts) { if (c > 0) result.success++; else result.skipped++; } } } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) { } } result.failed += rows.size(); result.errors.add("批次失败: " + e.getMessage()); } finally { JDBCUtils.close(conn); } } }

这段代码有几个点值得停一下。INSERT 用的是INSERT ... SELECT,靠学号和课程号在数据库里定位selection_id,而不是在 Java 里先查一遍再拼 id。一条语句完成查找和插入,少一次往返,也避免了两步之间数据变化导致的错位。ON DUPLICATE KEY UPDATE让重复导入变成覆盖更新,教师同一份文件导入两次不会报唯一键冲突,成绩被更新成最新值。

executeBatch返回的int[]里,每个元素是单条语句的受影响行数。MySQL 的 JDBC 驱动默认不返回具体值,需要连接串加 `

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/29 20:11:46

彩影ARP防火墙6.02安装配置全指南:Win7/2008R2下驱动级ARP防护实战

简介&#xff1a;彩影ARP防火墙6.02简体中文单机版是一款面向局域网终端用户&#xff08;尤其适用于32位Windows系统&#xff09;的专业级ARP防护工具&#xff0c;专为解决ARP欺骗、IP冲突、内网中毒传播及DoS攻击等常见局域网安全问题而设计。资源包为RAR压缩格式&#xff0c;…

作者头像 李华
网站建设 2026/10/29 20:09:29

食物图像识别落地:数据增强、迁移学习与ONNX量化部署

简介&#xff1a;围绕卷积神经网络实现食物图像识别的完整实践项目&#xff0c;面向具备 Python 与深度学习基础、希望打通数据预处理到模型评估全流程的学习者与开发者。项目以 TensorFlow/Keras 构建 CNN&#xff0c;覆盖卷积、池化、全连接与激活函数等核心结构&#xff0c;…

作者头像 李华
网站建设 2026/10/29 20:09:06

C#自绘工控仪表控件:圆表直表旋钮开关与指示灯实现

简介&#xff1a;面向C#工控界面开发者的仪表控件示例包&#xff0c;聚焦圆表、直表&#xff08;测量标尺&#xff09;、旋钮、开关与指示灯等常见仿真控件的实现&#xff0c;适合需要为组态软件或设备监控界面补充可视化反馈的初、中级开发者参考。压缩包共18个文件、约1.15MB…

作者头像 李华
网站建设 2026/10/29 20:08:36

网页变化监控实战指南:用Ponytail把竞品盯梢变成自动化流程

做电商运营的朋友前两天跟我抱怨&#xff0c;说每天上班第一件事就是挨个刷新十几个竞品页面&#xff0c;看价格变没变、库存还剩多少&#xff0c;一上午就这么没了。我听完直接给他推荐了Ponytail——一个专门做网页变化监控的浏览器插件。这篇文章不写官方说明书&#xff0c;…

作者头像 李华
网站建设 2026/10/29 20:08:35

Stable Diffusion安装、txt2img调参与黑图排查指南

简介&#xff1a;这份PDF文档面向想入门AI绘画的开发者与爱好者&#xff0c;系统讲解Stable Diffusion的安装与使用全流程&#xff0c;帮助读者从零搭建可运行的图文生成环境&#xff0c;解决环境配置、依赖安装与出图调参等常见问题。整个压缩包仅含1个PDF文件&#xff0c;体积…

作者头像 李华