news 2026/10/8 18:45:01

基于Jsp+Servlet的医院预约挂号系统实现要点解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Jsp+Servlet的医院预约挂号系统实现要点解析

简介:基于Jsp+Servlet构建的医院预约挂号系统完整项目包,面向JavaWeb初学者、课程设计与毕业设计开发者,帮助解决从环境搭建、源码部署到前后端功能梳理的全流程问题。前端提供首页展示、用户注册、医生查询与公告信息等基础功能;后端管理覆盖医院信息、科室、医生、会员及系统公告等模块,功能完整可直接运行或二次改造。资源共1451个文件,压缩包约31.95MB,以js、html、jsp、jar、xml、png等类型为主:其中400个js文件处理前端交互,148个html页面搭建展示框架,78个jsp页面承接服务端动态响应,jar封装依赖库,xml保存配置信息,png/gif提供界面素材,另含SQL数据库脚本及部署说明。已有134人学习下载,配套部署教程、软件工具和项目源码一并打包,目录结构清晰,适合边看边练,快速掌握Servlet+JSP项目开发流程,无论是课程作业还是毕业设计,都可直接借鉴其分层设计与MVC思想。

1. 基于Jsp+Servlet的医院预约挂号系统还值得做吗?——先别急着换框架

"基于Jsp+Servlet的医院预约挂号系统"在课程设计、毕业设计和医院内部小型HIS系统里一直有需求。很多同学第一反应是技术栈老,但恰恰因为老,整套请求流转全程透明:浏览器把请求交给Servlet,Servlet调JDBC取数据,再塞进JSP渲染回页面,没有SpringBoot的自动配置替你掩盖细节。这套系统能解决的真实问题是:患者在线注册、按科室/医生/排班挂号、取消预约,医生和管理员维护出诊数据。适合想弄懂Java Web底层原理的人,也适合需要快速交付、后期还能维护的本地系统。下面从表结构到部署,讲清每一步怎么落地。

2. 从业务到表结构:先把挂号系统的核心流程和数据库定下来

2.1 角色与核心用例:患者、医生、管理员各自管什么

做这类系统最常见的翻车点不是代码写不出来,而是没想清楚角色就开始建表。医院预约挂号系统的角色至少有三种:患者、医生、管理员。患者关心的是注册登录、查科室、找医生、看排班、预约、取消预约、查看个人信息;医生关心的是查看自己某一天有哪些患者预约、是否完成接诊;管理员关心的是科室维护、医生维护、排班发布、退号处理、挂号量统计。

这三类角色的权限差异会影响所有页面和Servlet接口。比如患者能访问/profile修改自己的手机号,医生能访问/doctor/patients,管理员能访问/admin/scheduleManage。如果一开始只在Servlet里用if (session.getAttribute("user") != null)判断,后面加权限会越来越乱。常见做法是角色字段放在用户表里,然后加一个登录Filter,在请求进入业务Servlet之前就拦截掉没有权限的人。

业务用例确定后,数据库就能倒推出来:至少要有一张患者表、一张医生表、一张科室表、一张排班表、一张预约表。管理员不单独建表也可以,在用户表里用role字段区分。真正需要想清楚的是预约表怎么设计,因为它要支撑"同一患者不能在同一时段重复挂号""退号后号源要释放""医生排班不能冲突"三个核心约束。

2.2 三张核心表和扩展表:预约挂号表怎么设计才能支持改号、退号

预约挂号表是整个系统的核心。很多课程设计给的示例表只有appointment_id, user_id, doctor_id, time四个字段,实际跑起来会发现两个问题:一是用户点了两次按钮会挂两个号,二是退号后不清楚当初挂的是哪个时间段。我一般会在表里加入schedule_id和status,把"医生、日期、时段、余号"拆到排班表里,预约表只引用排班表的ID。

具体建表SQL如下,以MySQL 5.7/8.0为例,字符集统一用utf8mb4:

-- 建库:utf8mb4 比 utf8 更稳,中文和生僻字都不崩 CREATE DATABASE IF NOT EXISTS hospital_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE hospital_db; -- 科室表 CREATE TABLE department ( id INT AUTO_INCREMENT PRIMARY KEY, dept_code VARCHAR(20) NOT NULL COMMENT '科室编码,如 NEIKE', dept_name VARCHAR(50) NOT NULL COMMENT '科室名称', intro VARCHAR(500) DEFAULT '' COMMENT '科室简介', status TINYINT DEFAULT 1 COMMENT '1启用 0停用', UNIQUE KEY uk_dept_code (dept_code) ) ENGINE=InnoDB COMMENT='科室表'; -- 医生表 CREATE TABLE doctor ( doc_id INT AUTO_INCREMENT PRIMARY KEY, dept_id INT NOT NULL, doctor_name VARCHAR(30) NOT NULL, title VARCHAR(20) DEFAULT '' COMMENT '职称:主任医师/副主任医师/主治医师', reg_fee DECIMAL(6,2) DEFAULT 10.00 COMMENT '挂号费用', intro VARCHAR(500) DEFAULT '', status TINYINT DEFAULT 1, KEY idx_dept (dept_id), CONSTRAINT fk_doctor_dept FOREIGN KEY (dept_id) REFERENCES department(id) ) ENGINE=InnoDB COMMENT='医生表'; -- 排班表 CREATE TABLE schedule ( id INT AUTO_INCREMENT PRIMARY KEY, doc_id INT NOT NULL, work_date DATE NOT NULL, time_slot VARCHAR(10) NOT NULL COMMENT '上午 AM / 下午 PM / 晚间 NM', total_number INT DEFAULT 20, booked_number INT DEFAULT 0, version INT DEFAULT 0 COMMENT '乐观锁字段,用于并发扣号', UNIQUE KEY uk_doc_date_slot (doc_id, work_date, time_slot), CONSTRAINT fk_schedule_doctor FOREIGN KEY (doc_id) REFERENCES doctor(doc_id) ) ENGINE=InnoDB COMMENT='出诊排班表'; -- 患者表 CREATE TABLE patient ( pid INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(30) NOT NULL, password VARCHAR(64) NOT NULL COMMENT 'MD5加盐后的密文', real_name VARCHAR(30) NOT NULL, phone VARCHAR(20) DEFAULT '', id_card VARCHAR(18) DEFAULT '' COMMENT '身份证,实名就医校验用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINE=InnoDB COMMENT='患者表'; -- 预约挂号表 CREATE TABLE appointment ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT '订单号,业务上不直接用主键', patient_id INT NOT NULL, schedule_id INT NOT NULL, reg_fee DECIMAL(6,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 0 COMMENT '0已预约 1已完成 2已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, cancel_time DATETIME NULL, UNIQUE KEY uk_patient_schedule (patient_id, schedule_id), KEY idx_schedule (schedule_id), CONSTRAINT fk_appt_patient FOREIGN KEY (patient_id) REFERENCES patient(pid), CONSTRAINT fk_appt_schedule FOREIGN KEY (schedule_id) REFERENCES schedule(id) ) ENGINE=InnoDB COMMENT='预约挂号表';

这条SQL里有几个值得注意的地方。status用 TINYINT 而不是字符串,是为了后续扩展方便,0、1、2分别对应已预约、已完成、已取消,程序里写常量就好。uk_patient_schedule唯一约束是防重复挂号的最后一道防线,光靠Java代码判断永远有可能漏。booked_number字段是记录已经预约的人数,而不是实时用COUNT统计,因为高频预约场景下COUNT会拖慢查询。排班表还加了version字段,后面做并发扣号时会用到乐观锁。

这里的关键点是把"改号"拆成"退号 + 挂新号"两个动作,而不是用UPDATE改预约表的schedule_id。改号直接UPDATE会导致患者原有的号源没释放,新号又占用,排班余量也会错。所以预约表的状态机设计成:已预约(0) -> 已取消(2),或者已预约(0) -> 已完成(1)就足够覆盖需求了。

2.3 初始化数据:科室、医生、排班怎么填才合理

建表完成后,需要用初始化数据把系统跑起来。很多人在这一步直接跑一个大的INSERT语句,但容易忽略排班时间应该是相对日期。测试环境可以用DATE_ADD(CURDATE(), INTERVAL 1 DAY)生成明天的排班,这样项目跑起来当天就能预约到明天的号,不用手工改日期。

INSERT INTO department (dept_code, dept_name, intro) VALUES ('NEIKE', '内科', '内科常见病、多发病诊治'), ('WAIKE', '外科', '外科手术及创伤处理'), ('FUKU', '妇产科', '妇科、产科疾病诊疗'); INSERT INTO doctor (dept_id, doctor_name, title, reg_fee, intro) VALUES (1, '张医生', '主任医师', 20.00, '心血管方向'), (1, '李医生', '主治医师', 10.00, '呼吸方向'), (2, '王医生', '副主任医师', 15.00, '普外科'); INSERT INTO schedule (doc_id, work_date, time_slot, total_number, booked_number) SELECT 1, DATE_ADD(CURDATE(), INTERVAL 1 DAY), 'AM', 20, 0 UNION ALL SELECT 1, DATE_ADD(CURDATE(), INTERVAL 1 DAY), 'PM', 20, 0 UNION ALL SELECT 2, DATE_ADD(CURDATE(), INTERVAL 1 DAY), 'AM', 15, 0;

初始化数据不要只插一条,尽量每个科室至少一个医生,每个医生至少一个明天的时段。这样页面展示时有数据可看,联调时不需要临时造数据。医生表的reg_fee是挂号费,后续创建预约时要把它快照到预约表里,而不是每次查询医生表,防止管理员改了挂号费后老订单金额跟着变。

患者表和管理员表我一般不会直接写在建表SQL里,而是通过一个init.sql单独跑。密码不能用明文,常见做法是MD5(salt + password),salt在注册时随机生成,存成salt:密文的格式。登录校验时先把用户输入的密码拼上salt再算MD5比对。这个方案虽然不如BCrypt,但在Jsp+Servlet教学项目里已经足够说明问题了。

3. 用Servlet把业务逻辑串起来:从前端JSP到后端JDBC的请求流转

3.1 为什么用Servlet而不是直接写JSP?接口分层与控制器的取舍

很多初学者上来就在JSP里写if (rs.next())、while(rs.next())直接查数据库,页面跑通后自我感觉良好,第二天加一个需求就崩了。JSP本质上是Servlet,但它被设计成负责展示,不是负责业务。把JDBC查询、金额计算、状态判断全塞进JSP里,页面一多就变成黑匣子,报错都不知道是数据库问题还是页面渲染问题。

常见做法是把项目拆成浏览器 -> Servlet -> Service -> DAO:Servlet只接收HTTP参数、调用Service、返回页面或重定向;Service处理业务规则,比如预约名额是否充足、是否重复预约;DAO只写SQL的增删改查。这个分层看起来繁琐,但在Jsp+Servlet项目里是最不容易翻车的结构。就算你后面要换成MyBatis或SpringBoot,DAO和Service层基本可以原样保留。

Servlet不需要额外引入框架,Tomcat自带javax.servlet-api。用注解@WebServlet("/path")注册接口,比在web.xml里写一堆<servlet>和<servlet-mapping>要省事。注意开发环境下Servlet依赖要用provided作用域,因为Tomcat里已经有一份了,打包的时候不能塞进去。

3.2 写一个能跑的servlet demo:RegisterServlet的完整代码

这里给一个最小可运行的注册接口,用来演示从JSP表单到Servlet再到DAO的完整请求流转。

package com.hospital.web; import com.hospital.dao.PatientDao; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @WebServlet("/register") public class RegisterServlet extends HttpServlet { private PatientDao patientDao = new PatientDao(); @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 编码过滤在Filter里做了,这里再设一次是为了防止直接访问接口时乱码 request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); String confirmPassword = request.getParameter("confirmPassword"); String realName = request.getParameter("realName"); String phone = request.getParameter("phone"); // 用request.setAttribute传错误信息,转发回注册页回显 if (password == null || !password.equals(confirmPassword)) { request.setAttribute("error", "两次密码输入不一致"); request.getRequestDispatcher("/jsp/register.jsp").forward(request, response); return; } boolean success = patientDao.register(username, password, realName, phone); if (success) { // 注册成功后重定向到登录页,避免用户刷新页面时重复提交 response.sendRedirect(request.getContextPath() + "/login.jsp"); } else { request.setAttribute("error", "用户名已存在"); request.getRequestDispatcher("/jsp/register.jsp").forward(request, response); } } }

这个Servlet有两点值得学习。第一,失败时用forward而不是sendRedirect,这样错误信息能存在request里带到JSP;成功时用sendRedirect,既防止重复提交,又让浏览器地址栏变成login.jsp。第二,所有参数都用request.getParameter获取,但要注意getParameter返回的是String,做数字转换时要捕获异常或先用正则校验。

DAO层的register方法内部要处理SQLIntegrityConstraintViolationException,因为uk_username唯一约束会在并发注册时兜底。不要只靠SELECT COUNT(*)判断用户名是否存在,那个操作和INSERT之间可能被别的请求插队。JDBC代码里所有资源都要用try-with-resources关闭,不然连接池很快被耗尽。

3.3 Filter统一处理编码与登录状态:三个Filter管住所有请求

如果每个Servlet都写一遍request.setCharacterEncoding("UTF-8"),项目小还能忍,接口一多就遗漏。常见做法是用Filter统一处理。推荐至少写三个Filter:编码Filter、登录Filter、管理端权限Filter。编码Filter必须放在最前面,并且chain.doFilter(req, resp)之前设置编码。

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

登录Filter要放行登录页、注册接口、静态资源,以及根路径。判断是否登录时,优先用request.getSession(false),不要用request.getSession(),避免每次访问静态资源都创建无用的Session。

@WebFilter("/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(false); String uri = request.getRequestURI(); String ctx = request.getContextPath(); // 放行登录、注册、静态资源 if (uri.endsWith("/login.jsp") || uri.endsWith("/login") || uri.endsWith("/register") || uri.endsWith("/register.jsp") || uri.startsWith(ctx + "/css/") || uri.startsWith(ctx + "/js/") || uri.startsWith(ctx + "/images/") || uri.startsWith(ctx + "/upload/")) { chain.doFilter(req, resp); return; } if (session == null || session.getAttribute("patient") == null) { response.sendRedirect(ctx + "/login.jsp?redirect=" + UriEncoder.encode(uri)); return; } chain.doFilter(req, resp); } }

登录Filter里有一个容易踩坑的点:放行路径用endsWith判断,当路径是/jsp/login.jsp时有效,但当路径是/admin/login.jsp时反而会误放行。管理端权限Filter要单独写,不能和患者登录Filter混在一起。如果项目规模不大,也可以在同一个Filter里先判断角色再判断路径前缀,但那样会越来越难读。

3.4 用JDBC还是DBUtils?连接池Druid的配置与参数说明

Jsp+Servlet项目最常见的持久层方案是Apache Commons DBUtils配合Druid连接池,而不是直接裸写JDBC。DBUtils一行代码就能把ResultSet映射成JavaBean,省掉大量getString/setString模板代码。Druid则负责连接复用和监控,配置如下:

driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/hospital_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username=root password=123456 initialSize=5 minIdle=5 maxActive=20 maxWait=60000 removeAbandonedTimeout=180 removeAbandoned=true validationQuery=SELECT 1 testWhileIdle=true

参数里最需要调的是initialSize、maxActive、maxWait。initialSize设成5表示启动时就建立5个连接;maxWait如果设成0,连接不够时会一直等下去,页面会卡死,所以设成60000毫秒。validationQuery的SELECT 1用来探测连接是否还活着,数据库重启后Druid会自动丢弃死连接。

创建DataSource的代码建议放在一个公共类Static块里,保证整个应用只有一个连接池实例:

public class JdbcUtils { private static DataSource dataSource; static { try { Properties props = new Properties(); props.load(JdbcUtils.class.getClassLoader() .getResourceAsStream("druid.properties")); dataSource = DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static DataSource getDataSource() { return dataSource; } }

Druid还有监控页面,内嵌一个StatViewServlet,打开后能看到每个SQL的执行时间、连接获取时间。这在调优时非常有用。不过Tomcat 9的Jsp+Servlet项目里,Druid的WebStatFilter需要引入druid的jar包并在web.xml中注册,很多人会漏掉这一步。

4. JSP页面落地:从用户注册、科室列表到预约挂号的操作路径

4.1 用EL和JSTL改写页面,避免Scriptlet满天飞

JSP页面最怕看到<% %>里写循环和if。用EL表达式配合JSTL的c:forEach、c:if、fn:escapeXml,页面结构会清晰很多。以科室列表页为例:

<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %> <div class="dept-list"> <c:forEach items="${deptList}" var="dept"> <div class="dept-card"> <h3><c:out value="${dept.deptName}"/></h3> <p><c:out value="${dept.intro}"/></p> <a href="${pageContext.request.contextPath}/doctorList?deptId=${dept.id}">查看医生</a> </div> </c:forEach> </div>

注意c:out默认会对HTML字符做转义,用户在后台录入科室简介时如果包含<script>,输出到页面上会被转成普通文本,避免XSS攻击。${pageContext.request.contextPath}会自动带上项目上下文路径,比如/hospital,这样页面放在任何部署名下面都不会404。

JSTL依赖需要在WEB-INF/lib下放jstl-1.2.jar。如果用的是Maven,在pom.xml里加以下依赖并确保scope不是provided:

<dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>

如果你的部署环境是Tomcat 10及以上,包名变成了jakarta.servlet,JSTL依赖坐标也要换成jakarta.servlet.jsp.jstl,很多血泪教训都来自这里。

4.2 做一个能直接用的jsp个人信息展示页面:ProfileServlet + JSTL回显

"jsp个人信息展示页面"是这类项目里出现频率最高的需求。核心逻辑:登录后从Session里拿当前患者id,再用这个id查数据库,把最新数据放到request中,转发到profile.jsp展示。

@WebServlet("/profile") public class ProfileServlet extends HttpServlet { private PatientDao patientDao = new PatientDao(); @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session = request.getSession(false); Patient patient = (Patient) session.getAttribute("patient"); Patient freshPatient = patientDao.findById(patient.getPid()); request.setAttribute("user", freshPatient); request.getRequestDispatcher("/jsp/profile.jsp").forward(request, response); } }

为什么不直接展示Session里的patient?因为Session里的数据是登录那一刻写入的,如果用户改了手机号,Session里的还是旧值。每次查一次数据库成本也不高,还能保证页面数据是最新的。

JSP页面里用表格和EL输出信息,头像如果存在,则需要用绝对路径引用:

<div class="avatar-box"> <c:if test="${not empty user.avatar}"> <img src="${pageContext.request.contextPath}/upload/${user.avatar}" alt="头像" class="avatar-img"> </c:if> </div> <table> <tr><td>用户名</td><td><c:out value="${user.username}"/></td></tr> <tr><td>姓名</td><td><c:out value="${user.realName}"/></td></tr> <tr><td>手机号</td><td><c:out value="${user.phone}"/></td></tr> </table>

很多同学问"jsp图片如何对坐标定位",这其实不是JSP要解决的问题。JSP只负责输出<img>标签的src,裁剪、圆角、定位是CSS的事。想按坐标展示裁剪后的头像,用object-fit: cover或background-position: x y就搞定了。千万别把坐标写死在JSP标签里,那会让页面耦合到个人头像这种细枝末节。

4.3 预约挂号页面的表单校验:防重复提交、防越权访问其他患者数据

预约挂号是整个系统里并发和权限问题最集中的页面。页面表单必须隐藏排班ID、医生ID、日期和时段,提交时再在后端校验一遍,不能信任前端传过来的任何值。预约表单的HTML要点如下:

<form action="${pageContext.request.contextPath}/appointment" method="post"> <input type="hidden" name="scheduleId" value="${schedule.id}"> <input type="hidden" name="doctorId" value="${schedule.doctorId}"> <input type="hidden" name="regFee" value="${schedule.regFee}"> <input type="hidden" name="token" value="${sessionScope.csrfToken}"> <div>医生:<c:out value="${schedule.doctorName}"/></div> <div>出诊日期:${schedule.workDate} ${schedule.timeSlot}</div> <button type="submit">确认预约</button> </form>

token字段来自Session,后端在创建预约时校验该token并立刻作废,这是最基础的防CSRF和防重复提交方案。页面层级用JavaScript防止按钮连点,但真正可靠的还是后端:事务里先检查预约表有没有唯一约束冲突,再检查排班表余量,最后执行插入并扣减号源。

这里要特别提醒防越权:如果预约接口只接收patientId,恶意用户把请求包改成别人的patientId就能替别人挂号。正确做法是后端永远从Session里取当前登录者的id,而不是信任前端传来的参数。

4.4 页面跳转与参数传递:get/post与重定向的边界

Jsp+Servlet里最容易混的就是forward和redirect,用错会导致刷新后重复提交、URL显示错乱、参数丢失。规则其实只有两条:查询类请求用forward,让request里的数据带到JSP;增删改类请求成功后用redirect,把浏览器重新定位到一个GET页面。

// forward:浏览器地址栏不变,request里的属性可以带过去 RequestDispatcher rd = request.getRequestDispatcher("/jsp/doctorList.jsp"); rd.forward(request, response); // redirect:浏览器地址栏变成新URL,原request数据全部丢失 response.sendRedirect(request.getContextPath() + "/doctorList?deptId=" + deptId);

redirect之后要想把用户ID传过去,必须拼在URL的query string里。所以在写完appointment接口时,我会在预约成功后的代码里写response.sendRedirect(ctx + "/appointmentList?patientId=" + patientId),然后appointmentList根据patientId查出最新列表。

一个常见误用是sendRedirect("index.jsp"),这个写法在浏览器处于/hospital/appointment时会把用户带到/hospital/appointment/index.jsp,直接404。正确写法永远是sendRedirect(request.getContextPath() + "/index.jsp")。

5. Jsp+Servlet挂号系统的常见坑与排查:从404到数据错乱的定位思路

5.1 坑:JSP编译不过或报ClassNotFoundException

现象:启动Tomcat后访问.jsp页面报500,错误信息里出现Unable to compile class for JSP或ClassNotFoundException: org.apache.taglibs.standard.tag.common.core.ForEachSupport。

原因:最常见的是JSTL依赖没有放进WEB-INF/lib。JSP页面里写了<c:forEach>,但Tomcat自身不带JSTL实现,必须手动引入jar包。另一个原因是jar包冲突,比如同时放了旧版jstl.jar和standard.jar。

解决:Maven项目在pom.xml中加入jstl依赖,非Maven项目直接把jstl-1.2.jar复制到WEB-INF/lib下。如果项目已经用了Jakarta命名空间,Tomcat 10以上要换成:

<dependency> <groupId>jakarta.servlet.jsp.jstl</groupId> <artifactId>jakarta.servlet.jsp.jstl-api</artifactId> <version>2.0.0</version> </dependency>

JSP页面顶部的taglib指令也要配套改。出现这个错时先看Tomcat版本,再确认taglib URI是javax还是jakarta,别把时间花在清缓存上。

5.2 坑:表单提交中文乱码,Filter设置了编码还是问号

现象:注册表单填入"王芳",Servlet里request.getParameter("username")能正确打印,但插入MySQL后变成???。

原因:乱码不一定在Tomcat这一层。如果Filter已经设置了request.setCharacterEncoding("UTF-8"),并且Tomcat 8以上默认GET参数也是UTF-8,那么问题多半出在JDBC连接URL没有指定字符集。MySQL驱动默认使用数据库的latin1或系统默认编码,必须显式声明。

解决:打开druid.properties,确认连接URL里已经带了characterEncoding=utf8:

url=jdbc:mysql://localhost:3306/hospital_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai

同时确认MySQL建表时的字符集是utf8mb4。如果连URL和建表都对,还乱码,检查数据库连接在MySQL命令行执行SET NAMES utf8mb4;是否正常。另一个隐藏坑是response返回页面时忘了设Content-Type,导致浏览器用GBK解码JSP生成的UTF-8字节,表现也是乱码,但这种情况只在JSP没写page指令时出现。

5.3 坑:预约挂号重复提交,同一患者同一时段挂上两次号

现象:患者点击"确认预约"后页面没反应,又点了一次,结果挂号列表出现两条相同医生的记录。

原因:页面端的按钮没有加防重复点击,后端也没有数据库唯一约束。Service层先查再插的代码存在时间差:两个并发请求同时查到余号充足,都执行INSERT,最终两条记录都写进去了。

解决:三层防线。第一层在预约表上加唯一约束uk_patient_schedule(patient_id, schedule_id),插第二次直接报Duplicate entry;第二层在Service层用事务,先UPDATE排班表扣减号源,再插入预约记录,用UPDATE schedule SET booked_number = booked_number + 1 WHERE id = ? AND booked_number < total_number判断受影响行数;第三层才是前端按钮置灰。核心SQL如下:

-- 扣号时必须带上 booked_number < total_number,否则说明号已挂满 UPDATE schedule SET booked_number = booked_number + 1 WHERE id = ? AND booked_number < total_number;

Service层伪代码:

@Transactional public synchronized boolean book(Appointment appt) { int updated = scheduleDao.increaseBooked(appt.getScheduleId()); if (updated == 0) { return false; // 号源已满 } try { appointmentDao.insert(appt); return true; } catch (DuplicateKeyException e) { // 唯一约束触发,回滚已扣的号源 scheduleDao.decreaseBooked(appt.getScheduleId()); return false; } }

synchronized只对单机Tomcat有效,如果部署成多节点,要靠数据库锁。课程设计场景下单机事务 + 唯一约束已经够稳,别过度设计。

5.4 坑:JSP页面图片/JS/CSS路径失效

现象:登录页http://localhost:8080/hospital/login.jsp打开时样式正常,跳到/jsp/profile.jsp后页面没有样式,图片全部破碎。查看网络面板,请求路径变成了http://localhost:8080/jsp/css/style.css。

原因:JSP里写的是相对路径<link href="css/style.css">,浏览器在解析时会基于当前浏览器地址栏的URL目录去拼接。当地址是/hospital/login.jsp时解析到/hospital/css/;当Servlet转发到/jsp/profile.jsp时,浏览器地址栏还是/hospital/profile,相对路径自然就拼错了。

解决:所有静态资源引用都使用绝对路径,最笨也最可靠的是在每个JSP里写:

<base href="${pageContext.request.contextPath}/">

这样页面里所有相对路径都会以项目根目录为准。但要注意<base>会连<a href="#anchor">这样的锚点也一并改变,所以更推荐的做法是在引用资源时不偷懒,直接写全:

<link rel="stylesheet" href="${pageContext.request.contextPath}/css/style.css"> <img src="${pageContext.request.contextPath}/upload/${user.avatar}">

在Servlet里forward到JSP时,浏览器地址栏没变,这个坑尤其隐蔽。我的习惯是新建JSP文件后先检查引用资源的前缀,再决定是否复制已有页面模板。

5.5 坑:Servlet里的转发路径和重定向路径写错

现象:点击"退出登录",跳转后地址栏变成http://localhost:8080/hospital/index.jsp,但服务器日志显示请求/index.jsp返回404,去掉项目名才正常。

原因:response.sendRedirect("index.jsp")是相对路径,浏览器会把它拼接在相对当前URL的目录上。如果当前URL是/hospital/appointment,那它跳到/hospital/appointment/index.jsp,自然404。

解决:sendRedirect必须带上contextPath:

response.sendRedirect(request.getContextPath() + "/index.jsp");

而RequestDispatcher.forward("/index.jsp")的规则不一样,它是以Web应用根目录为起点的绝对路径,不需要带contextPath。所以看到getRequestDispatcher("/hospital/index.jsp")这种写法基本是错的,会变成找不到/hospital/hospital/index.jsp。这两个API的路径规则是Jsp+Servlet初学者绕不开的坑,建议写到自己项目的工具类里,而不是每次都拼字符串。

6. 上线的最后一个坎:打war包、验证核心链路、防并发漏号

到这里,系统的Servlet和JSP已经能跑起来,但离"交给别人用"还差最后一步:打包部署和验证。我一般用Maven统一管理依赖,先确认pom.xml里打了war包插件,然后执行:

mvn clean package ls target/hospital.war cp target/hospital.war $TOMCAT_HOME/webapps/ $TOMCAT_HOME/bin/startup.sh tail -f $TOMCAT_HOME/logs/catalina.out

启动后不要急着注册,先用一条SQL确认数据库连接正常,再按"注册->登录->查科室->选医生->看排班->预约->取消预约->管理端统计"的路径走一遍。每走一步看浏览器开发者工具的网络面板:如果某接口返回500,优先看catalina.out的最后30行,而不是盲改代码。测试预约并发时,可以用ab命令对一个不依赖登录态的GET接口做压测,比如科室列表:

ab -n 200 -c 20 http://localhost:8080/hospital/doctorList?deptId=1

如果返回率低于99%,先看连接池maxActive是不是太小,再看代码里有没有在finally里关闭连接。我自己做过Jsp+Servlet的挂号系统,第一次上线时最狼狈的不是业务逻辑,而是路径和编码两个问题:一种页面上全是404,另一种数据库全是问号。后来养成一个习惯:每个新接口先用curl打印response,确认路径正确再写JSP,能省掉一半排错时间。

如果项目准备长期维护,建议把注册登录的密码加密换成BCrypt,连Druid监控页也一起打开,这样就算有人拖库,也不会直接拿到明文密码。Jsp+Servlet这套技术栈虽然不再是新项目首选,但作为理解Java Web请求流转的基石,依然值得完整做一遍。希望这些踩坑记录能帮你把系统一次跑通,少浪费几个通宵。

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

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

汇写开题报告功能 —— 毕业论文的第一步

毕业论文不是从写正文开始的&#xff0c;是从开题报告开始的。汇写左侧栏专门有 "开题报告" 功能&#xff0c;旁边还标着火焰图标。汇写&#xff08;https://www.huixielunwen.com/tool/graduationThesis&#xff09;帮你从第一步就走稳。 开题报告写什么&#xff1…

作者头像 李华
网站建设 2026/10/8 18:42:22

网络性能分析、网络性能指标

网络性能分析、网络性能指标网络规划设计有严谨科学的技术指标&#xff0c;可以实现对设计网络性能的定量分析&#xff0c;因此在进行网络需求分析阶段&#xff0c;需要确定网络性能的技术指标。很多国际组织定义了明确的网络性能技术指标&#xff0c;这些指标为我们设计网络提…

作者头像 李华
网站建设 2026/10/8 18:42:07

计算机发展简史与硬件五大部分

** 计算机系统概述&#xff5c;计算机发展简史与硬件五大部分 前言 计算机组成原理是计算机类专业的核心基础课程&#xff0c;它解释一台计算机到底是如何工作的&#xff0c;从硬件底层理解程序执行的完整过程。本篇是系列第一篇&#xff0c;主要学习计算机的发展历史、硬件五大…

作者头像 李华
网站建设 2026/10/8 18:40:48

2026年空间规划行业TOP10趋势解析:一鸿堂如何引领未来方向?

开篇&#xff1a;从传统到现代&#xff0c;空间规划的演变 随着科技发展与社会变迁&#xff0c;空间规划不再仅仅是关于地理位置的选择和布局设计&#xff0c;它已经演变成一种融合了人文、环境、技术等多方面因素的综合学科。特别是在2026年&#xff0c;我们看到了一系列新趋势…

作者头像 李华
网站建设 2026/10/8 18:40:24

零基础学C语言

我是一名给排水大三在读学生&#xff0c;为了未来想从事的职业&#xff0c;跨考科目需要C语言&#xff0c;目前正在零基础学习C语言&#xff0c;以下是我近期的收获1&#xff0c;认识C语言C语言是一种面向过程的编程语言&#xff0c;由贝尔实验室的丹尼斯里奇于1972年设计开发。…

作者头像 李华