☰
医院预约挂号系统 JavaWeb 实战:从数据库设计到业务闭环全解析
2026/9/28 7:02:57 网站建设 项目流程

简介:基于JavaWeb开发的医院预约挂号管理系统源码包,适合正在准备期末大作业、课程设计或毕业设计的计算机专业学生参考。项目包含完整的源码、前端页面与数据库脚本,围绕预约挂号管理的核心流程组织,可直接导入开发环境运行,也便于对照学习相关开发技术。压缩包共343个文件,总大小约8.94MB,其中包含60个Java源文件、11个XML配置文件、11个CSS样式文件、9个JavaScript文件以及SQL脚本,同时附带大量页面截图,方便快速了解界面效果与功能模块。该设计已获老师指导并通过,代码逻辑清晰,属于纯手打的高分项目,对初学者而言实用且容易上手,可帮助理解JavaWeb项目的前后端交互与数据库设计思路。目前已有541人学习下载,是一份可用于答辩演示和项目复现的高质量参考资料。

1. 分数差距不来自功能数量:医院预约挂号系统的业务闭环是什么

选题一小时,跑通两小时,答辩三分钟——这是 javaweb 大作业最常见的节奏。医院预约挂号系统是这类课设里的常青树,业务熟悉、模块边界清晰、数据库关系不复杂,又能把增删改查、登录态、分页、权限这些考点全部覆盖。可同一个题目,有人拿 60 分,有人拿 95 分,差距从来不在功能数量,而在业务闭环:患者能不能走完「选科室→选医生→看号源→提交预约→号源扣减→生成记录→查询/取消」的完整链路,后台能不能对科室、医生、排班、订单做有效管理,数据库看着像是一个能跑的真系统,而不是拼凑的示例堆。这套技术方案核心由 JSP + Servlet + JDBC 连接池 + MySQL 构成,部署在 Tomcat 上,特别适合正在做 javaweb 课程设计、毕业设计预演,或者想把一个「完整 javaweb 项目案例 mysql 全流程」当作模板来改的同学。下面从数据库开始,按我自己做这类系统的顺序把它拆开。

2. 建好数据库,预约系统才能立住:表结构与初始化数据

2.1 五张核心表:把挂号拆成「科室—医生—排班—预约」四层

很多大作业翻车的第一个原因,是上来就建一张 appointment 表,里面塞了科室名、医生名、患者名、时段、状态——所有信息平铺在一张表里。看起来简单,但做预约业务时立刻卡住:想查某个科室还有哪些医生、某位医生明天上午还剩几个号,SQL 写得又长又绕,改动字段还要连带改前端。正确的做法是拆成四层:科室是医生归属,医生是排班主体,排班承载某一天上午/下午的号源,预约订单则是患者与排班的一次绑定。

我一般设计五张核心表:department(科室)、doctor(医生)、patient(患者)、doctor_schedule(排班)、appointment(预约订单)。再加一张 admin 表做后台登录,结构上就够了。排班表是整个系统的枢纽,它同时关联医生、记录出诊日期、上下午号源总量和已约数量,预约发生时只在排班表上做扣减,而不是直接改医生表。这样的好处是:一位医生一天可以有多条排班记录、上午下午号源互不干扰,取消预约时也只要还原排班表上的计数。

顺序上有讲究:先建 department,再建 doctor,然后 patient、doctor_schedule、appointment。外键关系不复杂,但删除顺序要反着来,避免约束报错。

2.2 建库脚本:MySQL 8.0 一次跑通

压缩包里所谓的「数据库」,行业惯例就是一份 .sql 文件,包含建库、建表、插入初始化数据三部分。下面这份脚本是我做类似管理系统时惯用的最小结构,字符集统一 utf8mb4,存储引擎 InnoDB,MySQL 5.7 和 8.0 都能直接跑。

-- init.sql CREATE DATABASE IF NOT EXISTS hospital_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE hospital_db; CREATE TABLE department ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, location VARCHAR(100) COMMENT '门诊位置', description VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='科室表'; CREATE TABLE doctor ( id INT PRIMARY KEY AUTO_INCREMENT, dept_id INT NOT NULL, name VARCHAR(30) NOT NULL, title VARCHAR(30) COMMENT '职称:主任医师/副主任医师/主治医师', intro VARCHAR(255), phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_doctor_dept FOREIGN KEY (dept_id) REFERENCES department(id) ) ENGINE=InnoDB COMMENT='医生表'; CREATE TABLE patient ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL COMMENT 'MD5 摘要,不存明文', real_name VARCHAR(30) NOT NULL, phone VARCHAR(20), id_card VARCHAR(18), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='患者表'; CREATE TABLE doctor_schedule ( id INT PRIMARY KEY AUTO_INCREMENT, doctor_id INT NOT NULL, work_date DATE NOT NULL, period TINYINT NOT NULL COMMENT '1=上午 2=下午', am_quota INT DEFAULT 30 COMMENT '上午号源总量', pm_quota INT DEFAULT 20 COMMENT '下午号源总量', booked_am INT DEFAULT 0, booked_pm INT DEFAULT 0, status TINYINT DEFAULT 1 COMMENT '1=放号 0=停诊', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_doctor_date_period (doctor_id, work_date, period), CONSTRAINT fk_schedule_doctor FOREIGN KEY (doctor_id) REFERENCES doctor(id) ) ENGINE=InnoDB COMMENT='医生排班表'; CREATE TABLE appointment ( id INT PRIMARY KEY AUTO_INCREMENT, patient_id INT NOT NULL, schedule_id INT NOT NULL, visit_date DATE NOT NULL, visit_period TINYINT NOT NULL COMMENT '1=上午 2=下午', status TINYINT DEFAULT 1 COMMENT '1=已预约 2=已取消 3=已完成', remark VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, cancel_time DATETIME, CONSTRAINT fk_appt_patient FOREIGN KEY (patient_id) REFERENCES patient(id), CONSTRAINT fk_appt_schedule FOREIGN KEY (schedule_id) REFERENCES doctor_schedule(id) ) ENGINE=InnoDB COMMENT='预约订单表';

逻辑说明:排班表用UNIQUE KEY uk_doctor_date_period保证同一位医生同一天同一时段只有一条记录,倒逼前端在「新增排班」时先查重,数据层面先拦住重复录入。period用 TINYINT 而不是字符串,是因为前端的下拉框和后端的 Integer 判断都不需要做字符串匹配,性能和省空间在课设里无所谓,但编码习惯会加分。appointment表不直接冗余医生姓名、科室名,通过schedule_id关联查询,这符合第二范式,也是答辩时老师最爱问的「为什么不在订单表里直接存医生名」的标准答案。

参数说明:am_quota=30、pm_quota=20是我按普通门诊半天 30-40 个号的经验给出的默认值,做演示时不要固定死,后面初始化数据里要给不同科室不同趟。status字段统一用 TINYINT 加注释的写法,避免用char(1),因为 Java 端取值后跟Integer比较比跟字符串比较更自然。

2.3 初始化数据的三个讲究:密码脱敏、号源参数、演示日期

初始化数据是让评审老师一眼觉得「这系统有人用过」的关键。我见到不少大作业,数据库里就两条测试数据,还都是 user1/user1,排班空空的,点进医生详情页直接白屏——这比功能缺一个还减分。初始化时要照顾三件事:

第一,密码不要存明文。患者表里的 password 字段存 MD5 摘要,md5('123456')即可,前端登录时用DigestUtils.md5Hex生成摘要后再传 SQL,避免数据库被打开时密码直接暴露。第二,号源参数要有区分度。内科一个上午 30 号,专家门诊一个上午 15 号,这样前端页面渲染出来才有「号源紧张/充足」的对比,演示时有话讲。第三,演示日期必须用「相对当前日期」生成,不能用固定日期。用CURDATE()和DATE_ADD生成未来三天的排班,比写死2024-05-20强得多——否则评审老师在 2025 年打开系统,看到的全是已过期停诊的排班,第一印象直接崩。

-- 初始化演示数据(核心片段) INSERT INTO department (name, location, description) VALUES ('内科', '门诊楼2层', '呼吸、消化系统常见病'), ('骨科', '门诊楼3层', '关节与脊柱损伤'), ('儿科', '门诊楼1层', '14岁以下儿童'); INSERT INTO doctor (dept_id, name, title, intro) VALUES (1, '张医生', '主任医师', '擅长慢性呼吸系统疾病'), (1, '李医生', '主治医师', '消化内科方向'), (2, '王医生', '副主任医师', '关节置换与运动损伤'), (3, '赵医生', '主治医师', '小儿呼吸道感染'); INSERT INTO patient (username, password, real_name, phone, id_card) VALUES ('demo', 'e10adc3949ba59abbe56e057f20f883e', '演示患者', '13800000000', '110101199001011234'); -- 未来三天的排班,每天上午放号,专家号15个,普通号30个 INSERT INTO doctor_schedule (doctor_id, work_date, period, am_quota, pm_quota) SELECT d.id, CURDATE() + INTERVAL 1 DAY, 1, 15, 20 FROM doctor d WHERE d.name = '张医生'; INSERT INTO doctor_schedule (doctor_id, work_date, period, am_quota, pm_quota) SELECT d.id, CURDATE() + INTERVAL 2 DAY, 1, 30, 25 FROM doctor d WHERE d.name = '李医生';

参数说明:MD5 摘要e10adc3949ba59abbe56e057f20f883e对应明文123456,是测试账号的标准写法。CURDATE() + INTERVAL 1 DAY比用DATE_ADD(CURDATE(), INTERVAL 1 DAY)更简洁,两者等价。插入排班时用SELECT d.id FROM doctor d WHERE d.name = '张医生'而不是硬编码doctor_id=1,是为了避免前面医生表的自增 ID 因删改而变化时初始化数据脱节。

3. 从 dao 到 servlet:把预约挂号主流程在 javaweb 里跑通

3.1 包结构与依赖:用 Maven 把骨架搭出来

拿到一个 javaweb 项目,先看它的包结构,基本就能判断值不值 95 分。我推荐按com.xxx.hospital为根包,下面分五层:entity(实体)、dao(数据访问)、service(业务逻辑)、servlet(控制器)、filter(过滤器),再加一个util放连接池工具类。很多低分大作业的问题就是所有 Servlet 堆在默认包、SQL 写满 JSP,连一个独立的 dao 层都没有,这属于一打开就暴露的问题。

IDE 的配置上,我用 IDEA 举例:新建项目时直接选 Jakarta EE / Web 骨架,或者在普通 Maven 项目的pom.xml里加上war打包方式和javax.servlet-api依赖,然后配置 Tomcat。这里的坑是 IDEA 里新建的「Web」项目默认不在src/main/java下建源码目录,要和 Maven 的标准目录结构对齐,否则后面 servlet 编译不到。

<dependencies> <!-- Servlet 与 JSP 依赖,Tomcat 运行时可去掉 scope=provided 的注释 --> <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>jsp-api</artifactId> <version>2.0</version> <scope>provided</scope> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- Druid 连接池 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> <!-- 前端 JSTL 标签库 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies>

逻辑说明:scope=provided的意思是编译时需要、运行时由 Tomcat 提供,如果不加,打包时会把 servlet-api 打进去,部署到 Tomcat 时可能和容器自带的类冲突,典型表现是启动时NoSuchMethodError。这个坑我不止一次见人踩过。MySQL 驱动版本和数据库版本尽量大版本一致,5.7 用 8.x 驱动没问题,但反过来 8.0 数据库配 5.1 驱动会报Public Key Retrieval is not allowed。

如果你拿到的是一个不打 Maven 的普通 Web 项目,常见做法是把 mysql-connector、druid、jstl 的 jar 直接拷到WEB-INF/lib目录下。IDEA 里注意右键 jar 选Add as Library,否则编译通过但运行时报ClassNotFoundException。这个区别我放在后面避坑章节细说。

3.2 Druid 连接池参数:db.properties 里的关键配置

传统 javaweb 课设最爱用 JDBC 直连,每次请求都DriverManager.getConnection,功能没问题,但答辩时老师问一句「多个用户同时预约会不会把数据库连接打满」就直接卡壳。用连接池是性价比极高的加分项,Druid 在国产项目中普及率最高,配置也直观。

# db.properties jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/hospital_db?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=123456 # 连接池参数 pool.initialSize=5 pool.maxActive=30 pool.minIdle=5 pool.maxWait=60000 pool.validationQuery=SELECT 1 pool.testWhileIdle=true pool.timeBetweenEvictionRunsMillis=60000

参数说明:serverTimezone=Asia/Shanghai解决 MySQL 8.0 下驱动默认使用 UTC 导致时间差 8 小时的问题,不配的话CURDATE()查出来的日期和本地日期对不上,演示时你会发现「今天」的排班查不出来。allowPublicKeyRetrieval=true是 MySQL 8.0 默认 caching_sha2_password 认证插件的常见报错解法,开发环境直接开。initialSize=5表示启动时预创建 5 个物理连接,maxActive=30是上限,maxWait=60000是拿连接的最大等待毫秒数,超过就抛超时异常。testWhileIdle=true让连接池在空闲时用validationQuery=SELECT 1探测连接是否有效,避免数据库重启后 Tomcat 还拿着死连接——这个参数在答辩演示当天特别有用,因为评委往往要求现场重启 MySQL。

对应的工具类用一个静态块初始化 DruidDataSource,然后通过DruidDataSourceFactory.createDataSource(props)生成数据源,最后dataSource.getConnection()获取连接。这个类写成单例,所有 dao 都从它拿 Connection,避免每个 dao 都 new 一个数据源。

3.3 核心方法:号源扣减的事务实现

预约挂号最有技术含量的一步,是「扣减号源」和「生成订单」必须作为一个事务原子提交。低分写法是:先SELECT booked_am FROM doctor_schedule WHERE id=?,拿到余量后在 Java 里判断,大于 0 就UPDATE加 1,再INSERT预约记录。这在大作业演示时通常没问题,但两个浏览器同时提交时,两个请求都可能读到同样的余量,导致超卖。老师要的是线程安全的意识,最简单的解法是让数据库自己保证原子性。

// AppointmentService.book 核心方法 public boolean book(int patientId, int scheduleId, Date visitDate, int period) throws SQLException { Connection conn = null; PreparedStatement updateStmt = null; PreparedStatement insertStmt = null; try { conn = DruidUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 第一步:条件更新排班号源,返回值影响行数,天然防超卖 String updateSql = "UPDATE doctor_schedule SET " + (period == 1 ? "booked_am = booked_am + 1 " : "booked_pm = booked_pm + 1 ") + "WHERE id = ? AND status = 1 AND " + (period == 1 ? "booked_am < am_quota" : "booked_pm < pm_quota"); updateStmt = conn.prepareStatement(updateSql); updateStmt.setInt(1, scheduleId); int rows = updateStmt.executeUpdate(); if (rows == 0) { conn.rollback(); return false; // 号源已满或停诊 } // 第二步:插入预约订单 String insertSql = "INSERT INTO appointment (patient_id, schedule_id, visit_date, visit_period, status) " + "VALUES (?, ?, ?, ?, 1)"; insertStmt = conn.prepareStatement(insertSql); insertStmt.setInt(1, patientId); insertStmt.setInt(2, scheduleId); insertStmt.setDate(3, new java.sql.Date(visitDate.getTime())); insertStmt.setInt(4, period); insertStmt.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { if (conn != null) conn.rollback(); throw e; } finally { if (insertStmt != null) insertStmt.close(); if (updateStmt != null) updateStmt.close(); if (conn != null) conn.close(); // 归还到连接池,不是物理关闭 } }

逻辑说明:关键在UPDATE ... WHERE id=? AND booked_am < am_quota,这条 SQL 把「检查余量」和「扣减名额」合并成一个原子操作。数据库的行锁保证同一时刻只有一个请求能成功更新这一行,executeUpdate返回 0 就说明余量不足或排班停诊,直接回滚。业务上不需要先SELECT再判断,既少一次查询,又堵住了并发超卖。conn.setAutoCommit(false)开启事务,两个语句要么都成功要么都回滚,不会出现「订单建了号没扣」或者「号扣了订单没生成」的中间状态。

这里有个参数容易踩:visitDate从 Servlet 接收时是String,不要直接用request.getParameter("visitDate")拼进 SQL,MySQL 默认DATETIME和字符串比较虽然能用,但遇到2025/03/20这种格式就废了。Servlet 端用SimpleDateFormat("yyyy-MM-dd")解析成java.util.Date,再转java.sql.Date。如果前端传的是yyyy-MM-dd,也可以用java.sql.Date.valueOf(字符串)一步到位。

finally里的conn.close()在连接池场景下是归还连接而不是真关闭,这是 Druid 代理对象的行为,不用特殊处理。一个常见的误用是有人手动在finally里conn.commit(),如果前面已经回滚,这里会抛Cannot commit when autoCommit is enabled之类的问题,记住commit/rollback只出现在 try 和 catch 里。

4. 前端页面与权限控制:让系统看起来值 95 分的四个模块

4.1 患者端的预约动线:列表页→详情页→提交表单

患者端的交互动线决定演示是否顺畅。页面不需要多炫,但以下几点必须有:登录/注册页、科室列表页、医生列表页、排班号源展示页、预约提交页、我的预约列表页。几个页面之间靠 URL 参数串联:deptList.jsp → doctorList.jsp?deptId=1 → scheduleList.jsp?doctorId=1 → book.jsp?scheduleId=5。

JSP 页面里建议用 EL 和 JSTL 输出数据,少写<% %>scriptlet。下面给出排班展示页取数据的 Servlet 片段和 JSP 核心表格,这是整个预约流程的入口。

// ScheduleServlet 的 doGet,返回某位医生的未来排班 protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Integer doctorId = Integer.valueOf(req.getParameter("doctorId")); List<DoctorSchedule> list = scheduleDao.findAvailableByDoctor(doctorId); req.setAttribute("scheduleList", list); req.getRequestDispatcher("/WEB-INF/jsp/scheduleList.jsp").forward(req, resp); }
<!-- scheduleList.jsp 核心片段 --> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table border="1" cellpadding="8" cellspacing="0"> <tr> <th>出诊日期</th> <th>时段</th> <th>剩余号源</th> <th>操作</th> </tr> <c:forEach items="${scheduleList}" var="s"> <tr> <td>${s.workDate}</td> <td>${s.period == 1 ? '上午' : '下午'}</td> <td> <c:if test="${s.period == 1}"> ${s.amQuota - s.bookedAm} / ${s.amQuota} </c:if> <c:if test="${s.period == 2}"> ${s.pmQuota - s.bookedPm} / ${s.pmQuota} </c:if> </td> <td> <a href="book?scheduleId=${s.id}">预约</a> </td> </tr> </c:forEach> </table>

逻辑说明:Servlet 只负责把数据塞进request,然后forward到 JSP,JSP 用 JSTL 的c:forEach循环渲染。这里要注意/WEB-INF/jsp/目录的用法——JSP 放在 WEB-INF 下,用户无法通过 URL 直接访问.jsp文件,强制走 Servlet,避免地址栏输入scheduleList.jsp绕过登录。这是流程上很重要的一环。${s.period == 1 ? '上午' : '下午'}是 EL 三目运算符,比在 Java 里转换后放 request 更省事。剩余号源展示的是可预约数/总量,评审老师看到「余 3 / 15」比看到「15」直观得多,这也是细节加分项。

book这个 Servlet 接收scheduleId参数后,先查排班确定还有号源,再 forward 到预约确认页,页面显示医生名、科室、日期、时段,让患者二次确认后提交。我在预约确认页上会加一行「提交后不可重复预约同医生同日期同时段」的提示,同时在后端也做校验,防止前端绕过页面直接请求接口重复下单。

4.2 医生端与管理后台:另一个视角的增删改查

很多大作业只做了患者端,后台管理全靠手改数据库——这离 95 分有明显距离。管理后台至少要覆盖:科室管理(增删改查)、医生管理(按科室筛选、新增、编辑、删除)、排班管理(为医生添加未来三天的排班、停诊操作)、预约管理(查看所有订单列表、按状态筛选)。这四组功能本质都是标准增删改查,但组合在一起,就构成了系统「管理端」的完整拼图,也能自然覆盖 javaweb 必考的数据库增删改查考点。

排班管理里一个实用的代码片段是批量生成排班。很多时候讲师会问「你怎么保证有号可约」,答案就是管理员提前录排班。

// ScheduleAdminServlet 新增排班的批量处理 String[] workDates = req.getParameterValues("workDate"); String doctorId = req.getParameter("doctorId"); String amQuota = req.getParameter("amQuota"); String pmQuota = req.getParameter("pmQuota"); String[] periods = req.getParameterValues("period"); // 按「医生+日期+时段」查重后批量插入 boolean allInserted = true; for (String dateStr : workDates) { if (scheduleDao.exists(doctorId, dateStr, period)) { allInserted = false; // 跳过重复排班 continue; } scheduleDao.insert(doctorId, dateStr, period, amQuota, pmQuota); }

逻辑说明:getParameterValues是处理多选框的标准方式,前端用一个「可多选日期」的复选框列表让管理员勾选未来一周的工作日,后端批量插入。exists查询利用排班表唯一键提前拦截,避免数据库抛DuplicateEntry异常然后整个页面报错——这比直接插入然后 catch 异常的方法要优雅,而且演示时不会出现半截红字报错页。管理员删除医生时,要先确认该医生是否有未完成的排班和预约,我通常在删除前查 count,大于 0 就提示「请先处理该医生的排班与预约」,防止外键约束直接 500。

管理端页面不需要单独写一套样式,复用患者端的 CSS 即可。关键是导航清晰,左侧菜单或者顶部 tab 分为「科室管理 / 医生管理 / 排班管理 / 预约管理」,每个页面都有新增按钮和列表表格,操作后跳回列表并带上?msg=success参数,页面顶部显示一条绿色提示。这套交互做下来,评委的印象就是「完整」。

4.3 登录态与权限过滤器:Session 的写法

javaweb 的登录态控制,行业标准做法是 Session + Filter。登录成功时把用户对象放进session.setAttribute("patient", patient),之后每个需要登录的页面都经过一个过滤器检查 Session 是否存在,不存在就重定向到登录页。这里有个常见误区:只在 JSP 里判断 Session 不够,因为用户可以直接请求 Servlet 接口,跳过 JSP 页面。

// AuthFilter.java public class AuthFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; Object user = request.getSession().getAttribute("patient"); Object admin = request.getSession().getAttribute("admin"); // 静态资源和登录相关接口放行 String uri = request.getRequestURI(); if (uri.contains("login") || uri.contains("register") || uri.endsWith(".css") || uri.endsWith(".js") || uri.endsWith(".png")) { chain.doFilter(request, response); return; } if (user == null && admin == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }
<!-- web.xml 关键映射 --> <filter> <filter-name>AuthFilter</filter-name> <filter-class>com.demo.hospital.filter.AuthFilter</filter-class> </filter> <filter-mapping> <filter-name>AuthFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

逻辑说明:/*拦所有请求,但放过 login、register 和静态资源。管理端和患者端共用一套 Session 的话,上面代码里user == null && admin == null的逻辑就是「只要登录了任意角色都放行」,但后台管理页面要在 Servlet 里再判断admin是否为空,防止患者直接访问adminServlet。两个过滤器分开管理会更干净,做成PatientAuthFilter和AdminAuthFilter分别映射/patient/*和/admin/*是一种更细的做法,大作业里用一对多过滤器更有解释空间。

Session 超时时间在web.xml里配置,<session-config><session-timeout>30</session-timeout></session-config>表示 30 分钟。容易忽略的一点是req.getContextPath(),它返回应用上下文路径,比如/hospital,用response.sendRedirect(req.getContextPath() + "/login.jsp")拼出来的地址才不会被部署路径差异影响——直接从 IDEA 部署到独立 Tomcat,上下文路径可能变,拼死了/hospital/login.jsp就得改代码。这个细节是「idea 运行 javaweb 项目配置」时很容易被绊住的地方,后面避坑章节会再提。

5. 踩坑清单:IDEA 运行 javaweb 项目常见的 6 个翻车现场

场景一:启动 Tomcat 报 ClassNotFoundException: com.mysql.cj.jdbc.Driver

现象:Tomcat 启动时或第一次点预约时页面直接 500,控制台一堆 ClassNotFoundException,指向 MySQL 驱动类。

原因:两个典型来源。一是 Maven 工程里没在pom.xml引入 mysql-connector-java;二是普通 Web 工程的 jar 包放在硬盘某个角落但没复制到WEB-INF/lib,IDEA 编译时靠手动 Add as Library 能找到,但 Tomcat 运行时不会去读项目外的 jar。

解决:Maven 项目先确认<dependency>里驱动版本和数据库版本同系列;普通项目把 mysql-connector-java-8.0.33.jar 和 druid-1.2.20.jar 直接放进web/WEB-INF/lib目录,并在 Project Structure 里确认这两个 jar 被标记为 Library。改完最好 Rebuild Project 再启动。清单顺序是:lib 目录 → Project Structure → Artifacts 里看输出是否包含 jar。

场景二:IDEA 部署后页面访问 404,但文件明明在

现象:Tomcat 能启动,但浏览器访问localhost:8080/项目名/xxx.jsp报 404,IDEA 控制台也没有明显报错。

原因:最常见的是在 IDEA 里创建了普通 Java 项目而不是 Web 项目,没有自动生成 Web Artifact,或者 Artifact 的 output 目录没有把web目录带进去。另一类情况是访问http://localhost:8080/WEB-INF/jsp/scheduleList.jsp——JSP 放在 WEB-INF 下就会 404,这是正常的保护行为,不是 bug。

解决:打开 Project Structure → Artifacts,确认有且仅有一个 Web Application Exploded/War Exploded 类型的 Artifact,其 Structure 里Web Resource Directory指向web或src/main/webapp。Run 配置里 Deployment 选这个 Artifact,Application context 填/hospital或留根路径/,之后访问路径必须和这个 context 对应起来。

场景三:连接到数据库成功,但中文全部乱码,或时间差 8 小时

现象:页面表格里医生姓名显示å¼ ä¸,或者排班日期显示昨天而系统日期是今天。

原因:数据库连接 URL 没有携带编码和时区参数。项目本身全链路都是 utf8mb4,但 JDBC 驱动默认使用平台编码传输,MySQL 8.0 驱动默认时区是 UTC,两者叠加要么乱码要么日期错位。

解决:连接串统一改成jdbc:mysql://localhost:3306/hospital_db?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true。看过不少项目只在web.xml或 JSP 页面里加pageEncoding,那只解决了 HTTP 层的编码,JDBC 层没设置照样白搭。两个地方同时设才对,另外 JSP 顶部<%@ page pageEncoding="UTF-8" %>和contentType="text/html; charset=UTF-8"也要都写上,缺一个页面标题就可能出乱码。

场景四:预约时偶尔会「约了号但订单没生成」或「订单在但号源没扣」

现象:先在另一个窗口手动取消一个订单,然后在原窗口再次提交同一个排班,发现页面提示成功,但数据库里 appointment 新增了记录而 doctor_schedule 的 booked 字段没加 1。更玄学的是这个问题不是必现的,只有两个请求几乎同时提交时才出现。

原因:代码里先SELECT余量再UPDATE扣减,两步操作之间存在时间窗口,没有用事务包住,或者第二步的UPDATE失败了但前一步的INSERT已经提交。本质上就是「读判断 + 写操作」这两步没被拼成一个原子动作。

解决:按本文第 3.3 节的做法,把检查放入UPDATE的WHERE条件里,用一条条件UPDATE完成扣减并且在同一事务内插入订单。这个改动撬动的面不大,但属于答辩时最容易被追问的技术点,值得提前改好。另一个隐藏点:conn.setAutoCommit(false)之后,如果UPDATE影响行数为 0,一定要手动rollback(),不然这条连接回池子后自动提交状态可能不正确。

场景五:Tomcat 端口被占用,启动直接失败

现象:IDEA 里启动 Tomcat 时日志报Port 8080 was already in use,或者提示 Address already in use: JVM_Bind。

原因:上一个 Tomcat 实例没完全关闭,或者本机其他程序占用了 8080。IDEA 里关掉 Run 窗口的停止按钮有时只是断开了调试连接,实际 Java 进程还活着。

解决:最稳妥的是在 IDEA 的事件日志里找到线程 id,或在命令行输入netstat -ano | findstr 8080查看占用进程 PID,然后taskkill /PID <pid> /F(Windows),macOS/Linux 用lsof -i:8080查占用后kill -9。或者直接把 Tomcat 端口改成 8081,改的是conf/server.xml里<Connector port="8080" ...>。但注意改端口后,所有前端跳转如果是拼接http://localhost:8080的硬编码地址,也得同步改。我建议代码里所有跳转都用相对路径加ctxPath,避免这个坑。

场景六:MySQL 8.0 连接报 Public Key Retrieval is not allowed

现象:系统之前跑得好好的,换了一台电脑或重装 MySQL 后,连接池初始化就抛异常,提示 Public Key Retrieval is not allowed,页面直接 500。

原因:MySQL 8.0 默认认证插件是 caching_sha2_password,在 SSL 未启用的情况下,客户端需要向服务器请求 RSA 公钥来做密码加密传输。JDBC 驱动为了安全默认不自动请求公钥,需要显式打开。

解决:开发环境在 JDBC URL 上追加allowPublicKeyRetrieval=true即可。这不是漏洞开关,只是允许驱动获取公钥,内网课设场景完全可接受。如果不想加这个参数,可以把 MySQL 用户默认认证插件改回 mysql_native_password,但这条链路要改用户并重启 MySQL,反而更麻烦,我一般直接改 URL 参数。

6. 答辩现场实测:一条预约数据从提交到落库的验收路径

演示最有说服力的方式,不是机械地把页面走一遍,而是「制造异常再修复」。我每次交付前会跑一遍验证 SQL,确认业务闭环没有断点。

第一步,重置演示环境,把排班数据拨回到有余量状态:

UPDATE doctor_schedule SET booked_am = 0, booked_pm = 0, status = 1 WHERE work_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 3 DAY);

第二步,在页面上完成一次预约:用demo / 123456登录,选内科 → 张医生 → 明天上午 → 提交预约。

第三步,回数据库验证订单已生成、号源已扣减,这两条 SQL 在答辩现场直接抛给评委看:

SELECT a.id, p.real_name, d.name AS doctor_name, s.work_date, a.visit_period, a.status FROM appointment a JOIN patient p ON a.patient_id = p.id JOIN doctor_schedule s ON a.schedule_id = s.id JOIN doctor d ON s.doctor_id = d.id ORDER BY a.id DESC LIMIT 3; SELECT doctor_id, work_date, period, booked_am, am_quota, CONCAT(booked_am, '/', am_quota) AS am_used FROM doctor_schedule WHERE doctor_id = 1 AND work_date = CURDATE() + INTERVAL 1 DAY;

这两条 SQL 一跑,患者、排班、医生、订单四张表全部关联上,业务闭环当场完整呈现。第四步,再去「我的预约」页面取消这条订单,回到数据库看appointment.status是否变成 2,同时doctor_schedule.booked_am是否减一回落。取消预约同样要走事务:先改订单状态,再条件减号源,顺序不能反。

我自己的习惯是每次改完代码、交付前都重放一遍这条验收路径,并且把演示用的排班日期做成「今天到未来三天」的动态生成。曾经有一次因为初始化数据写死了日期,现场打开系统发现所有排班都在上周,页面空白,评委直摇头。后来所有排班相关的初始化统一改成基于CURDATE()的相对日期,就再没出过这类低级事故。这个工程习惯,配上事务、连接池、过滤器三位一体的实现,课程设计想低于 90 分都难。希望帮到你。

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

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

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

立即咨询