ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

从零解析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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;排班表的唯一键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 groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.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 后面加上serverTimezoneAsia/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-nameloginFilter/filter-name filter-classcom.hospital.filter.LoginFilter/filter-class /filter filter-mapping filter-nameloginFilter/filter-name url-pattern/patient/*/url-pattern /filter-mapping filter-mapping filter-nameloginFilter/filter-name url-pattern/doctor/*/url-pattern /filter-mapping filter-mapping filter-nameloginFilter/filter-name url-pattern/admin/*/url-pattern /filter-mappingFilter 里做的事情是检查 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. 前端页面与请求交互JSPJSTL 渲染预约列表避免原生 JDBC 拼字符串5.1 JSP 页面复用用 include 指令拆出头部导航页面不用做得多华丽但结构要清晰。JSP 页面如果全部复制粘贴头部导航后续改一个菜单就要改二十个文件所以我通常会拆header.jsp和footer.jsp。在需要复用的页面里用% include filecommon/header.jsp %引入。注意 JSP 静态 include 是编译期合并被包含文件里的变量不能与主页面重名。挂号页面的核心是一张医生排班表展示科室、医生、日期、时段和剩余号源。这个列表从后端 Servlet 查询后通过request.setAttribute塞给 JSP再由 JSTL 的c:forEach遍历渲染。下面是一个典型的排班列表片段% taglib urihttp://java.sun.com/jsp/jstl/core prefixc % table classtable table-hover thead tr th科室/thth医生/thth日期/thth时段/thth剩余号/thth操作/th /tr /thead tbody c:forEach vars 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 methodpost input typehidden namescheduleId value${s.scheduleId} / button typesubmit classbtn btn-primary btn-sm挂号/button /form /c:when c:otherwise span classtext-muted已约满/span /c:otherwise /c:choose /td /tr /c:forEach /tbody /tableJSTL 的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;charsetUTF-8); chain.doFilter(req, resp); } }注意这个过滤器一定要注册在其它过滤器之前。登录过滤器里如果不设置编码但编码过滤器已经先行设置问题不大但如果你把编码过滤器的url-pattern写成了/jsp/*那 Servlet 请求就不会被过滤到依然乱码。正确写法是/*同时 JSP 页面顶部要写% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-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 ListRegistrationVO 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; ListRegistrationVO 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 顶部统一写contentTypetext/html;charsetUTF-8MySQL 建库建表都用utf8mb4JDBC URL 追加?characterEncodingutf8。如果表已经用 latin1 建了用ALTER TABLE registration CONVERT TO CHARACTER SET utf8mb4;转换后再重新插入数据。这个坑是第一次做 Java Web 项目必见的但也是最好修的。6.2.3 号源超卖同一个时段被两个人同时约到现象两个患者同时点挂号数据库里registration生成了两条相同排班的记录但remaining_count只减了 1。原因挂号逻辑没有加锁或没有使用条件更新两条并发请求同时读到remaining_count1然后同时执行减一操作和插入操作事务隔离级别为可重复读时各自持有的读操作互不阻塞。解决沿用前面代码里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;charsetUTF-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 的方向迁移迁移过程本身就是一次很好的框架认知升级。希望帮到你。本文还有配套的精品资源点击获取
返回列表