基于Java Web的访客登记系统:从三层架构到CRUD与统计报表
2026/9/11 21:38:48 网站建设 项目流程

简介:基于Java Web的公司访客登记系统是一份课程设计完整交付包,面向Java Web初学者、高校在校生及需要完成类似课设/毕设的开发者。系统采用典型Servlet/JSP+JDBC+MySQL三层架构,实现访客信息添加、修改、删除、检索查询与分类统计,可帮助企业规范来访人员管理并提升工作效率。资源包共64个文件,内含Java源码、JSP页面、编译后的class文件、SQL数据库脚本、项目配置文件以及课程设计报告文档等,包体约966KB,结构清晰,源码与文档分离,便于直接导入IDE运行和学习。目前已有127人学习下载。通过源码阅读与报告分析,读者能掌握Java Web项目从数据库设计到前后端交互的完整流程,理解MVC分层思想与JDBC操作要点;也可直接作为课程设计参考模板,快速复用或二次开发,并从中学习良好的编程习惯与项目组织方式。

1. 访客登记系统为什么值得自己动手写一套

写字楼前台的纸质登记簿,是大多数公司访客管理的真实状态。访客自己填姓名电话,字迹潦草、信息不全,行政下班前还要手动录入 Excel 做月报。这套基于 Java Web 的公司访客登记系统要解决的正是这个场景:把登记、查询、统计整条链路搬进浏览器,前台打开页面就能录入,管理层随时能按部门、日期拉出访客报表。对做课程设计的开发者来说,它又是一个典型的 Servlet + JSP + JDBC + MySQL 练习项目,五大功能模块恰好覆盖了 Web 开发的增删改查与分类统计,源码和数据库脚本解压就能跑。后文从一张访客表的设计开始,把三层架构如何落地、统计 SQL 怎么写、部署到 Tomcat 会遇到哪些坑逐个讲透。

2. 三层架构与数据库表设计:从 ER 图到建表 SQL

2.1 课程设计场景下为什么不用 Spring Boot

访客登记系统这类 CRUD 应用,用 Spring Boot 写起来确实更快,一个 JpaRepository 就能省掉大半 DAO 代码,但课程设计的验收重点往往在能不能讲清楚请求是怎么流转的。Servlet 的生命周期(init、service、doGet/doPost、destroy)是 Web 容器与业务代码之间最直接的一层契约;JSP 在第一次访问时被编译成 Servlet,这个机制决定了浏览器里看到的每一个动态页面背后都是一个 Java 类;JDBC 的 Connection、PreparedStatement、ResultSet 则是所有 ORM 框架的地基。把这些层拆开写一遍,遇到 HTTP 500 时能顺着堆栈定位到具体是 SQL 写错、参数没取到,还是类型转换失败,而不是面对一整套自动装配的黑盒。所以不是 Spring Boot 不好,而是这个项目规模下显式优于隐式。

2.2 访客表与系统用户表:ER 关系怎么定

访客登记系统的核心实体只有两个:访客(Visitor)和系统用户(SysUser)。前台人员和管理员都属于系统用户,一次登记动作由某个账号触发,但业务报表不需要统计「谁登记的」,所以 ER 图上不需要单独的关联表,两张表之间也不设外键约束。Visitor 表用一个 status 字段标识访客当前是在访还是已离开,状态由前台在访客离开时更新,查询「现在大楼里有哪些访客」只需要一条带 status 条件的 SELECT。

字段设计上,姓名、电话、来访单位、身份证号是访客的基础信息,其中姓名和到访部门加 NOT NULL 约束,因为这两项是后续查询和统计的筛选项。到访部门不要沿用「前台随便填」的思路,前端用下拉框限定候选值,后端列表也按相同口径渲染,避免「技术部」和「技术研发部」这种同义不同写法让 GROUP BY 统计结果变得不干净。访问目的是自由文本,宽度给到 200 字符就够,不要用 TEXT,因为 TEXT 类型在排序和分组时无法使用索引,而访问目的在分类统计里是参与分组的字段。

2.3 建表 SQL:字段类型与索引的取舍

项目自带的 companyvisitor.sql 脚本已经包含建库、建表和初始账号,拆解核心的两张表:

CREATE DATABASE IF NOT EXISTS companyvisitor DEFAULT CHARACTER SET utf8mb4; USE companyvisitor; CREATE TABLE visitor ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '访客ID', name VARCHAR(50) NOT NULL COMMENT '访客姓名', phone VARCHAR(20) COMMENT '联系电话', company VARCHAR(100) COMMENT '来访单位', id_card VARCHAR(18) COMMENT '身份证号', department VARCHAR(50) NOT NULL COMMENT '到访部门', purpose VARCHAR(200) COMMENT '访问目的', visit_date DATE NOT NULL COMMENT '访问日期', visit_time TIME NOT NULL COMMENT '到访时间', leave_time TIME DEFAULT NULL COMMENT '离开时间', status TINYINT DEFAULT 0 COMMENT '0在访 1已离开', create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '登记时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, role TINYINT DEFAULT 1 COMMENT '1前台 2管理员' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE INDEX idx_visit_date ON visitor(visit_date); CREATE INDEX idx_department ON visitor(department);

访客表字段说明:

字段名类型约束作用
idINT自增主键记录唯一标识
nameVARCHAR(50)NOT NULL访客姓名,查询条件
phoneVARCHAR(20)联系电话,允许为空
departmentVARCHAR(50)NOT NULL到访部门,统计分组键
purposeVARCHAR(200)访问目的,分组统计字段
visit_dateDATENOT NULL访问日期,区间查询字段
statusTINYINT默认 0在访状态,0 在访 1 已离开

visit_date 用 DATE、visit_time 用 TIME,而不是都塞进 DATETIME,这样按日期分组统计时不需要先做函数截断,索引能直接命中。status 用 TINYINT 而不是字符串「在访」,一方面避免统计 SQL 写字符串比较,另一方面整数列的过滤代价更低。id_card 用 VARCHAR(18) 而不是 CHAR(18),定长 CHAR 在比较时会有尾部空格问题,录入异常数据时容易踩坑。两个索引分别对应「按日期查访客」和「按部门做分类统计」这两条高频查询路径;注意不要给 id_card 加唯一索引,实际场景里大量访客不登记身份证号,多个 NULL 会破坏唯一性约束。

注意:执行 companyvisitor.sql 前确认当前 MySQL 账号有建库权限,否则 CREATE DATABASE 阶段就会直接报权限拒绝,后面的表全部不会建立。

2.4 数据库连接工具类:一次连接,处处复用

DAO 层每个方法都要获取连接,项目里 src 下的 DBUtil.java 统一管理这件事:

package com.company.visitor.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/companyvisitor" + "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; // 改成你本机的 MySQL 账号 private static final String PASSWORD = "123456"; // 改成你本机的 MySQL 密码 static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("MySQL 驱动加载失败,请检查 jar 包是否放入 WEB-INF/lib"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(AutoCloseable... resources) { for (AutoCloseable r : resources) { if (r != null) { try { r.close(); } catch (Exception e) { e.printStackTrace(); } } } } }

URL 里三个参数是必修课:useUnicode=true 告诉驱动按 Unicode 传输字符串,characterEncoding=utf8 指定字符集,这两个缺一个插入中文姓名就会变成乱码;serverTimezone 是 MySQL 8.x 驱动新增的必填项,不写会直接报 Server timezone 未指定的异常。USER 和 PASSWORD 要替换成本机实际账号,课程设计报告里不要提交真实密码。close 方法用可变参数接收 Connection、PreparedStatement、ResultSet,按顺序自动关闭,比每次手动 try-catch-finally 干净得多。

3. 核心 CRUD 实现:Servlet 控制层与 JDBC 持久层

3.1 JSP 表单与列表页的数据流转

访客登记的前端入口是 addVisitor.jsp 里的表单,action 指向配置好的 Servlet 路径,请求走 POST:

<form action="${pageContext.request.contextPath}/visitor?action=add" method="post"> <div> <label>姓名</label> <input type="text" name="name" required maxlength="50"> </div> <div> <label>联系电话</label> <input type="text" name="phone" maxlength="20"> </div> <div> <label>到访部门</label> <select name="department" required> <option value="">请选择</option> <option value="行政部">行政部</option> <option value="人事部">人事部</option> <option value="技术部">技术部</option> </select> </div> <div> <label>访问目的</label> <textarea name="purpose" rows="3" maxlength="200"></textarea> </div> <button type="submit">提交登记</button> </form>

表单通过 POST 提交,避免中文参数被拼到 URL 上。required 和 maxlength 是浏览器端的第一层校验,能挡掉空提交和超长文本,但后端拿到参数后仍然要做同样的非空判断,因为绕过浏览器的请求工具完全不会理会这些属性。pageContext.request.contextPath 取的是当前应用上下文路径,部署时项目名字一改,这个值会自动跟着变,比写死 /companyvisitor 更抗折腾。

提交成功后的跳转选型有一个关键点:用 forward 转发,浏览器地址栏不变,刷新会重复提交表单,访客表里多两条相同记录;用 sendRedirect 重定向,浏览器收到 302 后重新发起一次 GET,地址栏变成列表 URL,刷新不会重复写库。登记这种写操作统一走重定向,提示信息放 session 里做一次性展示,这是避免重复提交的标准做法。

3.2 控制层 Servlet 的路由与参数解析

VisitorServlet 是整个后端唯一的入口,通过 action 参数区分功能:

package com.company.visitor.servlet; import com.company.visitor.dao.VisitorDao; import com.company.visitor.entity.Visitor; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; @WebServlet("/visitor") public class VisitorServlet extends HttpServlet { private final VisitorDao visitorDao = new VisitorDao(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("query".equals(action)) { queryList(req, resp); // 查询列表 } else if ("delete".equals(action)) { deleteVisitor(req, resp); // 删除记录 } else if ("editPage".equals(action)) { showEditPage(req, resp); // 跳转编辑页 } else { queryList(req, resp); // 默认查列表 } } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("add".equals(action)) { addVisitor(req, resp); // 新增登记 } else if ("update".equals(action)) { updateVisitor(req, resp); // 修改资料 } else { resp.sendRedirect(req.getContextPath() + "/visitor?action=query"); } } }

action 路由表:

action 参数请求方式对应功能
queryGET查询访客列表,支持多条件过滤
addPOST新增访客登记
editPageGET跳转编辑页并回显原数据
updatePOST提交修改后的访客信息
deleteGET删除访客记录

一个 Servlet 同时处理查询、新增、修改、删除,通过 action 分发。GET 只服务幂等操作——查询列表和打开编辑页,POST 服务写操作——新增和更新,这样划分符合 HTTP 语义,也降低刷新页面误提交的概率。每个 private 方法内部只做一件事:从 request 取参数、组装实体、调用 DAO、重定向回列表。这个结构对应 MVC 的 Controller 职责,将来换 Spring MVC,把 @WebServlet 换成 @Controller,把 req.getParameter 换成方法参数绑定,业务逻辑几乎不用动。答辩时能讲清楚这一点,框架相关的问题就答完了一半。

提示:把新增和修改限制为 POST 请求,是防止表单重复提交最有效的措施,比前端禁用一个按钮的层级更高。

3.3 DAO 层的增删改查与 SQL 注入防范

VisitorDao 是数据访问层,所有 SQL 集中在这里:

public List<Visitor> queryList(String name, String department, String date) { StringBuilder sql = new StringBuilder("SELECT * FROM visitor WHERE 1=1 "); List<String> conditions = new ArrayList<>(); List<Object> params = new ArrayList<>(); if (name != null && !name.isEmpty()) { conditions.add("AND name LIKE ? "); params.add("%" + name + "%"); } if (department != null && !department.isEmpty()) { conditions.add("AND department = ? "); params.add(department); } if (date != null && !date.isEmpty()) { conditions.add("AND visit_date = ? "); params.add(date); } sql.append(String.join(" ", conditions)); sql.append("ORDER BY visit_date DESC, visit_time DESC"); List<Visitor> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql.toString())) { for (int i = 0; i < params.size(); i++) { ps.setString(i + 1, params.get(i).toString()); // 占位符从 1 开始 } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Visitor v = new Visitor(); v.setId(rs.getInt("id")); v.setName(rs.getString("name")); v.setPhone(rs.getString("phone")); v.setDepartment(rs.getString("department")); v.setVisitDate(rs.getString("visit_date")); v.setStatus(rs.getInt("status")); list.add(v); } } } catch (SQLException e) { throw new RuntimeException("查询访客列表失败", e); } return list; }

WHERE 1=1 没有实际过滤作用,唯一目的是让后面追加的 AND 子句不用判断是不是第一个条件。MySQL 优化器在解析阶段就把 1=1 当常量处理,不存在性能开销,动态拼接 SQL 时逻辑最清晰。三个可选条件的顺序固定为姓名、部门、日期,和列表页表单输入顺序一致,阅读时容易对位。

所有参数都通过 PreparedStatement 的 setString 绑定,严禁用字符串拼接组合 SQL。在姓名框输入' OR 1=1 --时,参数化查询会把整段内容当作一个普通字符串;拼接版则会把它变成查询条件导致整张表被查出来,这就是 Java 面试里 SQL 注入题目的现场版。params 列表下标从 0 开始,PreparedStatement 占位符从 1 开始,所以 setString 的索引写成 i + 1。try-with-resources 会自动关闭三个资源对象,异常路径上也能保证释放。

3.4 修改与删除的防误操作设计

删除是高风险操作,列表页每一行都带「删除」按钮,点击后先确认再发请求:

function confirmDelete(id) { if (confirm("确认删除这条访客记录吗?此操作不可恢复。")) { location.href = '${pageContext.request.contextPath}/visitor?action=delete&id=' + id; } }

confirm 弹窗只能挡住误点击,兜底靠 SQL 层设计。记录积累到几百条之后,物理删除意味着数据不可恢复,课程设计阶段建议直接升级成逻辑删除:visitor 表加 is_deleted 字段,删除时执行 UPDATE visitor SET is_deleted = 1 WHERE id = ?,所有查询默认带 is_deleted = 0 条件。这样即使删错,也能用一条 UPDATE 把记录改回来,答辩提到这个设计是明确的加分项。

修改功能要防的是更新后数据与已有记录冲突。访客表没有业务唯一键,唯一值得校验的是 phone 不能与其他记录重复:更新前先执行 SELECT COUNT(*) FROM visitor WHERE phone = ? AND id != ?,结果大于 0 就把错误信息放回 request,转发回编辑页而不是直接落库。多一次查询对低并发的登记场景完全可接受,换来的是数据不会静默出错。

4. 分类统计功能的 SQL 聚合与报表输出

4.1 按部门与日期分组的聚合 SQL

五大功能模块里,分类统计是唯一需要跳出 CRUD、用 SQL 聚合思考的模块。输入是筛选条件,输出是分组汇总结果,和普通查询返回原始记录有本质区别。按部门统计的核心 SQL:

SELECT department AS 部门, COUNT(*) AS 访客数, DATE_FORMAT(MAX(visit_date), '%Y-%m-%d') AS 最近到访日期 FROM visitor WHERE visit_date BETWEEN ? AND ? GROUP BY department ORDER BY 访客数 DESC;

GROUP BY department 把同一部门的访客记录压成一行,COUNT(*) 统计分组内记录数,回答「哪个部门访客最多」。DATE_FORMAT(MAX(visit_date), '%Y-%m-%d') 取分组内最大访问日期转成字符串,标识该部门最近接待访客的时间,用聚合函数一次取出,不必单独查记录。BETWEEN 的两个参数由统计页日期输入框传入,注意日期列上不要写 WHERE YEAR(visit_date) = 2025 这类对列做函数的条件,否则索引直接失效。

按日期维度的统计写法类似:

SELECT visit_date AS 日期, COUNT(*) AS 访客数, SUM(status = 0) AS 当前在访人数 FROM visitor GROUP BY visit_date ORDER BY visit_date DESC;

SUM(status = 0) 利用了 MySQL 布尔表达式返回 1 或 0 的特性,一次扫描同时拿到总访客数和仍在访人数,不需要拆成两个聚合查询再合并。当前在访人数可以直接放到前台首页展示。

统计维度与 SQL 写法对照:

统计维度分组字段聚合字段典型场景
按部门departmentCOUNT(*), MAX(visit_date)部门访客排名
按日期visit_dateCOUNT(*), SUM(status=0)每日访客趋势
按访问目的purposeCOUNT(*)高频来访事由

4.2 动态条件统计与 PreparedStatement 占位符

带时间范围的统计逻辑放进 DAO:

public Map<String, Integer> countByDepartment(String start, String end) { String sql = "SELECT department, COUNT(*) AS cnt FROM visitor " + "WHERE visit_date BETWEEN ? AND ? " + "GROUP BY department ORDER BY cnt DESC"; Map<String, Integer> result = new LinkedHashMap<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, start); ps.setString(2, end); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { result.put(rs.getString("department"), rs.getInt("cnt")); } } } catch (SQLException e) { throw new RuntimeException("分类统计失败", e); } return result; }

这里用 LinkedHashMap 而不是 HashMap,因为 LinkedHashMap 保持插入顺序,ResultSet 本来按 cnt 降序,顺序保留到页面就不需要再排一次。两个占位符接收起止日期,只要是 yyyy-MM-dd 格式字符串,MySQL 会自动完成 DATE 类型比较。统计条件继续变多时,复用第 3 章的动态拼接思路:一个 List 存条件子句、一个 List 存参数值、最后统一绑定,这个模式在一套项目的 DAO 里会反复出现,值得抽成公共方法。

4.3 统计报表在 JSP 中的表格渲染

统计结果通过 setAttribute 放进 request 后转发给 statistics.jsp,用 JSTL 循环渲染:

<table border="1" cellpadding="6" cellspacing="0"> <tr> <th>部门</th><th>访客数</th><th>占比</th><th>分布</th> </tr> <!-- statMap 是后台传入的 LinkedHashMap --> <c:forEach var="entry" items="${statMap}"> <tr> <td>${entry.key}</td> <td>${entry.value}</td> <td><fmt:formatNumber value="${entry.value / total}" pattern="0.0%"/></td> <td> <div style="background:#4a90d9;width:${entry.value / total * 200}px;height:16px;"></div> </td> </tr> </c:forEach> </table>

遍历 Map 时拿到的是 Entry,所以用 entry.key 和 entry.value 而不是 item。占比计算必须先让被除数和除数转成浮点,两个 int 相除结果还是 int 会直接截断成 0,缩放的宽度也全部是 0。分布列用纯 CSS 的 div 做条形图,宽度等于访客数占比乘 200,不引入图表库就能直观呈现差异,对一个课程设计的管理报表完全够用。如果验收要求更正式的图表,后端输出 JSON、前端用 ECharts 绘制即可。

5. 部署到 Tomcat 的完整流程与常见报错排查

5.1 从 Eclipse 导入到 Run on Server 的最小步骤

这个项目没有 Maven 坐标,是典型的 Eclipse Dynamic Web Project 结构:src 放 Java 源码,WebRoot 放 JSP 和 WEB-INF。导入时选 File -> Import -> Existing Projects into Workspace,选中项目根目录,确认 Project Facets 里 Dynamic Web Module 版本和本机 Tomcat 兼容。Run As -> Run on Server 之前,在项目 Properties -> Targeted Runtimes 里勾选 Tomcat 8.5 或 9.0,否则项目不会出现在可运行列表。启动后访问 http://localhost:8080/项目名/,web.xml 里没有配置 welcome-file 时根路径会 404。

MySQL 侧先执行 companyvisitor.sql 建库建表,再改 DBUtil 里的账号密码。顺序问题值得注意:先启动项目后导入数据库也能跑,但第一次请求任何页面都会报表不存在的错误,先建库再启动能少一轮排查。

5.2 高频报错对照表

报错信息原因解决办法
ClassNotFoundException: com.mysql.cj.jdbc.Driver驱动 jar 不在 WEB-INF/lib把 mysql-connector-java-8.x.jar 拷入 WEB-INF/lib 并加到 Build Path
The server time zone value ... unrecognizedMySQL 8.x 未配置时区连接 URL 加 serverTimezone=Asia/Shanghai
HTTP Status 500 - NullPointerException at VisitorServletgetParameter 名称与表单 name 不一致逐字核对 name 属性与参数名
插入数据后中文显示 ??字符集链路断裂JSP pageEncoding、连接 URL、数据库 CHARSET 三处统一 utf8
Port 8080 required by Tomcat ... already in use8080 被其他进程占用换端口或结束占用进程
HTTP 404 访问 /visitor@WebServlet 路径与 form action 不匹配核对注解值和访问路径

两条出现频率最高的报错值得展开。驱动 jar 放错位置,是 Eclipse 项目常见的坑,jar 拖进 WebRoot/WEB-INF/lib 后还要在 Build Path -> Configure Build Path -> Add JARs 里加一次,否则编译期找不到类,运行期报 ClassNotFoundException。中文乱码的排查顺序是从浏览器响应往回推:先看页面编码,再改连接参数,最后确认表字符集,四个环节只要有一个不是 utf8,中文就会在某个节点变形。

5.3 一条命令定位端口冲突与堆栈根因

500 页面不会直接告诉你错在哪,正确做法是看 Tomcat 控制台。堆栈要从下往上找 Caused by,也就是根因,而不是最上面那行红字。NullPointerException 通常只是表象,根因可能是 SQL 语法错误、数据库连不上、或连接对象是 null,Caused by 那一行写的是真实原因。把根因输入搜索引擎比盯着异常堆栈猜快得多。

端口冲突的处理同样直接。Windows 上执行:

netstat -ano | findstr 8080

输出里找到 LISTENING 状态的 PID,然后结束进程:

taskkill /F /PID <PID>

Linux 对应的是 lsof -i:8080 加 kill -9,命令不同原理一样。简单记就是两句话:报错先找 Caused by,端口冲突先 findstr 8080。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询