尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于Java Web的访客登记系统:从三层架构到CRUD与统计报表

基于Java Web的访客登记系统:从三层架构到CRUD与统计报表 简介基于Java Web的公司访客登记系统是一份课程设计完整交付包面向Java Web初学者、高校在校生及需要完成类似课设/毕设的开发者。系统采用典型Servlet/JSPJDBCMySQL三层架构实现访客信息添加、修改、删除、检索查询与分类统计可帮助企业规范来访人员管理并提升工作效率。资源包共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 图到建表 SQL2.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 登记时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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管理员 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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 ?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 里三个参数是必修课useUnicodetrue 告诉驱动按 Unicode 传输字符串characterEncodingutf8 指定字符集这两个缺一个插入中文姓名就会变成乱码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 路径请求走 POSTform action${pageContext.request.contextPath}/visitor?actionadd methodpost div label姓名/label input typetext namename required maxlength50 /div div label联系电话/label input typetext namephone maxlength20 /div div label到访部门/label select namedepartment required option value请选择/option option value行政部行政部/option option value人事部人事部/option option value技术部技术部/option /select /div div label访问目的/label textarea namepurpose rows3 maxlength200/textarea /div button typesubmit提交登记/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?actionquery); } } }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 ListVisitor queryList(String name, String department, String date) { StringBuilder sql new StringBuilder(SELECT * FROM visitor WHERE 11 ); ListString conditions new ArrayList(); ListObject 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); ListVisitor 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 11 没有实际过滤作用唯一目的是让后面追加的 AND 子句不用判断是不是第一个条件。MySQL 优化器在解析阶段就把 11 当常量处理不存在性能开销动态拼接 SQL 时逻辑最清晰。三个可选条件的顺序固定为姓名、部门、日期和列表页表单输入顺序一致阅读时容易对位。所有参数都通过 PreparedStatement 的 setString 绑定严禁用字符串拼接组合 SQL。在姓名框输入 OR 11 --时参数化查询会把整段内容当作一个普通字符串拼接版则会把它变成查询条件导致整张表被查出来这就是 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?actiondeleteid 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 聚合思考的模块。输入是筛选条件输出是分组汇总结果和普通查询返回原始记录有本质区别。按部门统计的核心 SQLSELECT 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(status0)每日访客趋势按访问目的purposeCOUNT(*)高频来访事由4.2 动态条件统计与 PreparedStatement 占位符带时间范围的统计逻辑放进 DAOpublic MapString, 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; MapString, 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 border1 cellpadding6 cellspacing0 tr th部门/thth访客数/thth占比/thth分布/th /tr !-- statMap 是后台传入的 LinkedHashMap -- c:forEach varentry items${statMap} tr td${entry.key}/td td${entry.value}/td tdfmt:formatNumber value${entry.value / total} pattern0.0%//td td div stylebackground:#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 PathThe server time zone value ... unrecognizedMySQL 8.x 未配置时区连接 URL 加 serverTimezoneAsia/ShanghaiHTTP Status 500 - NullPointerException at VisitorServletgetParameter 名称与表单 name 不一致逐字核对 name 属性与参数名插入数据后中文显示 ??字符集链路断裂JSP pageEncoding、连接 URL、数据库 CHARSET 三处统一 utf8Port 8080 required by Tomcat ... already in use8080 被其他进程占用换端口或结束占用进程HTTP 404 访问 /visitorWebServlet 路径与 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 语法错误、数据库连不上、或连接对象是 nullCaused by 那一行写的是真实原因。把根因输入搜索引擎比盯着异常堆栈猜快得多。端口冲突的处理同样直接。Windows 上执行netstat -ano | findstr 8080输出里找到 LISTENING 状态的 PID然后结束进程taskkill /F /PID PIDLinux 对应的是 lsof -i:8080 加 kill -9命令不同原理一样。简单记就是两句话报错先找 Caused by端口冲突先 findstr 8080。本文还有配套的精品资源点击获取
返回列表