一、项目背景

作为一名计科专业的学生,这学期《面向对象程序设计(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;
}
// 首次启动:数据库为空时 admin/123 自动入库
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 { /* 恢复 + 关闭连接 */ }
// 返回 success / fail / dup 三指标
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
// StudentDao.java — 按班级删除
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
// CallService.java
public Student selectStudent(boolean needHighScore) {
List<Student> all = studentDao.getAllStudents();
if (all.isEmpty()) return null;

// 模式1:连续答错超阈值 → 从高分学生中抽取
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()));
}
}

// 模式2:正常模式 → 优先选被点名次数最少的学生
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
// CallService.java — recordResult
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 { /* 恢复 autoCommit + 关闭连接 */ }
}

3.4 统计报表模块

点名数据只有被看见才有价值。统计模块通过 StatServlet 提供页面展示和 CSV 导出。

3.4.1 ECharts 图表展示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// stat.jsp — 柱状图(点名次数)+ 折线图(正确率)
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
// StatServlet.java
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(''); // UTF-8 BOM 确保 Excel 正确识别中文
w.println("排名,学号,姓名,性别,班级,点名次数,答对次数,正确率");
List<Student> list = studentService.getAllStudents();
list.sort((a, b) -> Double.compare(b.getCorrectRate(), a.getCorrectRate()));
int rank = 1;
for (Student s : list) {
// csv() 方法自动处理逗号、引号转义
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 独立,studentattendance_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_calledtotal_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 {
// 确保 finally 中恢复外键检查
stmt.execute("SET FOREIGN_KEY_CHECKS = 1");
}
}

收获:异常处理中必须考虑恢复现场——无论是否发生异常,SET FOREIGN_KEY_CHECKS = 1 必须在 finally 中再次执行,否则整个数据库会话的外键检查都被永久关闭。


困难二:双表操作的事务一致性

问题:点名时需要同时执行两个 SQL——更新 student 表的统计字段和向 attendance_record 表插入记录。最初两个操作各自从 JDBCUtil.getConnection() 获取独立连接,导致一个成功一个失败时数据不一致(学生统计更新了但记录没写入)。

解决:在 CallService 层统一获取一个 Connection 对象,关闭自动提交,将同一个连接作为参数传入两个 DAO 方法,最后统一 commitrollback

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 表达式处理,导致 ClassNotFoundException500 Internal Server Error

错误日志

1
2
org.apache.jasper.JasperException: java.lang.ClassNotFoundException:
org.apache.jsp.student_005fmanage_jsp

解决:将所有 JavaScript 模板字符串改为传统字符串拼接(+ 连接),同时将箭头函数 => 改为 functionlet/const 改为 var,确保最大兼容性:

1
2
3
4
5
// 错误写法(会与 JSP EL 冲突)
rows.push(`<tr><td>${s.studentId}</td></tr>`);

// 正确写法(兼容 JSP 编译器)
rows.push('<tr><td>' + s.studentId + '</td></tr>');

收获:JSP 虽然是 Java Web 开发的经典技术,但在与现代 JavaScript 混用时存在语法冲突。后续可以考虑前后端分离(如 Vue.js + REST API)彻底避免这类问题。


困难四:文件上传时 FormData 导致 request.getParameter() 获取不到参数

问题:编辑/删除学生时,前端使用 FormData 封装参数并通过 fetch POST。但后端 request.getParameter("action") 始终返回 null,导致操作始终返回「未知操作」。

原因FormData 默认使用 multipart/form-data 编码,Servlet 容器需要 @MultipartConfig 注解才会解析 multipart 请求体中的参数。

解决:在 StudentServletSysServlet 上添加 @MultipartConfig 注解:

1
2
3
@WebServlet("/student")
@MultipartConfig // 关键:启用 multipart 解析
public class StudentServlet extends HttpServlet { ... }

六、★ 项目亮点

6.1 公平调度点名算法

不是简单的 Math.random(),而是基于「最少被点名优先」原则的加权随机。同时加入连续答错保底机制,避免了「差生永远答不出」的尴尬循环。

6.2 数据库冗余字段设计

student 表的 total_calledtotal_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 打开时中文不乱码。


七、不足与改进方向

  1. 密码明文存储:当前 sys_user 表密码为明文,应当改为 BCrypt 哈希存储
  2. 点名算法维度单一:目前只在全局范围内按次数分派,后续可以增加按班级维度点名的模式
  3. 缺少单元测试:重构时容易引入回归 bug,应补充 JUnit + Mockito 测试
  4. 前端代码冗余:原生 JavaScript 操作 DOM 代码量大,后续可引入 Vue.js 简化数据绑定
  5. 数据库连接:目前使用 DriverManager 每次获取新连接,生产环境应改用 Druid/HikariCP 连接池
  6. 无权限控制:目前只有 role 字段但未实际使用,应区分管理员/教师权限

八、课程设计感想

这次课程设计让我完整经历了一个 Java Web 项目从零到一的开发过程。

最大的收获不是写出多少行代码,而是踩坑后的思考。外键约束删除失败让我第一次认真阅读了 MySQL 关于 FOREIGN_KEY_CHECKS 的文档;事务一致性问题让我理解了为什么 Spring 选择声明式事务而非编程式事务;JSP 与 JavaScript 的语法冲突让我明白了前后端分离的价值。

每一个 bug 都是一次学习机会。从 500 Internal Server Error 的茫然,到能根据堆栈信息定位到具体代码行,解决问题的效率在提升。我曾经花了两个多小时排查 request.getParameter() 返回 null 的问题,最后发现是 @MultipartConfig 注解没有加——这种经验在课本上是学不到的。

另一个深有感触的是:好的设计需要前瞻性。比如 DAO 层提供接受外部 Connection 的方法,最初觉得是过度设计,但当需要事务保护时发现这是必要的。如果一开始就为这种场景预留了接口,后期改动的代价就小得多。


九、展望

马上要做的事

  1. 使用 Spring Boot + MyBatis 重构项目,用 AOP 管理事务、用注解替代 Servlet 配置
  2. 密码改为 BCrypt 加密存储
  3. 引入 Redis 缓存学生列表,减少数据库查询
  4. 补充 JUnit 单元测试覆盖核心 DAO 和 Service 方法

未来想尝试的方向

  1. 用 Vue.js 重构前端,实现前后端分离
  2. 增加 WebSocket 实现多点名终端同步(教师屏点名,学生屏实时显示结果)
  3. 用 Docker 容器化部署(MySQL + Tomcat + 应用一键启动)
  4. 学习 Spring Security 实现 OAuth2 登录