
简介这是一份基于JspServlet的医院预约挂号系统完整源码包主要面向Java初学者、课程设计以及毕业设计人群用来帮助理解传统JavaWeb的分层开发方式与前后端交互过程。压缩包整体约31.95MB共收录1451个文件其中比较有代表性的是400个js交互脚本、148个html静态页面、78个jsp动态视图、62个jar依赖包、40个css样式文件以及数据库SQL脚本基本覆盖了前端展示、后端逻辑、样式美化和数据初始化等环节。系统前台具备首页展示、用户注册、医生查询、公告信息等模块后台则包含医院信息管理、科室信息管理、医生信息管理、会员信息管理和系统公告管理功能设计齐全拿到后可直接在Eclipse或IDEA中导入运行。资源同时提供详细部署教程、相关软件工具和MySQL5.7数据库脚本配合Tomcat7/8/9即可快速搭建环境适合作为课程设计或入门实战项目参考。目前已有134人学习下载值得有类似需求的开发者收藏。1. 什么是「JspServlet医院预约挂号系统」一个部署五分钟就可能翻车的老实项目如果你第一次把这个系统部署到 Tomcat 上最可能遇到的不是挂号页面而是ClassNotFoundException: javax.servlet...紧接着是数据库连接失败、JSP 里中文乱码、登录后 404最后你会怀疑是不是自己漏了什么配置。其实大部分时候系统本身没问题是你的 Tomcat 版本、JDBC 驱动和路径写法在捣乱。“JspServlet医院预约挂号系统”是 Java Web 课程设计和毕业设计里最常见的一种项目形态JSP 负责页面展示Servlet 负责接收请求和控制跳转JDBC 直接操作 MySQL 数据库。它没有 Spring Boot 的自动装配所以每一条请求链路都看得见、改得动、讲得清。这套东西解决的是“从零实现患者端预约、后台管理、号源扣减”的完整闭环适合课程设计、毕业设计也适合想补 Java Web 底子的人。2. 先把骨架立起来六张表、目录结构与配套环境的落地细节很多人一上来就写 Servlet写到一半发现缺表、缺字段、页面路径乱成一团。我一般先把数据模型和项目目录定死再写业务代码这样后面基本不会推翻重来。2.1 六张表就够了最小可用的预约挂号数据模型一个能跑通完整流程的“JspServlet医院预约挂号系统”核心数据表可以控制在六张科室表、医生表、排班表、预约记录表、患者表、管理员表。科室和医生是基础数据排班表承载号源预约记录表是业务核心患者表和管理员表分别支撑前端登录和后台登录。CREATE DATABASE IF NOT EXISTS hospital_register DEFAULT CHARACTER SET utf8mb4; USE hospital_register; CREATE TABLE department ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, description VARCHAR(255) ) ENGINEInnoDB; CREATE TABLE doctor ( id INT PRIMARY KEY AUTO_INCREMENT, dept_id INT NOT NULL, name VARCHAR(30) NOT NULL, title VARCHAR(30), intro VARCHAR(500), status TINYINT DEFAULT 1 COMMENT 1正常出诊 0停诊, CONSTRAINT fk_doctor_dept FOREIGN KEY (dept_id) REFERENCES department(id) ) ENGINEInnoDB; CREATE TABLE schedule ( id INT PRIMARY KEY AUTO_INCREMENT, doctor_id INT NOT NULL, work_date VARCHAR(10) NOT NULL COMMENT 出诊日期格式yyyy-MM-dd, time_bucket VARCHAR(10) NOT NULL COMMENT MORNING上午 / AFTERNOON下午, total INT NOT NULL DEFAULT 30, booked INT NOT NULL DEFAULT 0, CONSTRAINT fk_schedule_doctor FOREIGN KEY (doctor_id) REFERENCES doctor(id) ) ENGINEInnoDB; CREATE TABLE patient ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(30) NOT NULL, id_card VARCHAR(18), phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE appointment ( id INT PRIMARY KEY AUTO_INCREMENT, patient_id INT NOT NULL, schedule_id INT NOT NULL, appt_no INT NOT NULL COMMENT 同一排班内的排队序号, status TINYINT DEFAULT 0 COMMENT 0已预约 1已取消 2已完成 3已过期, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_appt_patient FOREIGN KEY (patient_id) REFERENCES patient(id), CONSTRAINT fk_appt_schedule FOREIGN KEY (schedule_id) REFERENCES schedule(id) ) ENGINEInnoDB; CREATE TABLE admin ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL ) ENGINEInnoDB;几个关键参数说明schedule.total和schedule.booked是防超挂的核心预约占号时不是“插入一条记录”就完事而是基于这两个字段做条件更新后面会专门讲。work_date我用VARCHAR(10)存字符串而不是DATE类型因为“yyyy-MM-dd”的字符串比较和LocalDate.parse的转换都直观也不容易踩时区坑。appointment.status用TINYINT数字枚举不要用中文枚举字符串否则乱码和统计都会很痛苦。字符集统一用utf8mb4能存 emoji 表情的患者昵称也避免中文变问号的问题。除此之外患者登录密码在实际项目中一定要加盐哈希至少用BCrypt或SHA-256这里建表只保留字段位。2.2 目录结构Eclipse 动态 Web 项目和 Maven 的等价关系这套系统最常见的目录结构是传统 Dynamic Web Project。src下放 Java 源码WebContent下放 JSP、CSS、JS 和WEB-INF/web.xml。如果你用 IDEA 或者 Maven记住一个等价关系WebContent等价于src/main/webappJSP 放错位置会导致 404而且是那种“文件明明存在但访问不到”的 404。hospital_register/ ├── src/ │ ├── com/hospital/entity/ # 实体类Doctor、Schedule、Patient │ ├── com/hospital/dao/ # JDBC 数据访问DoctorDao、ScheduleDao │ ├── com/hospital/service/ # 业务层挂号事务、退号事务 │ ├── com/hospital/servlet/ # 控制器LoginServlet、BookServlet │ └── com/hospital/filter/ # 登录拦截、编码过滤器 ├── WebContent/ │ ├── index.jsp # 首页/登录页 │ ├── patient/ │ │ ├── register.jsp # 患者注册 │ │ └── my_appointment.jsp # 我的预约列表也就是个人信息展示页 │ ├── admin/ │ │ ├── dept_list.jsp # 科室管理 │ │ └── doctor_list.jsp # 医生管理 │ ├── css/ │ ├── js/ │ └── WEB-INF/ │ └── web.xml └── WebContent/WEB-INF/lib/ └── mysql-connector-java-8.0.33.jarServlet 配置有两条路WebServlet注解或web.xml映射二选一即可。很多翻车现场是两个都写了导致同一个 Servlet 被注册两次表现为登录后随机 500 或重复执行。我习惯用注解简单直接web.xml只留欢迎页和会话超时配置。这里还需要特别提醒如果你用的是 Tomcat 10 及以上老项目的javax.servlet包已经改名为jakarta.servlet一启动就是 ClassNotFoundException。最省事的方案是直接换 Tomcat 9后面还会提到。后台管理页面如果不想自己写图标常见做法是直接在 JSP 里静态引入 Element UI 的 CDN也就是把 CSS 和 JS 的link、script标签写死在公共头部 JSP 片段里。不要在 JSP 项目里跑 npm 构建Java Web 项目的前端资源定位和前后端分离项目完全不同静态引入反而是最不容易出错的。到了这一步骨架已经立好下一章开始穿业务链路。3. 从登录到号源扣减ServletJSP 的核心业务链路怎么穿起来骨架搭完接下来是整条业务链路患者注册登录、浏览科室医生、提交预约、查看个人预约记录、取消预约。这条链路每个点都能拆成一个 Servlet 一个 JSP 对。3.1 登录会话与拦截用 Session 管住“我是谁”登录是第一个 Servlet也是 Session 的第一个使用场景。登录成功把患者 ID 和用户名放进 Session后续所有预约操作从 Session 里取当前用户而不是信任前端传过来的patientId参数。这点很重要因为前端传来的参数可以被篡改你永远不知道请求背后是谁。WebServlet(/login.do) public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); PatientDao dao new PatientDao(); Patient patient dao.findByUsernameAndPassword(username, password); if (patient null) { // 登录失败回到登录页带一个错误标记 resp.sendRedirect(req.getContextPath() /index.jsp?error1); return; } HttpSession session req.getSession(); session.setAttribute(patient, patient); resp.sendRedirect(req.getContextPath() /patient/schedule_list.jsp); } }逻辑说明req.getContextPath()返回部署后的上下文路径比如/hospital_register。如果写死/hospital_register换一个部署名整个项目就全 404 了。登录失败用重定向而不是 forward是为了防止刷新页面时表单重复提交。Session 默认 30 分钟无操作失效可以在web.xml里用session-config调整会话超时参数。这里要注意登录密码校验应该调用 DAO 里的哈希比对方法不要在 Servlet 里拼 SQL。光有登录还不够没登录的人照样能直接访问schedule_list.jsp看到排班、甚至直接 POST 一个book.do请求占号。所以还需要一个登录拦截过滤器保护/patient/*和/admin/*下面的资源。WebFilter(/patient/*) public class LoginFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpSession session req.getSession(false); if (session null || session.getAttribute(patient) null) { HttpServletResponse resp (HttpServletResponse) response; resp.sendRedirect(req.getContextPath() /index.jsp); return; } chain.doFilter(request, response); } }逻辑说明req.getSession(false)表示“如果当前没有 Session 就返回 null而不是新建一个”。很多新手直接写req.getSession()结果没登录的人访问一次页面也会被创建出 Session加大服务器内存压力。拦截路径只要写在/patient/*上index.jsp、静态 CSS 都不会被挡住避免出现“登录页样式丢了”的尴尬。3.2 预约挂号事务最容易被问倒的防超挂问题预约挂号是整个系统里最核心的业务也是最容易在答辩时被老师追问的环节。很多第一版实现是“先查schedule.booked是否小于total再 INSERT 一条预约记录”。这个写法单机单用户没问题一旦有两个请求同时查到booked29且total30就会产生两条预约记录号源就超挂了。解决思路不是加synchronized因为 Servlet 是多实例部署时锁不住而是用一条条件更新语句把“判断号源”和“扣减号源”合并成一个原子操作。public boolean book(Patient patient, int scheduleId) { Connection conn null; PreparedStatement update null; PreparedStatement insert null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关键只有 booked 小于 total 时才会真的更新成功 String lockSql UPDATE schedule SET booked booked 1 WHERE id ? AND booked total; update conn.prepareStatement(lockSql); update.setInt(1, scheduleId); int rows update.executeUpdate(); if (rows ! 1) { conn.rollback(); return false; } String insertSql INSERT INTO appointment(patient_id, schedule_id, appt_no, status) VALUES(?, ?, ?, 0); insert conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS); insert.setInt(1, patient.getId()); insert.setInt(2, scheduleId); insert.setInt(3, 0); insert.executeUpdate(); conn.commit(); return true; } catch (Exception e) { if (conn ! null) conn.rollback(); log.error(预约失败scheduleId{}, scheduleId, e); return false; } finally { DBUtil.close(insert, update, conn); } }这段逻辑说明很关键UPDATE ... WHERE booked total这一行MySQL 在行锁层面保证同一时刻只有一个事务能改这一行的booked字段两个并发请求最终只有一个rows1另一个rows0直接回滚。这就是乐观并发控制里最常用的一招写接口比“先查后插”少一次查询也天然防超挂。appt_no这里先置 0正式系统里应该在同一个事务里查当前已约人数加 1或者用数据库窗口函数生成如果只是课程设计可以在插入后查自增主键作为排队序号简单但不严谨。3.3 我的预约与退号个人信息展示页不只能看还要能改状态“我的预约”页面是典型的 JSP 个人信息展示页它要做的不是干巴巴地输出列表而是把预约记录、科室、医生、日期、号序、状态都展示出来并且提供“取消预约”按钮。退号并不是把记录DELETE掉而是把appointment.status改成1同时把schedule.booked减回去这两个操作也必须放在同一事务里。WebServlet(/cancel.do) public class CancelServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session req.getSession(false); Patient patient (Patient) session.getAttribute(patient); int apptId Integer.parseInt(req.getParameter(apptId)); boolean ok appointmentService.cancel(patient.getId(), apptId); resp.sendRedirect(req.getContextPath() /patient/my_appointment.jsp?cancel ok); } }service 层实现里先做归属校验UPDATE appointment SET status 1 WHERE id ? AND patient_id ? AND status 0。这个条件里的patient_id是防越权操作的关键参数用户只能取消自己的预约。影响行数为 1 时才去执行UPDATE schedule SET booked booked - 1。注意booked不能减成负数SQL 里要加AND booked 0否则会出现脏数据。页面提交按钮要带apptId这个参数来自列表页循环输出不要用隐藏域存整个对象只存 ID 就行。到了这里登录、预约、取消的主链路已经通了。接下来一章是把系统“喂饱”的关键编码、连接、时间、分页这四个参数别看它们不起眼几乎所有的运行期异常都出在这四类参数上。4. 四个必调参数编码、连接、日期与分页的实际取值JSPServlet 项目没有 Spring Boot 的自动配置所有参数都得自己写对。我维护过几个这种老项目总结下来真正影响存活率的就四类编码、数据库连接、日期处理、分页。4.1 编码过滤器一个 Filter 解决 80% 的中文乱码中文乱码是 JSP 项目最常见的翻车点而且乱码分三种请求参数乱码、响应页面乱码、数据库存储乱码。解决请求和响应乱码的最稳做法是写一个全局过滤器拦截所有请求在 Servlet 读取参数之前把编码设置好。WebFilter(/*) public class EncodingFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding(UTF-8); response.setContentType(text/html; charsetUTF-8); chain.doFilter(request, response); } }逻辑说明request.setCharacterEncoding只影响 POST 表单参数的解码GET 请求的查询字符串编码取决于 Tomcat 的URIEncoding配置。Tomcat 8.0 以后默认URIEncoding就是 UTF-8所以只要你在 JSP 页面顶部也声明了pageEncodingUTF-8GET 和 POST 两边都不会乱。response.setContentType(text/html; charsetUTF-8)是设置响应体的字符集如果不写Tomcat 默认按 ISO-8859-1 输出中文浏览器就会看到一堆问号。这里有个血泪经验这个过滤器必须注册在第一个位置如果你有多个 Filter编码过滤器要放在最前面否则其他过滤器已经读取了参数后面的字符集设置就不生效了。4.2 JDBC 连接参数Driver 类名和 url 的两代差异MySQL 5.x 和 MySQL 8.x 的 JDBC 驱动、URL 参数差别很大这也是ClassNotFoundException和连接超时的高发来源。直接看参数表环境MySQL 5.7MySQL 8.0驱动类名com.mysql.jdbc.Drivercom.mysql.cj.jdbc.Driver连接 URLjdbc:mysql://127.0.0.1:3306/hospital_register?useUnicodetruecharacterEncodingutf8同左并追加serverTimezoneAsia/Shanghai驱动 jar 放置WEB-INF/lib下且不能在打包时遗漏同左String url jdbc:mysql://127.0.0.1:3306/hospital_register ?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/Shanghai;这里characterEncodingutf8必须写它保证 Java 程序写进数据库的中文不是乱码。useSSLfalse是为了避免 MySQL 8 默认 SSL 握手导致的连接警告。serverTimezoneAsia/Shanghai是 MySQL 8 的强制参数不写会直接报The server time zone value XXX is unrecognized。如果是课程设计这种小并发场景不引入连接池也能跑但每次请求都DriverManager.getConnection在高并发下会慢得明显。我一般会用一个最简单的连接池配置常见参数如下参数建议值说明initialSize5启动时预建连接数maxTotal20最大连接数超过会等待maxIdle10最大空闲连接数maxWaitMillis3000拿不到连接时的等待毫秒数超时抛异常validationQuerySELECT 1连接池回收前验证连接是否可用连接池参数不是越大越好。我曾经把maxTotal调到 100结果 MySQL 默认连接数上限是 151系统没高并发反而因为连接堆积把自己搞垮。小系统maxTotal20足够关键是连接用完要close()否则池子迟早被耗尽。4.3 日期处理字符串日期比 Date 更省心JSP 项目里日期处理最容易出幺蛾子。SimpleDateFormat是非线程安全的如果把它定义成 Servlet 的成员变量两个并发请求同时parse就会出现诡异的NumberFormatException。另外浏览器表单提交的日期格式是yyyy-MM-dd直接用java.util.Date接收再格式化很容易掉进时区坑。我的做法是排班日期和预约日期都用字符串传输和存储。DateTimeFormatter fmt DateTimeFormatter.ofPattern(yyyy-MM-dd); String workDate req.getParameter(workDate); try { LocalDate d LocalDate.parse(workDate, fmt); // 只允许预约今天和未来的日期 if (d.isBefore(LocalDate.now())) { req.setAttribute(error, 不能预约过去的日期); req.getRequestDispatcher(/patient/schedule_list.jsp).forward(req, resp); return; } } catch (DateTimeParseException e) { req.setAttribute(error, 日期格式必须为yyyy-MM-dd); // 回到列表页重新选择 }逻辑说明LocalDate.parse在收到2024-02-30这种非法日期时会抛DateTimeParseException必须在接口层捕获并提示不要让它一路穿透到数据库层。字符串比较2025-01-10.compareTo(2025-01-09)的结果和日期先后一致所以work_date CURDATE()这种 SQL 判断也可以直接写字符串比较数据库索引也能用上。日期在 JSP 页面展示时不要做任何格式化直接输出字符串避免后端Date类型在页面渲染时多出一个00:00:00或者时区偏移。4.4 分页参数预约列表的 limit 写法患者预约记录会随着使用变多不能一页全查出来。分页的核心是pageNo和pageSize两个参数SQL 用LIMIT offset, pageSize实现。常见的翻车是pageNo从 0 开始还是从 1 开始不一致前端传 1后端却用 0 当 offset导致第一页数据丢掉。SELECT a.id, d.name AS dept_name, doc.name AS doctor_name, s.work_date, s.time_bucket, a.appt_no, a.status, a.create_time FROM appointment a JOIN schedule s ON a.schedule_id s.id JOIN doctor doc ON s.doctor_id doc.id JOIN department d ON doc.dept_id d.id WHERE a.patient_id ? ORDER BY a.create_time DESC LIMIT ?, ?;后端计算 offset 时int offset (pageNo - 1) * pageSizepageNo从 1 开始pageSize默认 10。对于pageNo小于 1 的非法输入要重置为 1pageSize超过 50 要截断防止一次拉走全表。ORDER BY a.create_time DESC是预约列表的排序关键没有它分页会重复或漏数据。如果列表还要显示“已取消/已完成”状态可以在 SQL 后面加AND a.status ?但这个筛选条件要在分页总数 SQL 里也写一遍否则页数统计会错。5. 部署与避坑war 包、Tomcat 版本与五个高发问题排查代码写得再顺部署不上等于零。JSPServlet 项目的部署方式相对固定但版本兼容问题特别多这一章把部署步骤和最高发的五个问题一次说清。5.1 从源码到 war 包两种打包路径最传统的做法是在 Eclipse 或 IDEA 里导出 war 包然后丢进 Tomcat 的webapps目录。IDEA 用户一般先配置 ArtifactsBuild Build Artifacts Build输出目录里就是hospital_register.war。Eclipse 用户直接右键项目 Export WAR file即可。如果你用 Maven 管理依赖mvn clean package生成 war 包但要注意pom.xml里packaging必须是war而不是jar否则 Tomcat 只会给你一个解压不出来的死包。# 将 war 包部署到 Tomcat cp hospital_register.war $CATALINA_HOME/webapps/ cd $CATALINA_HOME/bin ./startup.sh # 默认访问地址 # http://127.0.0.1:8080/hospital_register/war 包部署后 Tomcat 会自动解压。如果之前部署过旧版本第二次覆盖时要把旧的解压目录一起删除只覆盖 war 包会导致类文件新旧混用出现“改了代码不生效”的玄学问题。这是部署后的第一坑而且你几乎必然会遇到。5.2 高发问题一Tomcat 10 下 javax.servlet 包直接 ClassNotFoundException现象项目在 Tomcat 9 上好好的换成 Tomcat 10 启动后页面一访问就报java.lang.ClassNotFoundException: javax.servlet.ServletException或NoClassDefFoundError。原因Tomcat 10 起把 Servlet 规范从 Java EE 迁移到 Jakarta EE所有javax.servlet.*都改成了jakarta.servlet.*老代码和依赖老 Servlet API 的第三方 jar 全部失效。解决最省事的做法是回到 Tomcat 9 部署JSPServlet 老项目不要轻易迁移到 Tomcat 10。如果必须用把源码里所有import javax.servlet批量替换成import jakarta.servlet并且移除WEB-INF/lib里自带的旧servlet-api.jar否则两个 jar 的类冲突会让 Tomcat 直接拒绝启动。没有后悔药可吃选版本前先确认环境。5.3 高发问题二MySQL 驱动加载失败与 timezone 报错现象启动不报错但一执行 DAO 查询就报ClassNotFoundException: com.mysql.jdbc.Driver或者The server time zone value 中国标准时间 is unrecognized。原因驱动 jar 没有真正进入WEB-INF/lib或者 MySQL 8 用了 MySQL 5 的驱动类名以及 URL 缺少serverTimezone参数。解决把mysql-connector-java-8.0.33.jar放进WEB-INF/lib确认不是只放在 IDE 的 classpath 里MySQL 8 的连接 URL 必须带serverTimezoneAsia/ShanghaiMySQL 5.7 则继续用com.mysql.jdbc.Driver。判断驱动是否生效可以打开解压目录看WEB-INF/lib里有没有 jar 文件不要只盯 IDE 里的引用列表。5.4 高发问题三登录后跳转 404CSS 和图片全部丢失现象登录成功后跳转到http://127.0.0.1:8080/patient/my_appointment.jsp显示 404或者页面 HTML 出来了但 CSS、图片全是 404。原因页面里用了相对路径hrefcss/style.css在hospital_register/patient/这个 URL 下浏览器会解析成hospital_register/patient/css/style.css自然找不到。显示图片同理JSP 里图片路径必须基于上下文根。解决所有静态资源和页面跳转统一在 JSP 里加一个base标签或者直接用 JSP 表达式拼绝对路径。% String ctx request.getContextPath(); % base href%ctx%/ link relstylesheet hrefcss/style.css img srcimages/doctor.png alt医生头像表单提交也一样道理action必须写绝对路径form action${pageContext.request.contextPath}/book.do methodpost${pageContext.request.contextPath}是 JSP EL 表达式等价于request.getContextPath()。不要写死/hospital_register/book.do换个部署名又是 404。这是我见过最多的一类问题根源就是“相对路径在子目录页面里失效”养成所有路径都带上下文根的习惯能一次治好。5.5 高发问题四双击提交按钮生成两条预约记录现象患者点了一次“提交预约”数据库里出现两条 appointment 记录号源被重复扣减。原因第一次提交还没返回用户又点了一次或者页面用 JS 做了window.location.reload()想在“加载完后刷新一次”拿到最新状态把上一次 POST 又提交了一遍。解决前端提交后立刻把按钮置灰禁用后端在book()里用事务内的条件更新兜底提交成功后用sendRedirect而不是forward刷新页面只会重放 GET 请求不会重复 POST。不要在预约提交页面里写location.reload()这个函数在 GET 页面用可以在表单页用就是在制造重复数据。5.6 高发问题五数据库里中文变成问号现象页面显示正常但打开数据库表看到患者姓名是???。原因JSP 页面编码、请求编码、连接 URL 编码、数据库表字符集四层里面有一层不是 UTF-8。解决建库时用DEFAULT CHARACTER SET utf8mb4连接 URL 带characterEncodingutf8JSP 文件头部声明pageEncodingUTF-8最后用第一节的编码过滤器统一收口。这四层我一般按“数据库表 → 连接 URL → JSP 声明 → 全局 Filter”的顺序排查每改一层就测一次基本能定位到具体是哪层漏了。6. 验证与进阶用自测脚本证明系统能挂号、能退号、不超挂功能写完不是终点要能证明它稳定才算真正完成。我每次交付前都会跑一遍自测脚本覆盖“未登录拦截、登录、预约、退号”四条链路。这里分享一个简单的 bash 自测方式不用装额外工具curl 加 grep 就够。#!/usr/bin/env bash BASEhttp://127.0.0.1:8080/hospital_register # 1. 未登录访问受保护资源应该跳回登录页而不是 200 curl -s -o /dev/null -w %{http_code}\n $BASE/patient/my_appointment.jsp # 2. 模拟登录并保存会话 Cookie curl -s -c cookies.txt -d usernametestpassword123456 $BASE/login.do # 3. 提交预约请求判定是否提示成功 curl -s -b cookies.txt -d scheduleId1 $BASE/book.do | grep -q 预约成功 echo book ok # 4. 退号后再查一次列表状态应变为“已取消” curl -s -b cookies.txt -d apptId1 $BASE/cancel.do | grep -q 取消成功 echo cancel ok测试时注意第一次请求返回的 200 可能只是登录页本身的状态码所以要用grep去匹配页面里的关键文案而不是只看 HTTP 状态码。另外在跑脚本前先把schedule.total临时改成 1然后并发请求两次book.do能直观验证防超挂是否生效。这个方法比手动点页面快很多也适合在答辩前做冒烟测试。如果想再进阶一步可以做两个小优化。一是把管理端和患者端的权限用两个 Filter 分开避免患者直接访问/admin/*的后台页面二是把挂号数据按科室做一个统计页面SQL 里GROUP BY department.name加COUNT(appointment.id)这个统计在答辩时非常加分。还有一点如果要做诊室平面图或候诊区可视化不要在 JSP 里写死图片坐标那是维护黑洞常见做法是把桌位或房间的坐标存成一张数据表页面循环输出位置参数图片只负责底图渲染这样调整点位不用改页面。我自己以前每次交付这类 JspServlet 项目总在 Tomcat 版本和路径上栽跟头。后来养成了一个习惯部署前先确认外部环境再跑一遍自测脚本最后才提交代码。这个流程帮我省下大量半夜排错的时间希望帮到你。本文还有配套的精品资源点击获取