JavaWeb教务管理系统:选课并发控制与事务一致性设计
简介面向JavaWeb初学者、课程设计学习者与毕业设计参考者的教务管理系统实战项目基于MVC设计模式与DAO模式组织分层代码使用Servlet、JSP、Filter与MySQL数据库实现可在Windows环境下配合Tomcat 7与JDK1.8运行。系统以登录和权限控制为主线划分学生、教师、教务员、系统管理员四类角色覆盖找回密码、修改密码、注销、成绩查询、选修与考级报名、学籍信息查看修改、考级成绩查询、教师个人信息维护、教务员成绩管理、报名学生名单管理及管理员用户管理等功能。压缩包共223个文件约2.6MB含82个Java源码、82个class、38个JSP页面以及若干jar、xml、sql等配置与数据脚本便于按控制层、业务层、数据访问层阅读调试。已有11659人学习适合借鉴其目录组织、过滤器拦截、数据库访问和角色权限设计并依据README说明快速启动在待完善功能上继续扩展。1. 选课第三周的那次并发决定了这套教务系统该怎么设计每年春季学期第三周选课系统都要承受一次真实的压力测试几百个学生在同一分钟里抢同一门课的几十个名额。多数教务管理系统项目就栽在这里——先select查余量、再update扣减中间隔着几毫秒窗口超卖就发生了。这套基于 JavaWeb 的教务管理系统方案就围绕这个场景把需求、架构、模块和验收完整走一遍三类角色、八张核心表、四个功能模块用 Servlet JSP JDBC 这套组合实现跑在 Tomcat 上。适合正在做毕设或课程设计、需要一份既讲得清原理又跑得起来的人。2. 需求与角色边界三类角色、八张表撑起的最小闭环动手建表之前先想清楚一件事教务系统最容易翻车的地方往往不在功能多少而在权限边界没有划分清楚。我见过不少课程设计学生登录后能在地址栏里把studentId一改看到别人的成绩。功能是齐的但这种方案拿出去答辩导师随便点两下整个方案的可信度就没了。所以第 2 章先把角色、主流程、技术选型这三件事讲清楚后面所有代码才有依据。2.1 权限边界三个角色、一张矩阵别做第四种教务系统的角色划分通常就是这么三类学生、教师、教务管理员。有些方案会再加“院系管理员”“督导”“系主任”听起来完整实际上每个多出来的角色都要配一套菜单、一套数据范围、一套测试用例工作量翻倍而答辩时并不会因此加分。我一般的建议是只做三类角色把院系维度做成教师表上的一个字段需要按院系统计时用where dept_id ?筛选即可。权限的落地方式有两种。一种是每个 JSP 页面顶部写if (role ! admin) return;这种写法在功能少的时候没问题一旦页面过了二十个你根本记不住哪个页面漏了判断。另一种是把权限判断前置到统一的入口用 URL 前缀映射角色页面里只负责展示。后者是通常的做法因为它让“有没有权限”变成一个可枚举、可测试的问题而不是靠人肉巡检。权限矩阵按“资源 动作”来列比按菜单列更细也更方便写测试用例资源学生教师管理员个人课表读自己读自己所教读全部选课记录增删自己读自己所教课程增删改全部成绩读已发布录入、修改未发布审核、发布、导出课程与开课班只读只读增删改用户与班级无无增删改这张表的价值在于它直接对应后面 Service 层的方法命名。看到“学生只能读已发布成绩”ScoreService.listForStudent()里就必然带一个status PUBLISHED的条件不允许由前端传参决定。把权限写进 SQL 条件而不是写进 if 判断是这套方案里最重要的一条设计约束。还有一条容易被忽略数据范围权限和功能权限是两回事。教师能“录入成绩”是功能权限但只能录“自己教的课”是数据范围权限。后者必须在 DAO 层用teacher_id作为参数参与查询否则教师 A 改一下表单里的classId就能改教师 B 的课。2.2 三条主流程选课、成绩、课表其余都是配套功能模块再多核心就是三条流程。把这三条跑通剩下的用户管理、公告管理、日志查询都是配套的增删改查实现上高度同构写起来很快。选课流程的判定顺序很关键顺序错了会出现“名额扣了但选课失败”的脏数据。通常的顺序是判断是否已选过唯一索引兜底→ 判断学期是否在选课窗口内 → 判断学分上限 → 判断时间冲突 → 扣减名额 → 写入选课记录。前四步都是只读校验可以放在事务外快速失败后两步必须在同一个事务里任何一步失败整体回滚。成绩流程要处理“未发布”和“已发布”两个状态。教师录入后是DRAFT可以反复修改提交后是SUBMITTED教师不能再改管理员审核通过后是PUBLISHED学生可见。很多课程设计把成绩录入做成直接可见学生端立刻刷新就能看到看着方便但一旦老师录错一位数字没有回退余地这在真实现场是要出事故的。课表流程本质是一个查询按学期查这个学生所有已选的开课班每个班有固定的星期、节次、周次范围前端按“星期 × 节次”渲染成二维网格。这里不需要复杂的排课算法教务系统里的排课是管理员线下协调好的结果系统只负责记录和展示。有些同学非要写遗传算法自动排课结果算法跑十分钟出一个仍有冲突的方案反而成了减分项。提示选课窗口、学分上限、单门课容量这三个参数不要硬编码在 Java 里放进sys_config表或常量类验收演示时改一个数字就能说明系统支持配置比讲十句设计模式管用。2.3 技术选型为什么坚持 Servlet JSP JDBC技术栈的选择要看目的。如果这套系统的目的是“练完整工程能力并能在答辩时讲清楚每一层”那 JDK 8 Servlet 3.1 JSP 2.3 JDBC MySQL 8 Tomcat 9 依然是最合适的组合。它没有中间件黑匣子请求从*.do进来经过 Filter、Servlet、Service、DAO最后到一条 SQL每一环你都能在代码里指出来。如果换成 Spring Boot MyBatis开发效率确实高但答辩时被问“这个请求是怎么到你的方法上的”多数人只能答“注解自动映射的”再往深处就答不上来。这不是说框架不能用而是说在需要讲原理的场景下自己组装一遍的价值更大。层面选型理由运行环境JDK 8 / Tomcat 9兼容性最好教程与排错资料最多表现层JSP JSTL EL无需额外构建工具页面直接部署控制层Servlet 3.1 注解映射免 web.xmlURL 一眼看清数据访问原生 JDBC PreparedStatement能讲清连接、事务、结果集映射数据库MySQL 8 InnoDB支持行锁与事务选课并发的基础前端原生 HTML/CSS/JS 少量 jQuery不引入构建链改完刷新即可需要提醒的是原生 JDBC 不等于满地try-catch-finally。DAO 层一定要抽一个BaseDao出来把连接获取、参数绑定、结果集映射、资源关闭收口到一处业务 DAO 只写 SQL 和映射。否则二十个 DAO 类里会有二十份重复的关闭逻辑其中至少有三份忘了关ResultSet。3. 总体架构设计三层结构落地成四个包和八张表分层这件事讲起来人人都会说“三层架构”但真正落到目录上很多项目是这样的Servlet 里拿了HttpServletRequest顺手就写了一段 SQL。这样的代码在演示时跑得通在改动时就是灾难——想加一个“导出成绩”的功能你会发现查询逻辑和请求参数、Session 取值搅在一起没法复用。架构设计的价值不在于画得好看而在于让“改动”这个动作有明确的落点。3.1 分层怎么切表现层、业务层、DAO 层各管什么表现层Servlet JSP只做三件事接收参数并做格式校验、调用 Service、根据结果选择跳转或转发。它不应该出现 SQL也不应该出现跨多个表的业务规则。判断标准很朴素如果把这套系统从网页换成手机接口Servlet 全部重写Service 和 DAO 一行不改那分层就是对的。业务层Service承载所有业务规则选课能不能选、成绩能不能改、名额够不够、学分超没超。它接收的是 Java 基本类型和实体对象不接收HttpServletRequest。这条约束能挡住绝大多数“业务逻辑泄漏到 Servlet”的情况。Service 方法内部负责开启和提交事务一个 Service 方法对应一个事务边界。DAO 层Repository只负责一件事把 SQL 执行掉把结果映射成实体。它不做判断不做校验不开启事务。DAO 方法名建议直接对应 SQL 语义findById、countByClassIdAndStatus、updateSelectedIfAvailable名字读出来就知道它干了什么。实体层Entity是数据库表的 Java 映射字段与列名一一对应只放 getter/setter 和一个无参构造。不要在实体里加业务方法也不要把实体直接当 VO 返回给 JSP——需要组合字段时比如课表里要同时显示课程名、教师名、教室单独建一个 VO实体保持纯净。3.2 目录与包结构按层分还是按功能分小项目按层分就够了包名统一前缀避免和第三方库冲突src/main/java/com/example/edu/ ├── common/ # 通用能力 │ ├── BaseDao.java # 连接获取、参数绑定、资源关闭 │ ├── PageBean.java # 分页结果封装 │ ├── DBUtil.java # 数据源初始化Druid 或内置连接池 │ └── BusinessException.java # 业务异常带错误码与提示语 ├── entity/ # 实体与表一一对应 │ ├── Student.java Teacher.java Course.java │ ├── CourseClass.java CourseSelection.java Score.java ├── dao/ # 数据访问只写 SQL │ ├── StudentDao.java CourseClassDao.java ScoreDao.java ├── service/ # 业务规则与事务边界 │ ├── AuthService.java SelectionService.java ScoreService.java ├── web/ # Servlet 控制器 │ ├── LoginServlet.java SelectionServlet.java ScoreServlet.java └── filter/ └── AuthFilter.java # 统一鉴权与编码 webapp/ ├── WEB-INF/views/ # JSP 页面禁止直接 URL 访问 └── static/ # css / js / 图片这里有两个细节值得说明。第一JSP 全部放进WEB-INF/views/这样浏览器无法直接访问student/list.jsp只能经过 Servlet 转发否则用户绕过 Servlet 直接打开页面鉴权和数据准备全部失效。第二common包里放BusinessExceptionService 抛出它Servlet 统一捕获后转发到错误页并带上提示语避免每个 Servlet 都写一套try-catch去拼错误字符串。3.3 数据库表设计八张表两个唯一索引表设计决定了后面代码的复杂度。八张表的分工是sys_user存登录凭证student和teacher存业务属性course是课程字典course_class是“某学期某教师开的某门课”course_selection是选课关系score是成绩semester是学期。学生选的是course_class而不是course这一点在建模时就必须分清否则同一个课程号在不同学期会冲突。-- 开课班选课的真正目标对象capacity 与 selected 是并发核心 CREATE TABLE course_class ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL COMMENT 课程字典ID, teacher_id BIGINT NOT NULL COMMENT 授课教师, semester_id BIGINT NOT NULL COMMENT 学期, classroom VARCHAR(32) NOT NULL COMMENT 上课地点, week_day TINYINT NOT NULL COMMENT 1-7, start_section TINYINT NOT NULL COMMENT 起始节次, end_section TINYINT NOT NULL COMMENT 结束节次, capacity INT NOT NULL DEFAULT 60 COMMENT 容量, selected INT NOT NULL DEFAULT 0 COMMENT 已选人数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1开放 0停开, KEY idx_semester (semester_id), KEY idx_teacher (teacher_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 选课记录唯一索引是防重复选的最后一道防线 CREATE TABLE course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_class_id BIGINT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_class (student_id, course_class_id), KEY idx_class (course_class_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 成绩一门课一个学生一条记录状态机由 status 控制 CREATE TABLE score ( id BIGINT PRIMARY KEY AUTO_INCREMENT, selection_id BIGINT NOT NULL COMMENT 关联选课记录, usual_score DECIMAL(5,2) COMMENT 平时分, exam_score DECIMAL(5,2) COMMENT 考试分, total_score DECIMAL(5,2) COMMENT 总评, grade_point DECIMAL(3,2) COMMENT 绩点, status VARCHAR(16) NOT NULL DEFAULT DRAFT COMMENT DRAFT/SUBMITTED/PUBLISHED, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_selection (selection_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;参数说明与设计取舍course_class.selected是个冗余计数理论上可以用count(course_selection)实时算但每次选课都要统计一遍再判断读放大严重所以用冗余字段 条件更新来保证一致性。uk_student_class唯一索引不是可选项——即使 Service 层已经判断过“是否已选”两个并发请求仍可能同时通过这个判断唯一索引会让第二个插入直接失败Service 捕获SQLIntegrityConstraintViolationException后转成友好提示。字符集统一utf8mb4不要用utf8否则学生姓名里生僻字或表情符号会插入失败这类问题在验收现场很难解释。所有涉及金额、分数的字段用DECIMAL不用FLOAT避免 59.999 显示成 59.99 的尴尬。4. 核心模块实现登录鉴权、抢名额、成绩录入设计讲完落到代码。这一章的四个模块是这套系统里最值得写进论文和答辩 PPT 的部分因为它们各自解决了一个具体问题而不是简单的增删改查。4.1 登录鉴权一个 Filter 拦住所有越权请求登录本身很简单难点在“登录之后怎么保证每个请求都是合法的”。做法是登录成功时把用户信息写进 Session再用一个 Filter 拦截所有请求检查 Session 和 URL 是否匹配当前角色。WebFilter(urlPatterns /*) public class AuthFilter implements Filter { // 白名单登录页、登录接口、静态资源不需要登录 private static final SetString WHITE_LIST new HashSet(Arrays.asList(/login, /login.jsp, /static)); Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; // 统一编码放这里避免每个 Servlet 都写一遍 request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); String uri request.getRequestURI().substring(request.getContextPath().length()); if (isWhite(uri)) { chain.doFilter(req, resp); return; } HttpSession session request.getSession(false); LoginUser user session null ? null : (LoginUser) session.getAttribute(LOGIN_USER); if (user null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } // 按 URL 前缀判定角色前缀与权限矩阵一一对应 if (uri.startsWith(/admin/) !ADMIN.equals(user.getRole()) || uri.startsWith(/teacher/) !TEACHER.equals(user.getRole()) || uri.startsWith(/student/) !STUDENT.equals(user.getRole())) { response.sendError(403, 无权访问); return; } chain.doFilter(req, resp); } private boolean isWhite(String uri) { return WHITE_LIST.stream().anyMatch(uri::startsWith); } }这段代码的逻辑是先放行白名单再检查 Session 里有没有登录用户最后按 URL 前缀校验角色。参数上request.getSession(false)表示“没有会话就不要新建”避免未登录用户每访问一次就创建一个空 Session 撑爆内存。sendError(403)而不是重定向到首页是因为重定向会掩盖越权行为排查问题时看不到痕迹。还有一种更细的做法把“角色 → 允许的 URL 正则”配置化写在一个 Map 里Filter 只负责匹配。这样做的好处是新增角色时不用改 Filter 代码。但要注意URL 前缀鉴权只能挡住“能不能进这个入口”挡不住“能不能看这条数据”后者必须在 SQL 里带student_id、teacher_id条件这一点在 2.1 节已经强调过代码里不要偷懒。4.2 选课容量控制用条件更新代替先查后改这是全文最该讲清楚的一段。直觉写法是1. select selected, capacity from course_class where id ? 2. if (selected capacity) { 3. update course_class set selected selected 1 where id ? 4. insert into course_selection ... 5. }问题出在第 2 行和第 3 行之间。两个学生同时执行到第 2 行都看到selected 59, capacity 60都判定“还有名额”然后都执行第 3 行selected变成 61超卖。这就是典型的检查与使用之间存在的竞态。正确做法是把判断和更新合并成一条 SQL让数据库的锁来保证原子性public void selectCourse(long studentId, long classId) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 事务边界在 ServiceDAO 不自己管 // 第一步条件更新affected 0 说明名额已满或课程停开 String sql UPDATE course_class SET selected selected 1 WHERE id ? AND status 1 AND selected capacity; int affected courseClassDao.update(conn, sql, classId); if (affected 0) { throw new BusinessException(SOLD_OUT, 该课程名额已满); } // 第二步写入选课记录唯一索引兜底防重复 selectionDao.insert(conn, studentId, classId); conn.commit(); } catch (SQLIntegrityConstraintViolationException e) { DBUtil.rollback(conn); throw new BusinessException(REPEATED, 你已经选过这门课了); } catch (BusinessException e) { DBUtil.rollback(conn); throw e; } catch (Exception e) { DBUtil.rollback(conn); throw new BusinessException(SYSTEM_ERROR, 选课失败请稍后重试); } finally { DBUtil.close(conn); } }关键在于WHERE ... AND selected capacity。MySQL 执行这条 UPDATE 时会对命中行加排他锁第二个事务必须等第一个提交后才能继续此时selected已经变成 60条件不成立affected返回 0业务层直接抛“名额已满”。不需要在 Java 里加任何 synchronized也不需要分布式锁。参数与边界说明status 1参与条件意味着停开的课程即使有余量也选不上这个判断放在 SQL 里比放在 Java 里更省一次查询。selected capacity而不是selected 1 capacity是因为 MySQL 会先算selected 1在selected capacity时读起来容易看错前者更直观。还要注意事务边界。conn.setAutoCommit(false)之后update和insert必须在同一个连接上执行所以 DAO 方法要接收Connection参数而不是自己从连接池取。这是很多项目写错的地方——两个 DAO 各自getConnection()结果根本不在一个事务里回滚时只回滚了其中一半。提示把上面这段在本地启动两个线程并发跑一百次观察course_selection的条数和course_class.selected是否一致这是答辩时最有说服力的演示比任何描述都直接。4.3 成绩录入与绩点计算成绩模块的重点不在录入界面而在状态机和计算规则。录入时教师提交的是一个班级几十条记录必须批量处理并在一个事务里完成否则中途失败会出现“一半学生有成绩、一半没有”的状态。public void saveScores(long teacherId, long classId, ListScoreInput inputs) { // 数据范围校验这门开课班必须是当前教师所授 CourseClass cc courseClassDao.findById(classId); if (cc null || cc.getTeacherId() ! teacherId) { throw new BusinessException(FORBIDDEN, 无权录入该课程成绩); } // 状态校验已提交或已发布的成绩不允许再改 if (scoreDao.existsStatusNotDraftByClass(classId)) { throw new BusinessException(LOCKED, 成绩已提交无法修改); } scoreDao.batchUpsert(classId, inputs); // 内部用 addBatch executeBatch }绩点计算通常用五分制或四分制规则必须写死在 Service 里并由参数表驱动不要在前端算。常见规则是总评 平时分 × 0.3 考试分 × 0.760 分以下绩点 060 分绩点 1.0每高 1 分增加 0.1上限 5.0。把这条规则抽成一个静态方法GradePointCalculator.of(double total)单元测试覆盖 59.5、60、60.5、100 四个边界值论文里附上这张边界测试表比写十行“本模块实现了绩点计算”有分量得多。4.4 分页与组合查询别在 JSP 里拼 SQL列表页是最容易被写烂的部分。学生列表、课程列表、成绩列表都要分页都要支持按姓名或课程名模糊查。做法是抽一个PageBeanT字段是pageNo、pageSize、total、rowsService 里查两次——一次count(*)一次limit offset, size。offset (pageNo - 1) * pageSizepageNo从请求参数取必须做上下界钳制否则用户可以传pageNo -1触发 SQL 语法错误。模糊查询的%keyword%要用PreparedStatement拼接参数里带通配符SQL 文本本身不变String sql SELECT * FROM student WHERE name LIKE ?; ps.setString(1, % kw %);。这样做的好处是预编译语句能被复用同时天然防注入。千万不要写成... LIKE % kw %那样只要有人在搜索框里输入一个单引号页面就直接报 500。5. 避坑与排查五个最容易在验收现场翻车的点下面这五个问题几乎每个用 JavaWeb 写教务系统的项目都会碰到至少三个。它们的共同特点是本地开发时不一定复现一到演示环节就冒出来。提前处理掉能省掉答辩当天一大半的慌乱。5.1 中文乱码三处都要设缺一处就白搭现象表单提交的课程名在数据库里是乱码或者查询出来的学生姓名显示成问号。原因编码涉及三个环节——浏览器提交、容器解析请求体、JDBC 写入数据库。任何一处字符集不是 UTF-8链路就断了。常见的是 Filter 里设了request.setCharacterEncoding(UTF-8)但数据库连接串没写characterEncodingutf8结果前两步正常最后一步写库时转成了默认字符集。解决固定三处配置。Filter 里设请求和响应编码见 4.1 代码JDBC URL 带上?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai建库建表统一DEFAULT CHARSETutf8mb4。改完之后清一次 Tomcat 的work目录因为编译过的 JSP 会缓存旧编码。5.2 连接泄漏跑半小时就卡死现象系统刚启动一切正常持续点几分钟后页面转圈最后报Communications link failure或连接池耗尽。原因DAO 里Connection、PreparedStatement、ResultSet没有在finally里关闭或者关闭顺序写反了、只关了ResultSet忘了关Statement。每次查询泄漏一个连接池子里的连接几十次就被吃干净。解决所有资源关闭收口到BaseDao.close(ResultSet, Statement, Connection)一个方法内部逐个判空、按 ResultSet → Statement → Connection 的顺序反向关闭并各自 try-catch。业务代码统一调用不允许自己写 close。排查时打开连接池的监控页面或者临时把池大小调到 2跑一遍全流程如果第二次点就卡住说明泄漏点在前面的方法里。5.3 时间字段对不上差 8 小时现象选课记录里的时间比实际时间少 8 小时或者查询某天的数据查不到。原因MySQL 8 的驱动默认按 JVM 时区解释DATETIME服务器时区如果是 UTC而业务代码按东八区理解就会整体偏移。另一个来源是java.util.Date与java.sql.Timestamp混用转换时又丢了一次偏移。解决JDBC URL 显式指定serverTimezoneAsia/Shanghai实体类的时间字段统一用java.time.LocalDateTimeJDBC 4.2 之后setObject和getObject原生支持不需要再手动转。跨时区部署时才考虑存 UTC校内系统一般不涉及。5.4 事务边界写错选课人数和记录对不上现象course_class.selected显示 45course_selection里只有 43 条记录。原因两个 DAO 各自从连接池取连接update和insert落在两个不同的物理连接上事务只覆盖了其中一个。异常发生时另一个已经提交数据永久不一致。解决Service 层取一次连接设为手动提交把同一个Connection一路传给 DAO 方法提交和回滚都在 Service 的try-catch里做。写完之后做一次验证故意在insert之后抛一个运行时异常看selected有没有回滚如果没回滚就是连接传错了。5.5 Session 与浏览器后退退出去还能看到数据现象学生点“退出登录”回到登录页按浏览器后退键还能看到自己的成绩页面。原因退出时只是session.invalidate()或重定向到登录页但浏览器把上一个页面放在本地缓存里后退时直接从缓存渲染根本没发请求到服务器。解决两层处理。一是退出时除了 invalidate还对所有页面响应加Cache-Control: no-store在 Filter 里统一设置让浏览器不缓存受保护页面。二是在每个需要登录的 Servlet 里再次确认 Session 中的用户存在Filter 已经做了但页面转发前再查一次成本极低避免依赖前端行为。6. 验收与答辩一份能自证的测试清单和三个必答问题方案写完只是完成了一半另一半是让别人相信它能跑。我见过不少项目功能其实做得不错但讲的时候东一榔头西一棒子导师问一句“选课并发怎么处理的”就答不上来最后分数被压得很低。验收这件事是可以提前设计的你不需要临场发挥只要手里有一张清单和三个准备好的答案。6.1 验收清单十六个用例跑完才算交付下面这张表按模块列了必须自己跑一遍的用例每条都标了预期结果。建议在答辩前一天完整执行一次把结果截图存档。表里的用例覆盖了正常路径、边界路径和异常路径三类导师挑着问的时候你至少有一半是提前验证过的。编号模块操作预期结果T01登录正确账号密码进入对应角色首页T02登录密码错误三次提示错误不泄露账号是否存在T03权限学生直接访问/admin/user/list返回 403T04权限教师修改他人课程成绩提示无权操作T05选课正常选一门有余量的课记录生成selected 1T06选课选一门已满的课提示名额已满selected 不变T07选课重复选同一门课提示已选过不产生第二条记录T08选课选与已选课程时间冲突的课提示时间冲突不写库T09选课并发两个请求抢最后一个名额只有一条成功selected 恰好等于 capacityT10成绩教师录入并保存草稿状态为 DRAFT学生不可见T11成绩提交后再次修改提示已锁定T12成绩管理员发布后学生查询可见绩点按规则计算T13成绩总评为 60 / 59.5 边界绩点分别为 1.0 / 0T14查询分页第 1 页与最后一页条数正确无重复无遗漏T15查询搜索框输入单引号不报错按普通字符处理T16稳定性连续操作 5 分钟连接不耗尽响应时间无明显劣化T09 是这张表的核心也是最容易在答辩现场被要求演示的一条。演示方法很简单开两个浏览器窗口都停留在选课确认页手动点“确认选课”中间间隔不到一秒。如果两次都成功而你只放了一个名额说明条件更新没写对。反过来如果第二次提示“名额已满”并且数据库里selected与course_selection条数一致这条就算过了。T15 用来证明 SQL 注入防护到位T16 用来证明连接没有泄漏。这两条不需要讲原理跑一遍给导师看即可。6.2 答辩现场三个必答问题第一个问题通常是“你这个系统的难点在哪”。不要答“功能比较多”或者“页面比较复杂”那等于没答。标准答案是选课并发描述检查与使用之间的竞态窗口说明你用条件更新把判断和扣减合并成一条 SQL并用唯一索引做第二道防线最后说清事务边界在 Service 层。这三句话讲完基本就够分了。第二个问题是“如果选课人数继续增长这套方案还撑得住吗”。这时候可以坦诚说明当前方案的边界单库单表下条件更新依赖行锁热门课程的热点行会成为瓶颈再往上要么按课程拆分、要么加排队机制。承认边界比硬说“完全没问题”可信得多导师也更愿意给这种回答加分。第三个问题往往落到数据一致性上“如果扣了名额但写记录失败了怎么办”。答案就是事务回滚同时指出selected是冗余计数字段所以必须有定期校对逻辑——写一个管理端的“人数校准”功能用count(*)重算所有开课班的selected这个功能实现只要十几行 SQL但在答辩时是很实的加分项。驾轻就熟之前我也翻过车。第一次做这套系统我把conn.setAutoCommit(false)写在了 DAO 里以为设了就是事务结果两个 DAO 用的不是同一个连接回滚根本不管用演示时人数对不上当场被问住。从那以后我每次写完涉及多表的写操作都强制走一遍“故意抛异常看有没有回滚”的流程再顺手跑一次人数校准 SQL 对账。这个习惯帮我省掉了至少三次返工。希望这套结构和清单能帮到你少走一遍我当年那条弯路。本文还有配套的精品资源点击获取