一、项目背景 作为一名计科专业的学生,这学期《面向对象程序设计(Java)》的课程设计给了我一个将理论知识付诸实践的机会。我选择开发一个基于 Web 的课堂点名系统,主要解决传统课堂点名中存在的两个痛点:
随机点名不公 :纯随机点名可能导致某些学生被频繁点到而另一些学生长期被忽略
数据难以沉淀 :点名结果缺乏系统化记录和统计分析,教师无法回溯历史点名情况
经过几周的努力,我完成了这个名为 AttendanceSystem 的项目。它不仅仅是一个点名工具,更是一个完整的学生管理与数据统计平台。
二、技术选型 在技术栈的选择上,我采用了经典的 Java Web 组合:
层次
技术
后端核心
Java Servlet + JSP
数据库
MySQL 8.0
数据库连接
JDBC + 自封装 JDBCUtil
Excel 处理
Apache POI 5.2.5
JSON 序列化
Jackson 2.15.2
数据可视化
ECharts 5.5.0
前端交互
原生 JavaScript + Fetch API
服务器
Apache Tomcat 9.0
整套系统采用 MVC 分层架构 ,分为控制层(Servlet 接收请求分发)、服务层(Service 处理业务逻辑)、数据访问层(DAO 封装 SQL)和模型层(Model 数据实体),各层职责清晰。
包结构 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 com.attendance ├── model/ 数据实体层 │ ├── Student.java 学生实体 │ ├── Record.java 点名记录实体 │ └── User.java 用户实体 ├── dao/ 数据访问层 │ ├── StudentDao.java 学生表 CRUD + 批量操作 │ ├── RecordDao.java 点名记录表操作 + 统计查询 │ └── UserDao.java 用户表操作 ├── service/ 业务服务层 │ ├── CallService.java 点名核心算法 + 事务管理 │ └── StudentService.java 学生管理 + Excel/文本解析导入 ├── servlet/ 控制层 │ ├── CallServlet.java 点名 Ajax 接口 │ ├── StatServlet.java 统计报表 + CSV 导出 │ ├── ImportServlet.java 文件上传 + 批量导入 │ ├── StudentServlet.java 学生编辑/删除/清空接口 │ ├── LoginCheckServlet.java 登录验证(查数据库) │ └── RegisterServlet.java 注册接口 └── util/ 工具层 └── JDBCUtil.java 数据库连接工具
三、核心功能模块 3.1 用户注册与登录模块 系统支持多用户。注册时校验用户名是否重复(查数据库)、两次密码是否一致;登录时查询 sys_user 表验证用户名和密码。首次启动时自动创建 admin/123 默认管理员账号。
数据库建表 :
1 2 3 4 5 6 CREATE TABLE IF NOT EXISTS sys_user ( id INT AUTO_INCREMENT PRIMARY KEY , username VARCHAR (50 ) NOT NULL UNIQUE , password VARCHAR (50 ) NOT NULL , role VARCHAR (20 ) DEFAULT 'teacher' ) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4;
RegisterServlet.java — 注册接口 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 @WebServlet("/register") public class RegisterServlet extends HttpServlet { private UserDao userDao = new UserDao (); protected void doPost (HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8" ); String username = req.getParameter("username" ); String password = req.getParameter("password" ); String password2 = req.getParameter("password2" ); if (username == null || username.trim().length() < 2 ) { req.setAttribute("error" , "用户名至少2个字符" ); req.getRequestDispatcher("register.jsp" ).forward(req, resp); return ; } if (password == null || password.length() < 3 ) { req.setAttribute("error" , "密码至少3位" ); req.getRequestDispatcher("register.jsp" ).forward(req, resp); return ; } if (!password.equals(password2)) { req.setAttribute("error" , "两次密码不一致" ); req.getRequestDispatcher("register.jsp" ).forward(req, resp); return ; } if (userDao.findByUsername(username.trim()) != null ) { req.setAttribute("error" , "用户名已存在" ); req.getRequestDispatcher("register.jsp" ).forward(req, resp); return ; } boolean ok = userDao.addUser(new User (username.trim(), password)); if (ok) { req.setAttribute("msg" , "注册成功,请登录" ); req.getRequestDispatcher("login.jsp" ).forward(req, resp); } } }
LoginCheckServlet.java — 登录验证(查数据库) :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 @WebServlet("/loginCheck") public class LoginCheckServlet extends HttpServlet { private UserDao userDao = new UserDao (); protected void doPost (HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username" ); String password = req.getParameter("password" ); User user = userDao.findByUsername(username); if (user != null && user.getPassword().equals(password)) { HttpSession session = req.getSession(); session.setAttribute("user" , username); session.setAttribute("role" , user.getRole()); resp.sendRedirect("call.jsp" ); return ; } if ("admin" .equals(username) && "123" .equals(password) && userDao.countAll() == 0 ) { userDao.addUser(new User ("admin" , "123" )); req.getSession().setAttribute("user" , username); resp.sendRedirect("call.jsp" ); return ; } req.setAttribute("errorMsg" , "用户名或密码错误" ); req.getRequestDispatcher("login.jsp" ).forward(req, resp); } }
3.2 学生管理模块 学生管理是系统的基础,涵盖全部 CRUD 操作。
3.2.1 批量导入学生 支持两种导入方式:
文件导入 :上传 Excel (.xlsx/.xls) 或 CSV/TXT 文件,后端用 Apache POI 解析,自动检测表头,兼容 3 列(学号/姓名/班级)和 4 列(学号/姓名/性别/班级)格式
手动录入 :网页端动态添加表格行,逐行填写后批量提交
StudentService.java — Excel 解析 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 public List<Student> parseExcel (InputStream is, String fileName) throws IOException { List<Student> list = new ArrayList <>(); Workbook wb = fileName.endsWith(".xlsx" ) ? new XSSFWorkbook (is) : new HSSFWorkbook (is); Sheet sheet = wb.getSheetAt(0 ); int start = 0 ; boolean hasGender = false ; Row hdr = sheet.getRow(0 ); if (hdr != null && hdr.getCell(0 ) != null ) { String v = getStr(hdr.getCell(0 )); if ("学号" .equals(v) || "姓名" .equals(v) || "name" .equalsIgnoreCase(v)) { start = 1 ; for (int c = 0 ; c <= 3 ; c++) { if ("性别" .equals(getStr(hdr.getCell(c)).trim())) { hasGender = true ; break ; } } } } for (int i = start; i <= sheet.getLastRowNum(); i++) { Row row = sheet.getRow(i); if (row == null ) continue ; String sid = getStr(row.getCell(0 )).trim(); String name = getStr(row.getCell(1 )).trim(); String gender = hasGender ? getStr(row.getCell(2 )).trim() : "" ; String cls = hasGender ? getStr(row.getCell(3 )).trim() : getStr(row.getCell(2 )).trim(); if (!sid.isEmpty() || !name.isEmpty()) list.add(new Student (sid, name, gender, cls)); } wb.close(); return list; }
重复学号检测 (batchImport 方法核心逻辑):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public Map<String, Object> batchImport (List<Student> students) { int success = 0 , fail = 0 , dup = 0 ; Set<String> seen = new HashSet <>(); Connection conn = null ; try { conn = JDBCUtil.getConnection(); conn.setAutoCommit(false ); for (Student s : students) { if (s.getStudentId() == null || s.getStudentId().trim().isEmpty() || s.getName() == null || s.getName().trim().isEmpty()) { fail++; continue ; } if (seen.contains(s.getStudentId())) { dup++; continue ; } seen.add(s.getStudentId()); if (studentDao.findByStudentId(s.getStudentId()) != null ) { dup++; continue ; } studentDao.addStudent(conn, s); success++; } conn.commit(); } catch (SQLException e) { if (conn != null ) conn.rollback(); } finally { } result.put("success" , success); result.put("fail" , fail); result.put("dup" , dup); return result; }
3.2.2 学生编辑与删除 点名页面的学生列表中,每条记录旁有「编辑」和「删除」按钮:
编辑 :点击后该行变为可编辑状态(姓名 input、性别 select、班级 input),修改后点「保存」发送 POST 请求到 /student?action=edit
删除 :确认后 POST 到 /student?action=delete,后端使用事务删除关联的点名记录和该学生
3.2.3 按班级删除 & 一键清空 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public int deleteByClass (String className) { Connection conn = null ; try { conn = JDBCUtil.getConnection(); conn.setAutoCommit(false ); stmt.execute("SET FOREIGN_KEY_CHECKS = 0" ); "DELETE FROM attendance_record WHERE student_id IN (SELECT student_id FROM student WHERE class_name = ?)" "DELETE FROM student WHERE class_name = ?" stmt.execute("SET FOREIGN_KEY_CHECKS = 1" ); conn.commit(); } catch ... { rollback } }
3.3 ★ 智能点名模块 这是系统的核心。传统随机点名完全靠概率,我设计了一个基于被点名次数的公平调度算法 ,并加入了连续答错保底机制 。
3.3.1 公平调度算法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public Student selectStudent (boolean needHighScore) { List<Student> all = studentDao.getAllStudents(); if (all.isEmpty()) return null ; if (needHighScore) { List<Student> high = all.stream() .filter(s -> s.getTotalCalled() > 0 ) .collect(Collectors.toList()); if (!high.isEmpty()) { int max = high.stream() .mapToInt(Student::getTotalCorrect).max().orElse(0 ); List<Student> c = high.stream() .filter(s -> s.getTotalCorrect() == max) .collect(Collectors.toList()); return c.get(new Random ().nextInt(c.size())); } } int min = all.stream() .mapToInt(Student::getTotalCalled).min().orElse(0 ); List<Student> c = all.stream() .filter(s -> s.getTotalCalled() == min) .collect(Collectors.toList()); return c.get(new Random ().nextInt(c.size())); }
算法逻辑:将所有学生按 total_called(被点名次数)升序排列,取最小值的群体,从中随机抽一个。当连续答错达到阈值(默认3次)时,切换到「高分模式」,从答对次数最高的学生中抽取。
3.3.2 前端异步点名与记录 call.jsp 通过 Fetch API 与后端 Ajax 交互,无需刷新页面:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 callBtn.onclick = function ( ) { fetch ("call?action=getStudent" ) .then (r => r.json ()) .then (data => { if (data && data.studentId ) { curSid = data.studentId ; display.innerHTML = data.name + '<div class="sub">' + data.studentId + '</div>' ; okBtn.disabled = false ; noBtn.disabled = false ; } }); }; function record (ok ) { fetch ("call?action=record&studentId=" + encodeURIComponent (curSid) + "&isCorrect=" + ok) .then (r => r.text ()) .then (result => { if (result === "success" ) { msg.textContent = ok ? "答对了!" : "答错了" ; setTimeout (() => location.reload (), 800 ); } }); }
3.3.3 点名事务保护 每次点名需同时完成「更新学生统计」和「插入点名记录」两个操作,必须在同一事务中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public boolean recordResult (String studentId, boolean isCorrect) { Connection conn = null ; try { conn = JDBCUtil.getConnection(); conn.setAutoCommit(false ); if (!studentDao.updateCallResult(conn, studentId, isCorrect)) { conn.rollback(); return false ; } if (!recordDao.addRecord(conn, new Record (studentId, new Date (), isCorrect))) { conn.rollback(); return false ; } conn.commit(); return true ; } catch (SQLException e) { if (conn != null ) conn.rollback(); return false ; } finally { } }
3.4 统计报表模块 点名数据只有被看见才有价值。统计模块通过 StatServlet 提供页面展示和 CSV 导出。
3.4.1 ECharts 图表展示 1 2 3 4 5 6 7 8 9 10 11 12 13 14 var ch = echarts.init (document .getElementById ("chart" ));ch.setOption ({ tooltip : { trigger : "axis" }, xAxis : { type : "category" , data : names }, yAxis : [ { type : "value" , name : "次数" }, { type : "value" , name : "正确率%" } ], series : [ { name : "点名次数" , type : "bar" , data : calledCounts, color : "#4a90d9" }, { name : "正确率%" , type : "line" , yAxisIndex : 1 , data : rates, color : "#27ae60" } ] });
3.4.2 CSV 导出(Excel 友好) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 if ("csv" .equals(fmt)) { resp.setContentType("text/csv;charset=UTF-8" ); resp.setHeader("Content-Disposition" , "attachment; filename=\"stats_" + timestamp + ".csv\"" ); PrintWriter w = resp.getWriter(); w.print('' ); w.println("排名,学号,姓名,性别,班级,点名次数,答对次数,正确率" ); List<Student> list = studentService.getAllStudents(); list.sort((a, b) -> Double.compare(b.getCorrectRate(), a.getCorrectRate())); int rank = 1 ; for (Student s : list) { w.printf("%d,%s,%s,%s,%s,%d,%d,%.1f%%\n" , rank++, csv(s.getStudentId()), csv(s.getName()), csv(s.getGender()), csv(s.getClassName()), s.getTotalCalled(), s.getTotalCorrect(), s.getCorrectRate()); } }
3.5 ★ 全局搜索功能 在点名页面右侧学生列表中实现了纯前端实时过滤 ,不请求后端:
1 2 3 4 5 6 7 8 9 10 function filter ( ) { var q = document .getElementById ("search" ).value .toLowerCase (); var rows = document .querySelectorAll ("#tbody tr" ); rows.forEach (function (tr ) { tr.style .display = (!q || tr.textContent .toLowerCase ().indexOf (q) >= 0 ) ? "" : "none" ; }); }
技术亮点 :通过 textContent 匹配整行文本,一次比较覆盖学号、姓名、性别、班级所有列,无需逐列遍历。
四、数据库设计 系统使用 MySQL,设计了四张核心表:
E-R 关系 :sys_user 独立,student 与 attendance_record 通过 student_id 一对一多关联,删除学生时先删记录再删学生。
student 表 1 2 3 4 5 6 7 8 9 10 CREATE TABLE student ( id INT AUTO_INCREMENT PRIMARY KEY , student_id VARCHAR (50 ) NOT NULL UNIQUE , name VARCHAR (50 ) NOT NULL , gender VARCHAR (10 ) DEFAULT '' , class_name VARCHAR (100 ), total_called INT DEFAULT 0 , total_correct INT DEFAULT 0 , status TINYINT DEFAULT 1 ) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4;
设计考量 :total_called 和 total_correct 为冗余字段,避免每次统计都 JOIN 记录表做 COUNT 聚合。更新点名结果时直接用 UPDATE ... SET total_called = total_called + 1,一次 SQL 完成。
attendance_record 表 1 2 3 4 5 6 7 CREATE TABLE attendance_record ( id INT AUTO_INCREMENT PRIMARY KEY , student_id VARCHAR (50 ) NOT NULL , call_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP , is_correct TINYINT(1 ) DEFAULT 0 , FOREIGN KEY (student_id) REFERENCES student(student_id) ON DELETE CASCADE ) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4;
sys_user 表 1 2 3 4 5 6 CREATE TABLE sys_user ( id INT AUTO_INCREMENT PRIMARY KEY , username VARCHAR (50 ) NOT NULL UNIQUE , password VARCHAR (50 ) NOT NULL , role VARCHAR (20 ) DEFAULT 'teacher' ) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4;
五、★ 开发中遇到的三个核心困难 困难一:外键约束导致删除失败 问题 :最初删除学生时,attendance_record 表的 FOREIGN KEY 约束阻止了删除操作,因为该学生有历史点名记录。
解决 :在 DAO 的 deleteStudent() 方法中,使用 SET FOREIGN_KEY_CHECKS = 0 临时关闭外键约束,先删除关联记录,再删除学生,最后恢复约束:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public boolean deleteStudent (String studentId) { Connection conn = null ; try { conn = JDBCUtil.getConnection(); conn.setAutoCommit(false ); stmt.execute("SET FOREIGN_KEY_CHECKS = 0" ); "DELETE FROM attendance_record WHERE student_id = ?" "DELETE FROM student WHERE student_id = ?" stmt.execute("SET FOREIGN_KEY_CHECKS = 1" ); conn.commit(); } catch ... { conn.rollback(); } finally { stmt.execute("SET FOREIGN_KEY_CHECKS = 1" ); } }
收获 :异常处理中必须考虑恢复现场 ——无论是否发生异常,SET FOREIGN_KEY_CHECKS = 1 必须在 finally 中再次执行,否则整个数据库会话的外键检查都被永久关闭。
困难二:双表操作的事务一致性 问题 :点名时需要同时执行两个 SQL——更新 student 表的统计字段和向 attendance_record 表插入记录。最初两个操作各自从 JDBCUtil.getConnection() 获取独立连接,导致一个成功一个失败时数据不一致(学生统计更新了但记录没写入)。
解决 :在 CallService 层统一获取一个 Connection 对象,关闭自动提交,将同一个连接作为参数传入两个 DAO 方法,最后统一 commit 或 rollback:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public boolean recordResult (String studentId, boolean isCorrect) { Connection conn = null ; try { conn = JDBCUtil.getConnection(); conn.setAutoCommit(false ); if (!studentDao.updateCallResult(conn, studentId, isCorrect)) { conn.rollback(); return false ; } if (!recordDao.addRecord(conn, new Record (...))) { conn.rollback(); return false ; } conn.commit(); return true ; } catch (SQLException e) { if (conn != null ) conn.rollback(); return false ; } }
收获 :良好的分层设计需要为事务场景预留灵活性。DAO 方法应当提供接受外部 Connection 参数的重载版本,让 Service 层有能力控制事务边界。
困难三:JSP 模板字符串与 EL 表达式冲突 问题 :前端 JavaScript 中使用了 ES6 的模板字符串语法 `${变量名}`,与 JSP 的 EL 表达式(${...})语法完全一致。Tomcat 的 JSP 编译器在编译 .jsp 文件时,将 <script> 标签内的 ${...} 当作 JSP 表达式处理,导致 ClassNotFoundException 和 500 Internal Server Error。
错误日志 :
1 2 org.apache.jasper.JasperException: java.lang.ClassNotFoundException: org.apache.jsp.student_005fmanage_jsp
解决 :将所有 JavaScript 模板字符串改为传统字符串拼接(+ 连接),同时将箭头函数 => 改为 function,let/const 改为 var,确保最大兼容性:
1 2 3 4 5 rows.push (`<tr><td>${s.studentId} </td></tr>` ); rows.push ('<tr><td>' + s.studentId + '</td></tr>' );
收获 :JSP 虽然是 Java Web 开发的经典技术,但在与现代 JavaScript 混用时存在语法冲突。后续可以考虑前后端分离(如 Vue.js + REST API)彻底避免这类问题。
问题 :编辑/删除学生时,前端使用 FormData 封装参数并通过 fetch POST。但后端 request.getParameter("action") 始终返回 null,导致操作始终返回「未知操作」。
原因 :FormData 默认使用 multipart/form-data 编码,Servlet 容器需要 @MultipartConfig 注解才会解析 multipart 请求体中的参数。
解决 :在 StudentServlet 和 SysServlet 上添加 @MultipartConfig 注解:
1 2 3 @WebServlet("/student") @MultipartConfig public class StudentServlet extends HttpServlet { ... }
六、★ 项目亮点 6.1 公平调度点名算法 不是简单的 Math.random(),而是基于「最少被点名优先」原则的加权随机。同时加入连续答错保底机制,避免了「差生永远答不出」的尴尬循环。
6.2 数据库冗余字段设计 student 表的 total_called 和 total_correct 是统计冗余字段。每次点名时用 UPDATE ... SET total_called = total_called + 1 原子更新,避免了 SELECT COUNT(*) FROM attendance_record GROUP BY student_id 的昂贵查询。
6.3 纯前端实时搜索 一行 tr.textContent.indexOf(q) 完成多列匹配,过滤延迟为零。
6.4 批量导入三级校验 文件导入时经历了「前端去重 → 数据库查重 → SQL 异常兜底」三级防护,确保脏数据不进库。
6.5 CSV 导出 BOM 兼容 导出 CSV 时在文件头写入 UTF-8 BOM (),确保用 Excel 打开时中文不乱码。
七、不足与改进方向
密码明文存储 :当前 sys_user 表密码为明文,应当改为 BCrypt 哈希存储
点名算法维度单一 :目前只在全局范围内按次数分派,后续可以增加按班级维度点名的模式
缺少单元测试 :重构时容易引入回归 bug,应补充 JUnit + Mockito 测试
前端代码冗余 :原生 JavaScript 操作 DOM 代码量大,后续可引入 Vue.js 简化数据绑定
数据库连接 :目前使用 DriverManager 每次获取新连接,生产环境应改用 Druid/HikariCP 连接池
无权限控制 :目前只有 role 字段但未实际使用,应区分管理员/教师权限
八、课程设计感想 这次课程设计让我完整经历了一个 Java Web 项目从零到一的开发过程。
最大的收获不是写出多少行代码,而是踩坑后的思考 。外键约束删除失败让我第一次认真阅读了 MySQL 关于 FOREIGN_KEY_CHECKS 的文档;事务一致性问题让我理解了为什么 Spring 选择声明式事务而非编程式事务;JSP 与 JavaScript 的语法冲突让我明白了前后端分离的价值。
每一个 bug 都是一次学习机会。从 500 Internal Server Error 的茫然,到能根据堆栈信息定位到具体代码行,解决问题的效率在提升。我曾经花了两个多小时排查 request.getParameter() 返回 null 的问题,最后发现是 @MultipartConfig 注解没有加——这种经验在课本上是学不到的。
另一个深有感触的是:好的设计需要前瞻性 。比如 DAO 层提供接受外部 Connection 的方法,最初觉得是过度设计,但当需要事务保护时发现这是必要的。如果一开始就为这种场景预留了接口,后期改动的代价就小得多。
九、展望 马上要做的事 :
使用 Spring Boot + MyBatis 重构项目,用 AOP 管理事务、用注解替代 Servlet 配置
密码改为 BCrypt 加密存储
引入 Redis 缓存学生列表,减少数据库查询
补充 JUnit 单元测试覆盖核心 DAO 和 Service 方法
未来想尝试的方向 :
用 Vue.js 重构前端,实现前后端分离
增加 WebSocket 实现多点名终端同步(教师屏点名,学生屏实时显示结果)
用 Docker 容器化部署(MySQL + Tomcat + 应用一键启动)
学习 Spring Security 实现 OAuth2 登录