
简介面向Java Web开发学习者和毕业设计学生本压缩包提供一套可运行的医院门诊挂号系统完整工程。系统以Java为基础集成SSM框架Spring、SpringMVC、MyBatis前端采用Vue和JSP组合运行于JDK1.8、Tomcat7与MySQL 5.7环境涵盖患者挂号、科室排班、医生管理、就诊记录等典型业务模块适合作为高校毕设或Java Web项目实训蓝本。包体约15.3MB包含项目源码、数据库脚本及功能介绍文档源码按控制层、业务层、数据访问层分层组织便于阅读和二次开发数据库脚本可一键建表帮助快速初始化环境。目前已有2736人学习下载项目经过严格调试能够稳定启动运行。通过学习这套资源读者不仅能掌握SSM整合与Vue/JSP前后端协作的开发流程还能获得医疗业务系统的完整设计思路为类似管理系统开发提供直接参考。1. 医院门诊挂号系统到底要解决什么SSMJSP 这套组合为什么还在被选择平时总有人问我现在前后端分离、Spring Boot 都这么成熟为什么还要去折腾 SSM 框架配 JSP 的旧项目。但现实中大量中小型医院、社区诊所的信息化系统恰恰就是这套技术在支撑。这个以 ssm9008 为标识的医院门诊挂号系统就是典型的 SSM 框架加 JSP 视图层完整工程把患者建档、科室分诊、医生排班、挂号缴费、退号处理这几条核心链路都做进了同一个 Web 应用里。对想系统学习 SSM 整合的开发者或者需要快速搭建门诊挂号原型的工程师来说这套代码把业务完整度和技术栈经典程度结合得比较到位。它的业务边界很清晰患者端能查科室、看排班、挂号和退号医生端能查看自己的号源和接诊列表管理员端维护科室与排班。三种角色对应三种权限页面路由都由 JSP 会话机制控制。适合两类人一类是刚学完 SSM 课程、需要综合项目练手的开发者另一类是医院信息科或外包团队需要快速出原型、后续再迭代改造的工程师。下面就从环境搭建开始把这套系统一步步跑起来。2. 把 ssm9008 跑起来的完整过程环境选型、数据库初始化和三个必改参数2.1 JDK、Tomcat 与 Maven 版本搭配先避开最玄学的版本坑跑任何 SSM 项目第一步都不是打开代码而是确认本机环境。这个项目的技术栈决定了版本上限要控制在 JDK 8 或 11其中 JDK 8 最稳妥——Spring 4.x 或早期 Spring 5.x 对 JDK 9 以上的模块化支持并不好反射操作会触发模块访问限制启动时直接抛 IllegalAccessException 的案例不少。常见做法是 JDK 8 配合 Tomcat 8.5 或 9.0Maven 用 3.6.x 或 3.8.x。如果机器上已经装了 JDK 17建议单独保留一个 JDK 8或用 IDEA 的 Project Structure 把 Project SDK 指到 8同时把 language level 也改成 8。然后是部署方式。这类压缩包通常是一个标准 Maven Web 工程结构上包含 src/main/java、src/main/resources 和 src/main/webapp。解压后别急着丢进 Tomcat 的 webapps 目录正确做法是用 IDEA 把它作为 Maven 项目导入等依赖解析完成后再配置 Tomcat Server。这一步容易卡住的是 Maven 中央仓库下载依赖太慢国内一般配置阿里云镜像在 settings.xml 的 mirrors 节点加一个镜像地址依赖解析速度会有明显改观。mirror idaliyunmaven/id mirrorOfcentral/mirrorOf nameAliyun Maven Repository/name urlhttps://maven.aliyun.com/repository/public/url /mirror这个配置把中央仓库的请求全部转给阿里云镜像mirrorOf 只填 central避免影响其他自定义仓库的行为。镜像配好以后在 IDEA 右侧 Maven 面板点刷新等底部进度条走完如果依赖列表没有任何报红说明 spring-webmvc、mybatis、druid 这些核心依赖都解析成功。这一步常见的翻车表现是 pom.xml 里某个依赖显示红色下划线多数原因是本地仓库缺包而不是代码写错。2.2 初始化 MySQL 数据库从建库到测试数据落地的步骤这类项目的数据库脚本一般放在压缩包的 db 或 sql 目录下一个完整的 SSM 工程通常包含建库、建表、初始化数据三段 SQL。注意脚本的存放位置暴露的是作者的工程习惯有的放在 src/main/resources 里有的独立在根目录 db 文件夹下名字可能叫 ssm9008.sql 或 init.sql。实际操作时我推荐用命令行而不是可视化工具刷脚本因为命令行能把每段 SQL 执行到哪里报错看得清清楚楚。以 MySQL 5.7 为例先创建数据库再指定字符集导入脚本mysql -uroot -p -e CREATE DATABASE IF NOT EXISTS ssm9008 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; mysql -uroot -p ssm9008 db/ssm9008.sql把数据库字符集固定为 utf8mb4而不是老的 utf8是为了让患者姓名、科室名称这些中文数据在排序和存储上不出问题。执行完以后用 show tables 确认表清单典型的表有 t_user、t_doctor、t_department、t_schedule、t_registration 等。建表语句里如果存在外键约束导入时最好按顺序执行否则会因为依赖的表还没创建而报错。如果对表结构不放心可以用 desc t_schedule 这样的命令逐张表确认字段类型。导入后还要检查测试数据是否完整。很多系统启动后首页能开但点进科室页面一片空白原因就是初始化 SQL 里的测试数据量太少或者某个外键关联的 ID 对不上。此时可以查一下 t_department 里有没有记录如果这张表是空的后续所有关联科室的查询都会返回空集合页面就会展示成无数据但有布局的形态这是一种不容易警觉的踩坑点。2.3 配置文件里的三个必改参数数据源、端口与资源路径SSM 项目里的配置文件比较分散spring-mvc.xml、spring-mybatis.xml、jdbc.properties、log4j.properties 各管一摊。首先要改的就是 jdbc.properties 里的数据库连接信息jdbc.drivercom.mysql.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/ssm9008?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai jdbc.usernameroot jdbc.password123456driver 这里有个关键区别MySQL 驱动如果是 8.x驱动类要写成 com.mysql.cj.jdbc.Driver5.x 版本写成 com.mysql.jdbc.Driver 即可。两者混用会在启动时报 ClassNotFoundException而且报错信息往往指向 Hibernate 或 Druid 初始化失败容易误导你往连接池方向排查。修改完数据库密码后Tomcat 启动时若看到 Access denied for user基本就是这里的密码没有同步。第二个必改参数是 Tomcat 端口多数情况下 8080 会被其他进程占用。改了 Tomcat 端口以后访问路径从 8080 变成新端口这本身很简单但有个延伸问题JSP 页面里如果写死了 这样的绝对地址端口一换这些链接会全部打不开。看代码时要留意 contextPath 是否被动态拼接。第三个是日志路径。log4j.properties 里的输出目录如果不存在日志文件写不进去应用不会直接崩溃但排查问题时发现没有日志比报错更难受。常见的 log4j 配置会写 log4j.appender.File.Filelogs/ssm9008.log启动前先确认相对路径指向的目录是否存在。这三个参数改完项目启动后访问登录页能够正常跳转到 JSP 并展示图片和样式说明环境这关就过了。3. 拆解挂号请求的完整链路Controller、Service、Mapper 和 JSP 怎么协作3.1 从登录到挂号一次请求经过的六个节点假设一个患者要挂内科的号从登录到挂号成功中间要经过 JSP 页面发起请求、DispatcherServlet 分发、HandlerMapping 定位 Controller、Service 层执行业务、Mapper 访问数据库、再回到 JSP 渲染结果这六个节点。理解这条链路是后续改造系统的前提因为无论是加一个支付接口还是调整号源规则定位问题都要沿着这条线走。JSP 页面发起请求最常见的方式是表单提交。传统 JSP 项目很少用 Ajax 去刷数据而是用 form 表单 POST 到后端再由后端返回一个新的 JSP 页面。所以阅读代码时有个规律Controller 方法的返回值大多是 registration/list 这样的字符串对应 webapp/WEB-INF/views 下的目录结构而不是 JSON。掌握这个规律后看到返回字符串就能猜到页面存放位置修改页面效率会高很多。Controller RequestMapping(/registration) public class RegistrationController { Autowired private RegistrationService registrationService; RequestMapping(/toRegister) public String toRegister(Integer scheduleId, Model model) { Schedule schedule registrationService.findScheduleById(scheduleId); model.addAttribute(schedule, schedule); return registration/register; } }上面是典型的页面跳转型 Controller 写法。方法入参 scheduleId 由请求参数自动绑定返回值指向 JSP 路径。Model 里放的是页面要展示的数据JSP 端通过 EL 表达式直接取。注意这里没有加 ResponseBody所以不会返回 JSON 给前端。如果写成了 ResponseBody页面就会显示一串 JSON 文本而不是渲染后的 HTML这是新手常犯的翻车操作。3.2 MyBatis 动态 SQL查询号源与排班信息的关键写法挂号系统的核心之一是排班查询。一个医生一周出诊六天每天上下午各放出一定数量的号这些数据存在排班表和号源表里。前端要按科室和时间筛选医生就得在 Mapper 里写动态 SQL。这里的 if 标签和 where 标签是使用频率最高的两个元素。select idfindSchedules resultTypecom.ssm9008.entity.Schedule SELECT s.*, d.real_name AS realName, dep.name AS departmentName FROM t_schedule s LEFT JOIN t_doctor d ON s.doctor_id d.id LEFT JOIN t_department dep ON d.department_id dep.id where if testdepartmentId ! null and departmentId ! 0 AND dep.id #{departmentId} /if if testscheduleDate ! null and scheduleDate ! AND s.doctor_date #{scheduleDate} /if /where ORDER BY s.doctor_date, s.shift_type /select这个 SQL 用 LEFT JOIN 连接三张表把医生姓名和科室名称冗余到排班查询结果中避免 JSP 页面上二次查表。where 标签会自动去掉第一个多余的 AND这是 MyBatis 的贴心设计手写 SQL 反而容易忘记这个细节。参数里的 departmentId 和 scheduleDate 由 Controller 从 request 中取出来塞进实体或直接作为 Mapper 方法入参。这里有一个高频踩坑点Mapper 接口方法如果有两个以上参数必须用 Param 注解否则 MyBatis 无法识别参数名会抛出 BindingException。ListSchedule findSchedules(Param(departmentId) Integer departmentId, Param(scheduleDate) String scheduleDate);加了 Param 以后XML 里的 #{departmentId} 才能正确匹配到入参。如果不加老版本 MyBatis 会报 parameter departmentId not found新版本则可能绑定到 arg0、arg1 这类位置参数表现是 SQL 能跑但查出来的结果不对。这种隐蔽的错误排查起来比较耗时所以建议从一开始就养成所有 mapper 方法超过一个参数就写 Param 的习惯。3.3 JSP 页面里个人信息展示与图片定位的细节JSP 做个人信息展示时EL 表达式和 JSTL 标签库是主力。医生列表、号源余量这些数据通常在 Controller 里塞进 Model然后页面用 c:forEach 循环输出表格行。这个套路几乎每个业务模块都一样。有个让新手头疼的细节是列表最后一列的操作按钮要带上当前行的 ID一般写成a href${pageContext.request.contextPath}/registration/toRegister?scheduleId${item.id}挂号/a${item.id} 取当前循环项的 IDpageContext.request.contextPath 会动态拼接应用部署上下文路径。很多项目部署后页面图片、CSS 全部丢失就是因为路径写成了 /img/doctor.png 这种绝对路径而应用上下文名不是根目录。正确的图片引用方式img src${pageContext.request.contextPath}/img/doctor/${doctor.photo} /关于 JSP 图片如何对坐标定位这个搜索词在设计挂号单打印模板或证件照上传预览时才会用到。常规做法是给图片外层套一个相对定位的 div内部需要叠加的元素用绝对定位去覆盖特定区域。比如在证件照上叠加一个姓名标签div styleposition: relative; width: 300px; img src${pageContext.request.contextPath}/img/patient/${patient.photo} stylewidth: 100%; / span styleposition: absolute; left: 60px; bottom: 20px; color: #fff;${patient.name}/span /div而如果要在 JSP 中实现 mp4 视频播放通常不靠 JSP 本身而是用 HTML5 的 video 标签指定一个可访问的静态资源地址。比如医院官网挂健康宣教视频JSP 只是负责把视频地址渲染进 video 标签真正的解码和播放交给浏览器。搜索里提到的 JSP 个人展示、毕业论文管理系统等话题本质都在处理同一类 MVC 模式下页面与业务数据的绑定问题做法都是后端查数据、JSP 渲染、页面交互用表单或请求参数驱动。4. 门诊挂号的核心业务逻辑号源设计、并发控制和退号事务4.1 号源表与排班表的设计为什么余号要单独一列很多刚接触挂号系统的人会想当然地认为剩余号数不必存储用总号数减去已挂号数就行了。但这个思路在关系型数据库里实现起来非常别扭每次查询剩余号数都要 count 一次 t_registration而排班列表页往往要展示一整个星期的号源情况不加缓存的话数据库压力会很大。这个项目里的设计更实际排班表直接带一个 remain_count 字段挂号成功减一退号成功加一查询的时候直接取这一列。CREATE TABLE t_schedule ( id INT PRIMARY KEY AUTO_INCREMENT, doctor_id INT NOT NULL, doctor_date DATE NOT NULL, shift_type TINYINT COMMENT 1上午 2下午, total_count INT DEFAULT 30, remain_count INT DEFAULT 30, create_time DATETIME, KEY idx_doctor_date (doctor_date) ) COMMENT 医生排班表;这个表结构里shift_type 用 TINYINT 而不是字符串节省空间也方便扩展。remain_count 单独成列意味着每次挂号和退号都要更新它。这里有一个重要的设计原则余号字段的更新必须和挂号记录创建放在同一个数据库事务里否则会出现号已挂上但余号没减的情况。排班表 ID 是业务关联的核心t_registration 表通过 schedule_id 引用它而不是通过 doctor_id 加日期这样退号和改签时只需要一个外键。4.2 挂号的并发问题为什么不能先查后改门诊挂号对并发的敏感度不亚于秒杀。早高峰多个患者同时挂同一个医生的号最经典的错误是先 SELECT 查出 remain_count 大于 0然后在 Java 代码里判断再 UPDATE。这个流程在两个请求同时进来时可能两个连接都查到余号还剩 1然后各自执行扣减最后数据库里的余号变成 -1。正确的做法是把判断和扣减合并进同一条 UPDATE利用数据库行锁保证原子性。UPDATE t_schedule SET remain_count remain_count - 1 WHERE id #{scheduleId} AND remain_count 0这条 UPDATE 是防超卖的关键。数据库在执行这条语句时会锁定命中的行第二个事务只能等第一个事务提交后才能执行。affected row 为 0 说明号源已被抢完业务层立刻抛出异常提示患者改选其他时段。使用这条语句时要注意 Service 方法必须有事务注解否则 UPDATE 自动提交事务边界不对的话插入挂号记录成功、扣减号源失败的情况就会让数据不一致。4.3 退号与重新放号事务边界怎么划退号是个典型的跨表写操作要把 t_registration 的状态从已挂号改成已退号同时把 t_schedule 的 remain_count 加回 1。这两个操作必须在一个事务里。如果先改状态成功、加号失败患者会发现自己明明退了号号源却没释放投诉就来了。Transactional(rollbackFor Exception.class) public void cancelRegistration(Integer registrationId) { Registration reg registrationMapper.selectById(registrationId); if (reg null || !1.equals(reg.getStatus())) { throw new BusinessException(挂号记录不存在或已退号); } registrationMapper.updateStatus(registrationId, 0); scheduleMapper.increaseRemainCount(reg.getScheduleId()); }注意 Transactional 注解里的 rollbackFor 设为 Exception.class。Spring 默认只在抛出 RuntimeException 时回滚而很多业务代码会抛自定义的 checked exception不加 rollbackFor 的话事务不会回滚数据库就会被写坏一半。另一个坑是 Transactional 对自调用不生效同一个类里的方法 A 调用方法 BB 上有事务注解也不会走到代理逻辑这个特性让不少人排查事务不生效排查到怀疑人生。实际开发时如果发现事务没生效先看调用链是不是 this.method() 这种自调用形式。5. 避坑与常见问题排查从 404 到数据乱码的血泪经验5.1 启动后一直 404web.xml 与 SpringMVC 映射的坑现象Tomcat 启动没有报错日志显示部署成功但访问任何一个页面都返回 404连静态资源也打不开。原因web.xml 里 DispatcherServlet 的 url-pattern 配置不当或者项目没有被正确识别为 Web 工程。有些压缩包里的 web.xml 把 servlet-mapping 配成了 /意思是拦截所有请求此时如果 mvc:resources 或 mvc:default-servlet-handler 没有配置静态资源和 JSP 也会被拦截后找不到处理器直接 404。解决方式分两步先确认 WEB-INF/web.xml 存在且 servlet-mapping 的路径符合预期再检查 spring-mvc.xml 里是否配置了资源映射。mvc:resources mapping/static/** location/static// mvc:annotation-driven/5.2 中文乱码JSP、请求参数、数据库三处同时踩雷现象页面上患者名称显示成问号或者表单提交的中文到数据库后变成乱码。原因往往是链路上三个地方至少一个没对齐。JSP 页面头部没有设置 pageEncodingTomcat 的 URIEncoding 不是 UTF-8数据库连接串里没有 characterEncodingutf8任意一个环节断了都会出问题。解决方式是把三处全部统一为 UTF-8一个都不能少。请求参数层面高版本 Tomcat 默认对 POST 表单使用 UTF-8但 GET 请求的查询字符串依赖 connector 的 URIEncoding 配置建议在 server.xml 里显式配置。Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 URIEncodingUTF-8/数据库层面连接串里的 characterEncoding 和表本身的字符集也要一致。如果表已经创建成了 latin1单纯改连接串没有效果需要转成 utf8mb4 后重新导入数据。5.3 MyBatis 参数绑定失败Param 缺失导致的黑匣子错误现象Mapper 方法接收两个参数调用时抛出 BindingException 或者返回空结果但单参数时一切正常。原因MyBatis 对多参数方法默认包装成 Param1、Param2 或 arg0、arg1XML 里写的名字自然匹配不上。解决方式是在接口方法里显式加 Param。这个错误最坑的地方在于老版本 MyBatis 只有在运行时才会暴露编译阶段完全看不出来新手往往第一反应是去检查 SQL 语法。5.4 静态资源与图片全部丢失绝对路径的后果现象登录页可以打开但页面上的 CSS 和图片全部显示破损图标查看元素发现 src 指向 localhost:8080/static/css/style.css缺少项目上下文名。原因JSP 里写死绝对路径部署上下文名一旦不是根路径就失效。解决方式统一用 pageContext.request.contextPath 拼接路径或者在 JSP 页面顶部用 c:set 定义一个 base 变量供全局复用。c:set varctx value${pageContext.request.contextPath} / link relstylesheet href${ctx}/static/css/style.css5.5 Druid 连接池启动报错驱动类与防火墙的陷阱现象应用启动后 Druid 报 SQLException: Connections could not be acquired具体错误是 Access denied 或 Communications link failure。原因分两种Access denied 是账号密码错误Communications link failure 则是 MySQL 端口不通。如果 MySQL 和 Tomcat 在同一台机器上后者多半是 MySQL 没启动或端口配置不是 3306。检查的方式是 telnet 127.0.0.1 3306 或查看 MySQL 的错误日志确认端口在监听。6. 把门诊挂号系统往生产方向改造验证清单与一套排查习惯项目跑通以后我更看重的是如何把它从课设水平往前推一步。这里分享一份我自己会对照的验证清单覆盖功能完整性、并发能力和可维护性三个维度。功能上至少要验证三个核心场景正常挂号流程、余号不足时的提示、退号后的号源恢复。并发层面可以用 JMeter 或 Postman 的 Collection Runner 模拟 50 个并发请求同时挂最后一个号观察数据库里最终余号是否为 0挂号记录是否只有一条成功。这一步能直接检验 4.2 节那条 UPDATE 是否真的在业务代码中生效。可维护性层面我会把 log4j 换成 Logback并按日期滚动保留日志文件方便追溯退号记录和挂号异常。JSP 页面里如果出现 SQL 拼接痕迹我会改成参数化查询。如果医院对性能有更高要求还可以用 Redis 缓存号源余量把 UPDATE 落库改成异步扣减加消息队列但这需要额外的中间件维护不是第一优先级。我的习惯是先把数据一致性做扎实再谈性能。另一个值得投入的改造点是权限控制。原始项目通常只在 JSP 里判断 session 中的用户类型比如判断 userType 等于 1 才显示管理菜单但后端 Controller 没有拦截器校验。这意味着一个普通患者只要猜到管理页面的 URL就能直接访问管理员功能。我会在 spring-mvc.xml 里配置一个拦截器按用户类型过滤这些敏感路径public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user request.getSession().getAttribute(loginUser); if (user null) { response.sendRedirect(request.getContextPath() /login); return false; } return true; } }这段拦截器不仅拦截未登录用户还能在 preHandle 里做基于角色的 URL 白名单判断。生产环境里这种权限校验必不可少否则系统上线就是裸奔状态。最后提一个老生长谈的习惯每次改动数据库表结构都在本地保留一份增量脚本而不是直接改原来的建表语句。这样部署到服务器时不会出现字段对不上的问题。这套项目虽然技术栈偏老但把它吃透之后无论是转 Spring Boot 还是重构业务模块都会有一个扎实的底子。希望帮到你。本文还有配套的精品资源点击获取