news 2026/10/8 10:17:26

从零解析JSP/Servlet传统架构:医院预约挂号系统的设计与落地

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零解析JSP/Servlet传统架构:医院预约挂号系统的设计与落地

简介:基于JAVA WEB的医院预约挂号系统压缩包,包含完整源码与数据库文件,面向Java Web初学者、毕业设计学生及医疗信息化开发者,解决在线预约、门诊排班、挂号信息管理等场景需求。压缩包共577个文件,约14.75MB,内含55个Java源文件、78个JSP页面、116个class文件、86张PNG界面素材及5个SQL数据库脚本,覆盖前后端页面、业务逻辑、数据库初始化和部署配置。系统基于Java EE技术栈,运用Servlet请求处理、JSP动态页面、MVC设计模式,配合MySQL完成挂号事务管理、并发控制与数据一致性,并集成验证码、邮件通知等机制;导入SQL脚本后需修改数据库连接配置方可运行。已有2211人学习下载。开发者可获取完整可运行项目,深入理解Web服务器工作原理、SQL脚本导入及连接配置流程,既适合课程设计与毕业答辩,也可为医疗挂号模块二次开发提供参考。

1. 从建表到挂号:一个 JSP/Servlet 传统架构的医院预约挂号系统到底能落地什么

很多人问我现在做 Java Web 项目,是不是就该 Spring Boot 起步,JSP/Servlet 那套早过时了。但如果你打开招聘网站的 Java 工程师岗位要求,或者翻大学课程设计、毕业设计的题目清单,医院预约挂号系统依然是最常出现的那一类。原因很简单:它业务边界清晰,有医生、科室、排班、号源、预约、取消、后台管理这些典型实体和状态流转,非常适合用来把 Servlet、JSP、JDBC、Session 这些基础组件一次打通。拿到“基于 JAVA WEB 的医院预约挂号系统(源码+数据库).zip”这个包,你真正要关心的不是它新不新,而是这套代码能不能带你跑通一条完整的 Web 开发链路,以及它暴露出来的问题你是否看得懂、改得动。

这个方向适合两类人:一类是刚学完 Java SE,想用第一个完整 Web 项目巩固基础知识的学生;另一类是准备面试、需要快速回顾传统 Java Web 分层结构的转行者。它解决的核心问题是:用最少的框架依赖,实现挂号、停诊、退号、后台审核这些真实业务逻辑,同时把 JDBC 连接、事务边界、Session 登录态这些细节暴露在你面前。下面我按一条实际可行路径来讲,从技术选型、表结构设计,到关键代码实现、部署和踩坑,全部围绕这个标题展开。

2. 先把架构立住:传统 Java Web 的三层结构、依赖关系和数据库选型

2.1 为什么不用 Spring Boot:这套系统背后的经典分层逻辑

标题里写的是“JAVA WEB”,没有限定框架。常见做法是用 JSP + Servlet + JDBC 实现,偶尔加上 MyBatis 简化数据访问。我一般建议先按照 JSP + Servlet + JDBC 做第一版,原因不只是题目要求,而是这套组合能把“请求如何进入服务器、Servlet 如何分发、JSP 如何渲染、JDBC 如何读写数据库”这条链路完整暴露出来。Spring Boot 把这些都封装了,你反而看不到请求生命周期的关键节点。

分层结构是固定的三层:表现层用 JSP 接收请求和渲染页面;控制层用 Servlet 接收表单参数、调用业务逻辑、控制页面跳转;数据访问层用 JDBC 连接数据库并执行 SQL。业务逻辑可以放在 Service 类里,也可以暂时放在 Servlet 中,但为了后续维护,建议至少独立出 DAO 层。这个结构下,每张表对应一个实体类,每类操作对应一个 DAO 类和一个 Servlet 映射路径,比如/patient/register、/doctor/schedule、/admin/audit。你不需要额外的配置文件,web.xml 负责所有 Servlet 映射和欢迎页设置。

依赖关系上,JSP 需要 Servlet API、JSTL 标签库;JDBC 需要对应的 MySQL 驱动;如果你用 Maven 管理,依赖放 pom.xml;不用 Maven 就直接把 jar 包拷到WEB-INF/lib下。数据库用 MySQL 5.7 最稳,字符集选 utf8mb4,否则你在存患者姓名和备注时经常会碰到中文乱码。

2.2 数据库设计:七张表搞定排班、挂号与权限

表结构是一次成功的最关键因素。我会设计七张核心表:用户表存医生、患者、管理员三类账号,科室表存科室编号和名称,医生表关联用户与科室,排班表存某个医生在某天某个时段的可预约号源数,挂号记录表存一次预约的状态,还有停诊表和系统配置表。

以挂号记录表为例,核心字段有reg_id、patient_id、doctor_id、schedule_id、visit_date、time_slot、status、create_time。status状态用数字存,0 表示已预约,1 表示已就诊,2 表示已取消,3 表示已退费。不要直接在表里存中文状态值,这样状态机扩展和统计都困难。排班表里必须有total_count和remaining_count,每次成功挂号时同时对remaining_count做检查并减一,这一步直接决定会不会出现“号源超卖”。

数据库脚本大致如下:

CREATE TABLE `department` ( `dept_id` INT NOT NULL AUTO_INCREMENT, `dept_name` VARCHAR(100) NOT NULL, `dept_desc` VARCHAR(500) DEFAULT NULL, PRIMARY KEY (`dept_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `user` ( `user_id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `password` VARCHAR(100) NOT NULL, `role` TINYINT NOT NULL COMMENT '1-患者,2-医生,3-管理员', `real_name` VARCHAR(50) DEFAULT NULL, `phone` VARCHAR(20) DEFAULT NULL, PRIMARY KEY (`user_id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `schedule` ( `schedule_id` INT NOT NULL AUTO_INCREMENT, `doctor_id` INT NOT NULL, `work_date` DATE NOT NULL, `time_slot` VARCHAR(20) NOT NULL COMMENT '上午/下午/夜间', `total_count` INT NOT NULL DEFAULT 0, `remaining_count` INT NOT NULL DEFAULT 0, PRIMARY KEY (`schedule_id`), UNIQUE KEY `uk_doctor_date_slot` (`doctor_id`, `work_date`, `time_slot`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `registration` ( `reg_id` INT NOT NULL AUTO_INCREMENT, `patient_id` INT NOT NULL, `doctor_id` INT NOT NULL, `schedule_id` INT NOT NULL, `visit_date` DATE NOT NULL, `time_slot` VARCHAR(20) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-已预约,1-已就诊,2-已取消', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`reg_id`), KEY `idx_schedule_id` (`schedule_id`), KEY `idx_patient_id` (`patient_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

排班表的唯一键uk_doctor_date_slot是防止同一医生同一天同一时段被插入多条排班记录。remaining_count的类型选择也很重要,INT 就好,不要随手设成INT(11)或BIGINT,这里没有那么大数字。挂号记录表用DEFAULT CURRENT_TIMESTAMP自动生成创建时间,省掉了在 Java 代码里new Date()赋值。

3. 让系统跑起来:Servlet 处理挂号请求、JDBC 事务与号源防超卖实现

3.1 先用 Maven 建出标准 Web 工程

动手写代码前先建工程。我用 Maven 的maven-archetype-webapp骨架创建,然后补全目录结构。IDEA 里新建项目时选 Maven,勾选 Web,等待骨架拉取完成,再把src/main/java标记为 Sources Root。创建后标准的目录是:

src/main/ java/ com/hospital/ controller/ -- Servlet 控制器 service/ -- 业务逻辑 dao/ -- 数据访问 entity/ -- 实体类 util/ -- 数据库连接工具等 webapp/ jsp/ -- 页面 WEB-INF/web.xml css/js/images/

pom.xml 里的核心依赖只有三个:javax.servlet-api的 provided 作用域(由 Tomcat 提供,避免打包冲突)、jstl标签库、mysql-connector-java。如果你用 JDBC 连接池,再引入druid或c3p0。不引入任何框架,保持最简。

<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency> </dependencies>

依赖版本这里要特别说明:MySQL 5.7 用5.1.49驱动没问题,如果你用的 MySQL 8.x,必须换成8.0.33并把driverClassName改成com.mysql.cj.jdbc.Driver,同时 JDBC URL 后面加上serverTimezone=Asia/Shanghai,不然连接时会直接报时区错误。

3.2 JDBC 连接工具类:一个简单可靠的 DBUtil

所有 DAO 公共部分提取到一个DBUtil,负责加载驱动、获取连接、关闭资源。习惯上把数据库配置写在jdbc.properties,再用静态代码块读取,避免数据库地址变动时重新编译。

public class DBUtil { private static String url; private static String username; private static String password; static { try { InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("jdbc.properties"); Properties props = new Properties(); props.load(in); Class.forName(props.getProperty("driver")); url = props.getProperty("url"); username = props.getProperty("username"); password = props.getProperty("password"); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } public static void close(ResultSet rs, Statement stmt, Connection conn) { if (rs != null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt != null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } } }

这个类的关键点是静态代码块里Class.forName在 JDBC 4.0 以后其实可以省略,但保留它能让老手和新手都一眼明白驱动加载时机。关闭资源要单独写,不能依赖 GC,否则连接池很快被耗尽。每次从DriverManager.getConnection拿连接,Tomcat 下频繁开关连接有一定开销,如果系统预计上线后并发量超过每分钟几百次挂号,就该换 Druid 连接池,改动也小,只需要把getConnection替换成从DruidDataSource获取。

3.3 挂号 Servlet:核心业务逻辑与事务边界

挂号是系统最核心的动作,必须在一次事务里完成三步:查排班剩余号源、判断是否大于 0、把remaining_count减一,再插入挂号记录。这三步任何一步失败都要整体回滚,否则会出现排班余量减了但挂号记录没生成,或者挂号记录生成而余量没减的情况。

我自己会这么写挂号 Servlet 的处理逻辑:

@WebServlet("/patient/register") public class RegisterServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session = req.getSession(); Object userObj = session.getAttribute("loginUser"); if (userObj == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } User user = (User) userObj; if (user.getRole() != 1) { resp.sendError(403); return; } String scheduleIdStr = req.getParameter("scheduleId"); if (scheduleIdStr == null || scheduleIdStr.isEmpty()) { req.setAttribute("error", "排班参数缺失"); req.getRequestDispatcher("/jsp/register.jsp").forward(req, resp); return; } int scheduleId = Integer.parseInt(scheduleIdStr); Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 查询排班并锁定行 String selectSql = "SELECT remaining_count FROM schedule WHERE schedule_id=? FOR UPDATE"; ps = conn.prepareStatement(selectSql); ps.setInt(1, scheduleId); rs = ps.executeQuery(); if (!rs.next()) { conn.rollback(); req.setAttribute("error", "排班不存在"); req.getRequestDispatcher("/jsp/register.jsp").forward(req, resp); return; } int remaining = rs.getInt("remaining_count"); if (remaining <= 0) { conn.rollback(); req.setAttribute("error", "该时段号源已挂完"); req.getRequestDispatcher("/jsp/register.jsp").forward(req, resp); return; } // 扣减号源 String updateSql = "UPDATE schedule SET remaining_count = remaining_count - 1 " + "WHERE schedule_id = ? AND remaining_count > 0"; ps = conn.prepareStatement(updateSql); ps.setInt(1, scheduleId); int updated = ps.executeUpdate(); if (updated == 0) { conn.rollback(); req.setAttribute("error", "号源已被抢完,请刷新后重试"); req.getRequestDispatcher("/jsp/register.jsp").forward(req, resp); return; } // 插入挂号记录 String insertSql = "INSERT INTO registration(patient_id, doctor_id, schedule_id, " + "visit_date, time_slot, status) VALUES (?,?,?,?,?,?)"; ps = conn.prepareStatement(insertSql); ps.setInt(1, user.getUserId()); // 从 schedule 表补查出 doctor_id、visit_date、time_slot ps.executeUpdate(); conn.commit(); resp.sendRedirect(req.getContextPath() + "/patient/myRegistration"); } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } e.printStackTrace(); req.setAttribute("error", "系统异常,挂号失败"); req.getRequestDispatcher("/jsp/register.jsp").forward(req, resp); } finally { DBUtil.close(rs, ps, conn); } } }

上面代码的SELECT ... FOR UPDATE是防超卖的关键,它把这一行排班数据在事务期间锁住,第二个并发请求必须等第一个事务提交或回滚后才能看到新的remaining_count。同时,UPDATE语句中又带上了AND remaining_count > 0条件,等于双重保险,即使锁机制失效,更新影响行数为 0 也会被捕获。

事务边界在这里就很明确:setAutoCommit(false)开启事务,到commit之前所有 SQL 都属于同一个事务。注意,FOR UPDATE必须使用在 InnoDB 引擎上且schedule_id是主键或唯一索引,MySQL 的行锁才能生效,如果你是 MyISAM 引擎,这一整行语句只是在表级别加锁,并发能力会明显下降。

3.4 登录与权限控制:用 Filter 挡住未登录和越权访问

挂号、查看预约、排班管理这些操作都必须先判断用户是否登录,这个逻辑如果放在每个 Servlet 里重复写,代码变得很丑,我习惯用一个LoginFilter统一拦截。在 web.xml 里注册过滤器,映射/patient/*、/doctor/*、/admin/*三类路径。

<filter> <filter-name>loginFilter</filter-name> <filter-class>com.hospital.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>loginFilter</filter-name> <url-pattern>/patient/*</url-pattern> </filter-mapping> <filter-mapping> <filter-name>loginFilter</filter-name> <url-pattern>/doctor/*</url-pattern> </filter-mapping> <filter-mapping> <filter-name>loginFilter</filter-name> <url-pattern>/admin/*</url-pattern> </filter-mapping>

Filter 里做的事情是检查 Session 里有没有loginUser对象,没有就重定向到登录页;有则继续放行。放行前再判断一下具体路径对应的角色是否匹配,比如/admin/*要求role == 3,不匹配就返回 403。这样无论未来增加多少个 Servlet,权限检查都集中在过滤器里,不会漏。

此外还要处理一个常见问题:JSP 页面本身也会被直接 URL 访问,比如用户绕过了登录页直接访问/jsp/patient/register.jsp。解决方法是把 JSP 全部放在WEB-INF目录下,外部 URL 无法直接访问,只能通过 Servletforward到这些页面。这也是传统 Java Web 项目的标准做法。

4. 后台管理与排班:医生的号源设置停诊与退号在代码里怎么处理

4.1 排班生成:按日期批量生成一周号源

医生登录后台后要能对自己的出诊日期进行排班,设置上午、下午号源总数。我给医生操作的是“快速排班”功能:选一个起始日期,连续生成七天的排班记录,每天上午下午各一个时段,每个时段默认 20 个号,医生可以单独调整。

批量插入的做法不是循环里单条executeUpdate,而是用PreparedStatement的批处理:

public void batchInsertSchedule(int doctorId, String startDate, int days) { String sql = "INSERT INTO schedule(doctor_id, work_date, time_slot, total_count, remaining_count) " + "VALUES (?,?,?,?,?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { LocalDate date = LocalDate.parse(startDate); String[] slots = {"上午", "下午"}; int defaultCount = 20; for (int i = 0; i < days; i++) { for (String slot : slots) { ps.setInt(1, doctorId); ps.setDate(2, java.sql.Date.valueOf(date.plusDays(i))); ps.setString(3, slot); ps.setInt(4, defaultCount); ps.setInt(5, defaultCount); ps.addBatch(); } } ps.executeBatch(); } catch (SQLException e) { e.printStackTrace(); } }

排班表里total_count和remaining_count初始相同。如果医生把上午号改成 30,下午改成 10,只要今天还没有产生任何挂号,我允许直接改remaining_count;如果已经有人挂了号,则只允许把remaining_count调大,不允许调小,避免出现“已约号数超过总号数”的脏数据。你拿到源码后应该先确认它是否做了这个校验,大部分课设代码都不会管这条。

4.2 停诊与短信通知:没有消息队列也能实现状态联动

停诊是另一个高频业务。医生选择某一天的某个排班记录,执行停诊操作时,不能只把排班状态改成停用,还需要把所有关联的、状态为“已预约”的挂号记录批量改成“已取消”,并给患者一个取消原因。这套联动逻辑放在一个事务里完成。

UPDATE schedule SET status = 0 WHERE schedule_id = ?; UPDATE registration SET status = 2, cancel_reason = '医生停诊' WHERE schedule_id = ? AND status = 0;

这里少了任何一条,都会出现患者到了医院才发现自己约的医生不出诊。很多简陋系统只做了第一句,导致患者记录永远是已预约状态,这是最典型的血泪经验之一。更完整一点的方案是在停诊事务提交后,把受影响的患者手机号查出来,插入到一张sms_notify表,由一个定时任务批量调用短信接口发送。这个项目规模不大,不用引 RabbitMQ,定时轮询足够,你只需要做一个简单的 Timer 或者定时线程池。

4.3 退号与退费:为什么不能只改一个状态

患者取消预约也值得单独讲。取消操作要求把挂号记录的status从 0 改成 2,并且把对应排班的remaining_count加回 1。很多人认为这只是一个 update 语句,但实际上它同样涉及两条表的变更,必须放在事务里。另外还要考虑业务规则:问诊发生前多久允许退号?通常医生停诊可以随时退,患者自己取消则限定在就诊日期前一天 24:00 前,否则系统返回“已过取消时限”。这个判断用visit_date和CURRENT_DATE比较即可。

退费逻辑在课程设计里一般不会接真实支付,只做模拟:给用户表加一个balance字段,退号成功时把挂号费加回余额。如果你想扩展成微信支付或支付宝,那需要引入支付 SDK 和回调接口,整体复杂度会大幅上升,不建议第一次做就动支付。

5. 前端页面与请求交互:JSP+JSTL 渲染预约列表,避免原生 JDBC 拼字符串

5.1 JSP 页面复用:用 include 指令拆出头部导航

页面不用做得多华丽,但结构要清晰。JSP 页面如果全部复制粘贴头部导航,后续改一个菜单就要改二十个文件,所以我通常会拆header.jsp和footer.jsp。在需要复用的页面里用<%@ include file="common/header.jsp" %>引入。注意 JSP 静态 include 是编译期合并,被包含文件里的变量不能与主页面重名。

挂号页面的核心是一张医生排班表,展示科室、医生、日期、时段和剩余号源。这个列表从后端 Servlet 查询后,通过request.setAttribute塞给 JSP,再由 JSTL 的<c:forEach>遍历渲染。下面是一个典型的排班列表片段:

<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %> <table class="table table-hover"> <thead> <tr> <th>科室</th><th>医生</th><th>日期</th><th>时段</th><th>剩余号</th><th>操作</th> </tr> </thead> <tbody> <c:forEach var="s" items="${scheduleList}"> <tr> <td>${s.deptName}</td> <td>${s.doctorName}</td> <td>${s.workDate}</td> <td>${s.timeSlot}</td> <td>${s.remainingCount}</td> <td> <c:choose> <c:when test="${s.remainingCount > 0}"> <form action="${pageContext.request.contextPath}/patient/register" method="post"> <input type="hidden" name="scheduleId" value="${s.scheduleId}" /> <button type="submit" class="btn btn-primary btn-sm">挂号</button> </form> </c:when> <c:otherwise> <span class="text-muted">已约满</span> </c:otherwise> </c:choose> </td> </tr> </c:forEach> </tbody> </table>

JSTL 的c:choose在这里非常实用,它避免了在 JSP 里写大量<% if()脚本片段。整个页面没有任何 Java 代码嵌入,维护起来比用 Scriptlet 舒服得多。${pageContext.request.contextPath}是自动获取上下文路径的写法,这样无论部署在ROOT还是/hospital子路径下,表单提交都不会路径出错。

5.2 表单提交与乱码:过滤器和编码一个都不能少

Post 表单提交中文参数乱码几乎是新手必踩的坑。Tomcat 8+ 对 GET 请求的 URI 编码默认已经是 UTF-8,但 POST 请求的 body 编码仍然默认是 ISO-8859-1,所以必须在 Servlet 读取参数之前设置request.setCharacterEncoding("UTF-8")。

比较省事的方法是写一个EncodingFilter,对所有请求设置编码:

@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); } }

注意这个过滤器一定要注册在其它过滤器之前。登录过滤器里如果不设置编码,但编码过滤器已经先行设置,问题不大;但如果你把编码过滤器的<url-pattern>写成了/jsp/*,那 Servlet 请求就不会被过滤到,依然乱码。正确写法是/*,同时 JSP 页面顶部要写<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>,三个地方保持一致,才能真正杜绝乱码。

5.3 预约记录查看:JDBC 查询结果自动封装到 List

查看“我的预约”是患者登录后的高频动作。这段代码需要联表查registration和schedule、doctor、department,然后封装成带有医生名、科室名的视图对象。如果直接在 JSP 里拿reg.doctorName,那么 DAO 查询时就要把联表结果映射到实体类的扩展字段。

我习惯的做法是先查一个RegistrationVO类,里面除了挂号记录字段外,带上doctorName、deptName、doctorTitle等展示字段。DAO 里用JOIN查询,然后循环封装。这里手写ResultSet到 VO 的转换不算复杂,但要注意rs.next()循环里返回的是同一个对象还是新对象——必须每次new一个 VO,否则列表里全是最后一条记录。这个问题我见过太多次了,本质上就是复用了同一个对象。

public List<RegistrationVO> findMyRegistrations(int patientId) { String sql = "SELECT r.reg_id, r.visit_date, r.time_slot, r.status, " + "d.real_name AS doctor_name, d.title, dp.dept_name " + "FROM registration r " + "JOIN user d ON r.doctor_id = d.user_id " + "JOIN department dp ON d.dept_id = dp.dept_id " + "WHERE r.patient_id = ? ORDER BY r.visit_date DESC"; List<RegistrationVO> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, patientId); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { RegistrationVO vo = new RegistrationVO(); vo.setRegId(rs.getInt("reg_id")); vo.setVisitDate(rs.getDate("visit_date")); vo.setTimeSlot(rs.getString("time_slot")); vo.setStatus(rs.getInt("status")); vo.setDoctorName(rs.getString("doctor_name")); vo.setTitle(rs.getString("title")); vo.setDeptName(rs.getString("dept_name")); list.add(vo); } } } catch (SQLException e) { e.printStackTrace(); } return list; }

这段 JDBC 代码展示了 Java Web 项目里最常见的查询封装模式。如果你拿到源码之后决定引入 MyBatis,那么这类 DAO 方法会大幅简化,映射关系由 SQL 注解或 XML 控制,DAO 层就不需要手写ResultSet了。但从学习 JDBC 的角度,至少先手写一遍,后面用框架时排错才有底。

6. 落地部署与避坑手册:从 IDEA 导 WAR 到 Tomcat,五个高频故障全排查

6.1 部署到 Tomcat:两种方式,建议直接打 WAR 包

本地跑通之后,要部署到服务器,常见做法是打一个 WAR 包放进 Tomcat 的webapps目录。IDEA 里点 Build -> Build Artifacts -> Build,选择xxx:war exploded与xxx:war两种模式。前者是开发调试用,后者是最终部署用。WAR 包里必须包含WEB-INF/classes(编译后的 class 文件和配置文件)和WEB-INF/lib(所有依赖 jar)。

部署时先停 Tomcat,删除旧的解压目录,再放新的 WAR,避免文件冲突。启动后访问地址是http://localhost:8080/项目名/,如果希望直接通过根路径访问,可以把 WAR 包改名为ROOT.war,同时先删除旧的ROOT目录。这里要注意 Tomcat 的端口是 8080,如果你服务器上有防火墙,要提前放行 8080 端口,否则远程访问会一直被卡在连接超时上。

6.2 避坑手册:按现象、原因、解决整理的五条踩坑记录

6.2.1 部署后报 ClassNotFoundException: com.mysql.jdbc.Driver

现象:Tomcat 启动后访问任意页面,后台打印ClassNotFoundException: com.mysql.jdbc.Driver,系统完全连不上数据库。

原因:MySQL 驱动 jar 没有打进 WAR 包的WEB-INF/lib,或使用 Maven 时把scope写成了provided,导致 jar 没有进入最终产物。

解决:检查WEB-INF/lib是否包含mysql-connector-java-x.x.x.jar;如果是 Maven 项目,确认依赖 scope 是compile,不要写成provided。另外,如果你是用 IDEA 的 Artifacts 打 WAR,需要在 Project Structure 里把依赖加入WEB-INF/lib目录,否则驱动和 JSTL 库都缺失。

6.2.2 中文乱码:页面全是问号

现象:页面显示“姓名”变成“???”,数据库中存进去的也是问号,后台没有任何报错。

原因:三个位置至少有一处编码不一致:JSP 文件的编码、MySQL 表字符集、JDBC URL 的连接字符集。

解决:JSP 顶部统一写contentType="text/html;charset=UTF-8";MySQL 建库建表都用utf8mb4;JDBC URL 追加?characterEncoding=utf8。如果表已经用 latin1 建了,用ALTER TABLE registration CONVERT TO CHARACTER SET utf8mb4;转换后再重新插入数据。这个坑是第一次做 Java Web 项目必见的,但也是最好修的。

6.2.3 号源超卖:同一个时段被两个人同时约到

现象:两个患者同时点挂号,数据库里registration生成了两条相同排班的记录,但remaining_count只减了 1。

原因:挂号逻辑没有加锁或没有使用条件更新,两条并发请求同时读到remaining_count=1,然后同时执行减一操作和插入操作,事务隔离级别为可重复读时,各自持有的读操作互不阻塞。

解决:沿用前面代码里SELECT ... FOR UPDATE的方式锁定排班行,保证第二个事务等待第一个提交后再读。同时,在UPDATE里加AND remaining_count > 0条件,并检查更新行数,作为无锁情况下的兜底。最后,还要给registration表加一个业务唯一索引,比如(schedule_id, patient_id, status),但实际项目里同一患者可以约多个不同医生,所以唯一索引的设计要谨慎,这里更适合加一个组合唯一索引让同患者同排班只能存在一条状态为“已预约”的记录。

6.2.4 明明登录了,却一直跳回登录页

现象:登录成功后访问/patient/myRegistration,浏览器 URL 又弹回/login.jsp,后台没有报错。

原因:Session 过期时间是默认 30 分钟,但最常见的是部署到 Tomcat 时,项目上下文路径变成了/项目名/,登录成功后代码里resp.sendRedirect("/patient/myRegistration")没有带上req.getContextPath(),导致跳转到根路径下,被 Tomcat 识别为另一套路径。

解决:所有跳转统一写成resp.sendRedirect(req.getContextPath() + "/patient/myRegistration")。JSP 里的链接同理,用contextPath拼接完整路径,不要硬编码绝对路径。还有一点,如果用了两个浏览器页面交替测试,其中一个 Session 被新的登录覆盖,旧页面操作会丢失登录态,这也是常见现象,不是 bug。

6.2.5 数据库连接泄露:系统运行一上午后越来越慢

现象:一开始响应正常,运行几个小时后 Tomcat 后台打印Connection is not available, request timed out,重启项目后恢复,但过一会儿又变慢。

原因:DAO 代码里写了Connection的获取,但在异常分支没有关闭,或者关闭顺序错误,导致连接一直被占用。使用 C3P0/Druid 时,多余连接占满连接池后所有新请求排队等待,表现为整体变慢。

解决:用 try-with-resources 方式获取连接和预编译语句,例如try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)),这样异常时也会自动关闭。如果不想改全部代码,至少保证 finally 块中调用DBUtil.close(rs, ps, conn),并且close方法里不要因为某个资源关闭失败而中断后续资源的关闭。

7. 让系统再往前一步:把挂号余量同步到前端轮询,附一个老手常用的 Session 失效处理技巧

到这里,系统已经能完成注册、登录、排班、挂号、退号、停诊这些主流程了。如果你不想停留在“课设可用”的程度,我建议你做一个特别有价值的小改进:前端页面的号源余量自动刷新。传统 JSP 页面是用户点一次查询才更新余量,两个患者同时盯着同一个号源时,后下单的人会在提交时才收到“号源已挂完”的提示。其实可以在排班列表页面加一个简单的 JavaScript 定时器,每 30 秒异步请求一个 Servlet,返回该医生某一天的剩余号源 JSON 数据,局部更新按钮状态。

实现不复杂,在一个QueryRemainingServlet里写:

@WebServlet("/patient/remaining") public class QueryRemainingServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String scheduleId = req.getParameter("scheduleId"); resp.setContentType("application/json;charset=UTF-8"); PrintWriter out = resp.getWriter(); if (scheduleId == null) { out.write("{\"code\":400}"); return; } int remaining = scheduleService.getRemaining(Integer.parseInt(scheduleId)); out.write("{\"code\":200,\"remaining\":" + remaining + "}"); } }

然后在页面中放一个隐藏的排班编号,用setInterval每 30 秒发一次fetch,这个改动很小,但极大提升真实使用体验,也让整个系统从静态查询变为半实时状态。要注意的是,如果使用 JSP 渲染时把当前用户 ID 直接放在页面的 JS 变量里,存在越权查看别人数据的风险,上面这个接口只返回余量数字,不涉及任何隐私,所以可以安全暴露。

另一个老手技巧是关于 Session 失效后的表单重复提交。很多人在做挂号时直接提交表单,请求处理中如果 Session 超时,request.getSession(false)返回 null,处理逻辑可能抛 NPE,用户看到的只是 500 页面。我的习惯是在所有需要登录的 Servlet 入口检查 Session,并且在 JSP 表单中放一个隐藏的 token 字段,首次渲染时存入 Session,提交时比对,比对一致则消费掉 token。这样不仅防止重复点击导致重复挂号,也防止了部分 CSRF 场景。注意,token 校验要在业务逻辑之前,失败时跳回到表单页并提示“请重新操作”。

我自己的习惯是每次拿到别人写的 Java Web 项目源码,第一件事先看web.xml里 Filter 注册顺序和WEB-INF/lib的 jar 列表,再看数据库脚本的字符集和引擎设置,最后跑通一个挂号和退号的主链路。这三关过了,项目大概率可靠;过不了,后续每个功能都可能翻车。这篇笔记里所有代码和配置都基于 JSP/Servlet/JDBC 这套最传统但最能练基本功的技术栈,如果你有精力,在此基础上往 MyBatis、Spring MVC、Spring Boot 的方向迁移,迁移过程本身就是一次很好的框架认知升级。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 10:16:27

本地部署AI编程助手:Docker容器化与GPU推理实战指南

1. 为什么要在本地折腾一个 AI 编程助手 把 AI 编程助手跑在自己机器上&#xff0c;这件事在两年前还属于“实验室玩具”的范畴&#xff0c;现在已经变成不少开发者日常写代码的标配。原因很直接&#xff1a;云端服务虽然开箱即用&#xff0c;但代码片段一旦离开本机&#xff0…

作者头像 李华
网站建设 2026/10/8 10:16:09

本地部署AI编程助手:Docker与Ollama实战指南

1. 为什么要在本地跑一个 AI 编程助手 把 AI 编程助手放到自己机器上跑&#xff0c;这件事在两年前还属于"折腾党专属"&#xff0c;现在已经变成很多团队的标准动作。原因很直接&#xff1a;代码是敏感资产&#xff0c;把整段业务逻辑贴到外部服务里&#xff0c;心里…

作者头像 李华
网站建设 2026/10/8 10:15:41

深度体验pi:本地部署的AI编程智能体从安装到实战

最近群里好几个朋友都在问同一个问题&#xff1a;pi 到底是什么&#xff1f;有人以为是树莓派&#xff0c;有人以为是圆周率&#xff0c;还有人发来一张控制器截图&#xff0c;问 PI 参数怎么调。这些理解都没错&#xff0c;但最近一段时间&#xff0c;开发者圈子里频繁出现的 …

作者头像 李华
网站建设 2026/10/8 10:15:28

从PMBOK第六版到第八版:项目经理角色与团队文化的价值转型

如果你对项目管理的印象还停留在 PMBOK 第六版——也就是把项目当成一条流水线&#xff0c;按启动、规划、执行、监控、收尾五个过程组&#xff0c;把十大知识领域里的动作一项项做完——那看到第八版的新框架时&#xff0c;第一反应很可能是&#xff1a;这怎么像一本讲领导力和…

作者头像 李华
网站建设 2026/10/8 10:15:14

游戏引擎基础架构:动态协作协议与运行时契约体系

1. 为什么“引擎基础架构”不是一张静态框图&#xff0c;而是一套动态协作协议很多人第一次接触游戏引擎架构时&#xff0c;会下意识打开某款开源引擎的源码目录&#xff0c;试图从顶层文件夹名&#xff08;比如Engine/,Renderer/,Core/&#xff09;里“看懂”整个系统——结果…

作者头像 李华