简介:一套基于Java语言、SSM框架与Vue/JSP前端技术构建的医院门诊挂号系统项目,面向需要完成毕业设计或希望深入理解前后端分离开发的读者。项目采用Spring、SpringMVC、MyBatis搭建后端,前端融合Vue组件化与JSP动态渲染,覆盖预约挂号、医生排班、患者管理等核心门诊业务;运行环境为JDK1.8、Tomcat7+与MySQL5.7+,符合教学与毕设场景。压缩包约15.3MB,主要包含项目源码、MySQL数据库脚本与功能介绍文档,源码可导入IDE二次开发,脚本可快速初始化表结构与测试数据,文档便于梳理模块和操作路径。已有2736人学习,项目经严格调试可稳定运行,既是毕业设计的完整参考,也有助于掌握SSM整合、Vue交互及医院信息系统落地技巧。
1. 为什么医院门诊挂号系统还在用SSM+JSP:别急着追新
看到「ssm9008医院门诊挂号系统+jsp.zip」这个标题,你可能会疑惑:SSM框架加JSP,这套组合已经算是JavaWeb里的老面孔了,为什么还有人在做、还有人要看?答案很直接:它是国内高校软件工程、计算机科学专业里最经典的毕设选题之一,也是很多中小型医院信息系统里真实跑着的技术栈。Spring管对象,SpringMVC接请求,MyBatis读写数据库,JSP负责渲染页面——四层分工明确,代码路径短,数据库操作直白,特别适合一个人从零到一交付整个业务系统。
这个系统解决的实际问题很具体:患者通过网页查科室、看医生排班、选时间段挂号;后台管理员维护科室、医生、排班和号源;数据库里记录每一笔挂号流水。说白了,它考验的是你对SSM三个框架的整合能力、关系型数据库建模的基本功,以及JSP里form表单、请求转发、EL表达式这些最基础的JavaWeb技能。适合谁来看这篇文章?准备做基于JSP的毕设选题的毕业生、想系统补一遍JavaWeb全链路的开发新手、以及需要快速搭一个内部管理系统的年青工程师。下面我按照自己做过类似项目的顺序,把这个系统从zip包到能跑、能答辩的整个过程拆给你看。
2. 从zip包到可运行工程:SSM框架分层与项目结构拆解
拿到一个「医院门诊挂号系统」的zip包之后,大多数人第一反应是解压、导入IDE、点运行,然后被一堆报错教做人。SSM项目有一个特点:它的可运行性不只在代码里,更在配置里。一份规范的分层结构,决定了你后面改功能、查bug、加报表时,是顺手还是抓狂。这一章我先把结构和配置讲透,这是后面所有功能的地基。
2.1 分包结构与JSP Model 2思想:代码该放在哪里
SSM项目本质上是JSP Model 2思想的工程化实现:Servlet(SpringMVC的DispatcherServlet)负责接收请求和调度,JavaBean(Service+Mapper)负责业务和数据处理,JSP只负责把数据渲染成页面。按照这个思想,分包应该一眼能看出「谁在处理请求、谁在写业务、谁在碰数据库」。我一般会这样组织:
src/main/java ├── com/hospital/ssm │ ├── controller # 控制层:接收参数,调用service,返回视图名 │ │ ├── LoginController.java │ │ ├── RegisterController.java │ │ ├── ScheduleController.java │ │ └── AdminController.java │ ├── service # 业务层:事务边界都在这层 │ │ ├── ScheduleService.java │ │ └── impl/ScheduleServiceImpl.java │ ├── mapper # MyBatis持久层接口 │ │ ├── DoctorMapper.java │ │ ├── ScheduleMapper.java │ │ └── RegistrationMapper.java │ ├── entity # 数据库表映射实体 │ │ ├── Doctor.java │ │ └── Schedule.java │ └── common # 通用工具、统一返回结果、异常 │ ├── Result.java │ └── BizException.java └── resources ├── jdbc.properties ├── spring-context.xml # Spring容器配置 ├── spring-mvc.xml # SpringMVC配置 ├── mybatis-config.xml # MyBatis全局配置 └── mapper # Mapper XML文件 ├── DoctorMapper.xml └── ScheduleMapper.xml分包的原则是「上层依赖下层,不能反向依赖」。controller不直接碰MyBatis的SqlSession,service不出现HttpServletRequest,mapper接口和mapper XML的namespace严格一一对应。很多人翻车的点是:图省事把业务逻辑写在controller里,结果后面要加一个挂号记录导出功能,不得不在controller里复制一大段查询代码,改了业务逻辑还要同时改两个地方。SSM项目最怕的就是这种「拷贝式复用」。
JSP页面放在src/main/webapp/WEB-INF/jsp目录下,而不是webapp根目录。为什么要放WEB-INF里面?因为WEB-INF下的资源不能通过URL直接访问,用户只能通过controller返回的视图名访问,相当于一个最低限度的访问控制。我见不少项目把JSP直接丢在webapp根目录,URL一猜就能打开页面,参数校验也裸奔,这种项目拿去答辩是会被问倒的。
2.2 三个XML配置串起SSM:web.xml、spring-mvc.xml、mybatis-config.xml
SSM整合之所以让新手头疼,是因为三个框架各管一摊,配置互相引用。我把它们拆成三条线来看:
- Spring管service和mapper的bean创建与事务;
- SpringMVC管controller与视图解析,是web入口;
- MyBatis管SQL映射,不参与请求处理。
web.xml是总入口,它干两件事:用ContextLoaderListener启动Spring容器,并注册DispatcherServlet加载SpringMVC配置。我写过一个最小可运行的web.xml配置:
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1"> <!-- 加载Spring容器配置 --> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-context.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- 编码过滤器必须在最前面,解决POST中文乱码 --> <filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- SpringMVC前端控制器 --> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> </web-app>这段配置里,load-on-startup必须写1,意思是Tomcat启动时就初始化DispatcherServlet,否则第一次请求才会加载SpringMVC容器,启动时的配置错误会延迟到线上才暴露。CharacterEncodingFilter则一定要放在DispatcherServlet之前注册,因为它拦的是/*,如果请求先进了SpringMVC再进Filter,POST参数在解析时就已经是乱码了。
spring-mvc.xml里最需要注意的是视图解析器前缀后缀必须和你的JSP目录对得上,另外一定要加静态资源放行配置,这个问题我在第5章会重点讲:
<mvc:annotation-driven/> <context:component-scan base-package="com.hospital.ssm.controller"/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/jsp/"/> <property name="suffix" value=".jsp"/> </bean> <mvc:default-servlet-handler/>InternalResourceViewResolver决定了controller里return "scheduleList"会被翻译成/WEB-INF/jsp/scheduleList.jsp。很多404都和prefix、suffix配置有关,新建JSP时一定要注意路径对齐,比如WEB-INF/jsp/admin/下的页面,controller就要写return "admin/scheduleManage"。
mybatis-config.xml则做三件事:配置驼峰映射、定义类型别名、注册mapper XML路径。驼峰映射这个配置容易漏,漏了的话数据库字段doctor_name映射不到实体的doctorName属性,页面能打开但数据显示全为null,一般也不会报错。
2.3 运行前的必改配置:jdbc参数与路径常量
导入项目后第一步不是点Run,而是改配置文件。最常见的是jdbc.properties里的数据库连接信息,以及数据库密码明文存储的问题。这类SSM项目默认连的是本机MySQL,跑通前需要先改配好数据库账号密码,同时确认MySQL驱动版本与本地环境匹配。我一般会先把连接信息改成这样,并顺手测一下连通性:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/hospital_db?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=你的数据库密码serverTimezone必须显式设置,MySQL 8.x默认时区是UTC,不设置的话查询时间会和本地时间差8小时,挂号记录的时间显示会错位。另外useSSL=false是避免MySQL 5.7以上版本在连接时出现SSL告警,域名写localhost时不需要证书。改完这四项,项目才谈得上「能启动」。
提示:每次拿到新的SSM项目,第一件事永远是打开web.xml和jdbc.properties,先看入口配置再去看代码。跳过这一步直接运行,大概率会浪费一整个下午在奇怪的报错里。
3. 挂号系统的地基:数据库表设计与核心字段说明
在SSM里,业务代码写起来相对机械,真正拉开差距的是数据库设计。一个挂号系统的数据模型如果设计得好,后面所有查询都是一条JOIN的事;设计得不好,写SQL时就要到处拧巴,甚至要在Java代码里做循环查询凑数据。这一章我把核心表结构和几个关键设计决策讲透,你照着建库就能跑通大部分业务。
3.1 五张核心表:字段选型与冗余设计
医院门诊挂号系统按我用过的方案,最少需要五张表:用户表、科室表、医生表、排班表、挂号记录表。用户表格区分患者和管理员,科室表和医生表是基础档案,排班表是号源的来源,挂号记录表是核心流水。建表脚本长这样:
CREATE DATABASE IF NOT EXISTS hospital_db DEFAULT CHARSET utf8mb4; USE hospital_db; -- 用户表(患者 + 管理员共用) CREATE TABLE tb_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(100) NOT NULL COMMENT '加密后密码', real_name VARCHAR(50) NOT NULL COMMENT '真实姓名', id_card VARCHAR(18) COMMENT '身份证号(挂号需要)', phone VARCHAR(20) COMMENT '手机号', role TINYINT NOT NULL DEFAULT 1 COMMENT '1=患者 2=管理员', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT '用户表'; -- 科室表 CREATE TABLE tb_department ( id INT PRIMARY KEY AUTO_INCREMENT, dept_name VARCHAR(50) NOT NULL COMMENT '科室名称', dept_intro VARCHAR(500) COMMENT '科室简介', status TINYINT DEFAULT 1 COMMENT '1=启用 0=停用' ) ENGINE=InnoDB COMMENT '科室表'; -- 医生表 CREATE TABLE tb_doctor ( id INT PRIMARY KEY AUTO_INCREMENT, dept_id INT NOT NULL COMMENT '所属科室id', doctor_name VARCHAR(50) NOT NULL, title VARCHAR(30) COMMENT '职称:主任医师/副主任医师/主治医师', intro VARCHAR(500) COMMENT '医生简介', status TINYINT DEFAULT 1 COMMENT '1=出诊 0=停诊', KEY idx_dept (dept_id) ) ENGINE=InnoDB COMMENT '医生表'; -- 排班表(号源表) CREATE TABLE tb_schedule ( id INT PRIMARY KEY AUTO_INCREMENT, doctor_id INT NOT NULL COMMENT '医生id', work_date DATE NOT NULL COMMENT '出诊日期', time_slot VARCHAR(20) NOT NULL COMMENT '时间段:AM或PM,或具体时段', total_count INT NOT NULL DEFAULT 20 COMMENT '总号源数', remain_count INT NOT NULL DEFAULT 20 COMMENT '剩余号数', fee DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '挂号费', UNIQUE KEY uk_doctor_date_slot (doctor_id, work_date, time_slot) ) ENGINE=InnoDB COMMENT '排班表'; -- 挂号记录表 CREATE TABLE tb_registration ( id INT PRIMARY KEY AUTO_INCREMENT, schedule_id INT NOT NULL COMMENT '排班id', user_id INT NOT NULL COMMENT '挂号的用户id', dept_name VARCHAR(50) COMMENT '冗余科室名称', doctor_name VARCHAR(50) COMMENT '冗余医生名称', work_date DATE COMMENT '冗余出诊日期', time_slot VARCHAR(20) COMMENT '冗余时间段', fee DECIMAL(10,2) COMMENT '冗余挂号费', reg_no VARCHAR(32) NOT NULL COMMENT '挂号流水号', status TINYINT DEFAULT 0 COMMENT '0=已挂号 1=已就诊 2=已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_schedule (schedule_id), KEY idx_user (user_id) ) ENGINE=InnoDB COMMENT '挂号记录表';字段类型上我特别说明几点:密码字段不要用VARCHAR(20),而是要预留足够长度存加密后的密文,如果你用MD5加密,32位;用BCrypt则要60位。身份证号id_card用CHAR(18)就够,但要注意最后一位是X的场景,Java端别转成int。金额字段一律用DECIMAL(10,2),不要用FLOAT,因为浮点数在累加时会出现精度问题,医嘱和退费计算会踩坑。
挂号记录表里的dept_name、doctor_name、work_date这些字段是冗余存储。为什么冗余?因为挂号记录一旦生成,之后科室改名、医生职称变动都不应该影响历史记录。查询「我的挂号记录」时,直接从tb_registration单表取数据,而不需要JOIN两张表,这在数据量上来之后性能差距非常明显。
3.2 就诊时间段怎么存:三种方案对比与选择
排班时间段看似简单,实际上有讲究。我见过三种做法,各有利弊:
| 方案 | 存储方式 | 优点 | 缺点 |
|---|---|---|---|
| 方案一 | 只存上午/下午AM/PM两个值 | 简单直观,页面下拉框好做 | 只能粗粒度区分,无法精确到9:00-10:00 |
| 方案二 | 存具体时段08:00-08:30 | 贴近真实医院的号源分配 | 比较大小和校验冲突时要转字符串 |
| 方案三 | 存时间起止两个字段begin_time/end_time | 查询和排序最正规 | 表字段更多,页面展示要拼接 |
我做这类系统时,一般推荐方案二,字符串存一个时段区间。原因有二:第一,挂号系统展示给用户的是「8:00-9:00」这种直观文本,存一个字段省去前端拼接;第二,判断号源是否还有余票时,核心是remain_count而不是时间,时间字段不需要参与运算。排序时按work_date + time_slot直接order by就行,08:00-08:30这种格式的字典序就是时间顺序,英文,无歧义。
3.3 种子数据SQL与联调顺序
空数据库启动系统后,页面一片空白是很正常的,因为科室、医生、排班都没有。我在联调前会先造一批种子数据,这样页面一打开就能看到效果:
INSERT INTO tb_department (dept_name, dept_intro) VALUES ('内科', '诊治呼吸、消化系统等常见疾病'), ('骨科', '诊治骨关节损伤与退行性疾病'), ('儿科', '14周岁以下儿童常见病诊疗'); INSERT INTO tb_doctor (dept_id, doctor_name, title, intro) VALUES (1, '张医生', '主任医师', '从事内科临床工作20年'), (1, '李医生', '主治医师', '擅长消化道疾病诊治'), (2, '王医生', '副主任医师', '擅长关节镜微创手术'); INSERT INTO tb_schedule (doctor_id, work_date, time_slot, total_count, remain_count, fee) VALUES (1, CURDATE(), '08:00-09:00', 20, 20, 10.00), (1, CURDATE(), '09:00-10:00', 20, 20, 10.00), (2, CURDATE(), '08:00-09:00', 15, 15, 5.50);造数时要刻意制造一些区分度:不同科室、不同职称、不同挂号费、不同号源数。这样你在页面上能看到排序和展示效果,测试挂号扣减时也能一眼看出数据变化。联调顺序建议是:先用户登录,再科室列表,再医生列表,再排班查询,最后做挂号。每一步都确认页面展示正常再进入下一步,不要一口气全写完再来调。
4. 核心业务落地:从科室查找到挂号成功的完整链路
数据库设计好了,接下来就是代码。这一章我挑了三个最具代表性的环节:Controller层怎么写、排班查询SQL怎么写、挂号动作怎么保证不出超卖。这三段代码拼起来,基本就是一个挂号系统的业务主链路。
4.1 Controller层标准写法与URL规划
Controller的职责只有一个:接参数、调Service、返回视图名或JSON。参数校验尽量放在Service层做,Controller保持轻薄。我习惯这么规划URL:/patient/**给患者用,/admin/**给管理员用,/schedule/**是排班查询。看一个标准的列表查询:
@Controller @RequestMapping("/patient") public class ScheduleController { @Autowired private ScheduleService scheduleService; /** * 按科室和日期筛选排班列表 * @param deptId 科室id,可为空 * @param pageNum 页码,默认第1页 */ @GetMapping("/schedule") public String listSchedule(@RequestParam(required = false) Integer deptId, @RequestParam(defaultValue = "1") Integer pageNum, Model model) { // 查所有启用科室,用于页面下拉框 List<Department> deptList = scheduleService.listAllDepartments(); // 查排班(带分页) PageResult<ScheduleVO> page = scheduleService.pageSchedule(deptId, pageNum, 10); model.addAttribute("deptList", deptList); model.addAttribute("page", page); model.addAttribute("currentDeptId", deptId); // 返回视图名,由InternalResourceViewResolver拼成 /WEB-INF/jsp/scheduleList.jsp return "scheduleList"; } }这里@RequestParam(required = false)表示deptId不是必传参数,不传就查全部科室;pageNum带默认值1,避免首次访问报参数缺失。Model里放的是页面要渲染的数据,JSP里用${page.list}就能取到。注意返回值是字符串视图名,不是JSON,所以方法上不能加@ResponseBody,否则会返回一个纯粹的字符串「scheduleList」而不是页面,这是新手很容易搞混的点。
JSP页面上渲染排班列表,核心就是JSTL的c:forEach加上EL表达式:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table class="table table-hover"> <thead> <tr><th>科室</th><th>医生</th><th>职称</th><th>日期</th><th>时间段</th><th>剩余号</th><th>费用</th></tr> </thead> <tbody> <c:forEach items="${page.list}" var="schedule"> <tr> <td>${schedule.deptName}</td> <td>${schedule.doctorName}</td> <td>${schedule.title}</td> <td>${schedule.workDate}</td> <td>${schedule.timeSlot}</td> <td>${schedule.remainCount}</td> <td>${schedule.fee}</td> </tr> </c:forEach> </tbody> </table>JSP的V层职责在这里体现得很清楚:不写任何Java业务代码,只做遍历和展示。EL表达式天然避免空指针,${schedule.doctorName}在对象为null时输出空字符串,不会抛异常。不过要注意,JSP页面是否能用EL,取决于web.xml的web-app版本,3.0以上默认开启,2.5及以下默认关闭,后面避坑章节会专门讲。
4.2 排班查询:多表关联与剩余号源过滤
排班查询是所有页面的核心SQL。用户打开页面时,看到的不只是一张schedule表,还要关联出科室名称、医生姓名和职称。查询时有一个关键点:默认只查remain_count > 0的排班,让用户的注意力集中在还能挂号的时段上。
SELECT s.id AS id, d.dept_id AS deptId, dp.dept_name AS deptName, s.doctor_id AS doctorId, doc.doctor_name AS doctorName, doc.title AS title, s.work_date AS workDate, s.time_slot AS timeSlot, s.remain_count AS remainCount, s.fee AS fee FROM tb_schedule s JOIN tb_doctor doc ON s.doctor_id = doc.id JOIN tb_department dp ON doc.dept_id = dp.id WHERE (#{deptId} IS NULL OR dp.id = #{deptId}) AND s.work_date >= CURDATE() AND s.remain_count > 0 AND doc.status = 1 AND dp.status = 1 ORDER BY s.work_date ASC, s.time_slot ASC LIMIT #{offset}, #{pageSize}WHERE (#{deptId} IS NULL OR dp.id = #{deptId})这段是动态查询的惯用写法:当deptId为空时,条件恒真,返回所有科室;当deptId有值时,精确匹配科室。MyBatis里也可以写成<if>标签动态拼接,但我觉得用IS NULL OR这种方式写出来的SQL更清晰,也方便直接复制到Navicat里调试。CURDATE()过滤掉了过去的日期,doc.status = 1和dp.status = 1过滤掉了停诊的医生和停用的科室,避免用户挂到一个已经停诊的号。
LIMIT分页的offset和pageSize是在Service层计算好传入的:offset = (pageNum - 1) * pageSize。如果页码从1开始,第一页偏移就是0,我的经验是别把分页逻辑写在SQL里,而是封装一个PageResult对象统一处理,否则每个业务的翻页代码都是一份复制粘贴。
4.3 挂号动作:事务与并发扣减
挂号是整个系统最核心的写操作,这里最容易出问题。最开始很多人会写成「先查询余号,if余号大于0,再update余号-1」——这种写法在并发场景下会超卖。两个用户同时读到remain_count=1,都认为是最后一个号,都去执行update,最终卖出去两张票。解决方式就是让扣减操作原子化,并加上事务控制。
我用的方案是「乐观扣减 + 受影响行数判断」,这是实际项目里性价比最高的做法,不需要在代码里显式开启数据库锁:
@Transactional(rollbackFor = Exception.class) public RegistrationResult createRegistration(RegistrationRequest req) { // 1. 前置校验:该排班是否存在且未过期 Schedule schedule = scheduleMapper.selectById(req.getScheduleId()); if (schedule == null) { throw new BizException("排班信息不存在"); } if (schedule.getWorkDate().before(new Date())) { throw new BizException("该排班已过期"); } // 2. 原子扣减:只有余号大于0时才更新成功 int rows = scheduleMapper.decreaseRemain(req.getScheduleId()); if (rows == 0) { throw new BizException("该时段号源已挂满,请选择其他时段"); } // 3. 生成挂号记录 Registration reg = new Registration(); reg.setScheduleId(schedule.getId()); reg.setUserId(req.getUserId()); // 冗余字段直接取自查出来的schedule对象 reg.setDeptName(schedule.getDeptName()); reg.setDoctorName(schedule.getDoctorName()); reg.setWorkDate(schedule.getWorkDate()); reg.setTimeSlot(schedule.getTimeSlot()); reg.setFee(schedule.getFee()); reg.setRegNo(generateRegNo()); // 生成唯一流水号 registrationMapper.insert(reg); return RegistrationResult.success(reg); }对应的Mapper XML里,扣减语句是核心:
<update id="decreaseRemain"> UPDATE tb_schedule SET remain_count = remain_count - 1 WHERE id = #{scheduleId} AND remain_count > 0 </update>这个UPDATE语句自己就完成了并发控制:数据库行锁会让同时到达的两个事务串行执行,第一个事务把remain_count从1改成0,第二个事务进入时发现remain_count > 0条件不成立,更新0行,rows == 0触发异常。整个挂号过程被@Transactional包住,一旦第3步插入记录失败,第2步的remain_count - 1会自动回滚,不会出现「号扣了但记录没生成」的情况。
这里有一个容易被忽略的细节:@Transactional注解放在ServiceImpl类的方法上,而不是Controller层。原因是Spring的声明式事务基于AOP代理,Controller的bean默认不经过事务增强,你放在Controller上会发现事务根本不生效,而且这个问题不会报错,只会在异常时数据不一致,排查起来非常隐蔽。
5. 部署与避坑:SSM+JSP项目最常见的5个翻车点
这一章我整理一下自己带项目时最常遇到的五个问题。每一条都是「现象 → 原因 → 解决」的真实排查记录,你保存好这份清单,至少能少走三天弯路。
5.1 页面能打开但登录跳转404
现象:Tomcat正常启动,首页能显示,但点登录后URL变成/login,页面显示404。
原因:Controller返回的视图名和WEB-INF/jsp目录下的物理文件对不上。最常见的是文件放在WEB-INF/jsp/下面,但Controller返回了login,视图解析器拼出来是/WEB-INF/jsp/login.jsp,而这个文件实际叫login.html或者放在WEB-INF/jsp/user/login.jsp。路径不对齐,404是必然。
解决:打开spring-mvc.xml,确认InternalResourceViewResolver的prefix和suffix,再对照Controller的返回字符串检查WEB-INF/jsp下的文件路径。我排查这类问题的习惯是先看Tomcat的catalina.out日志,里面会明确打印出它尝试访问的文件路径,按图索骥最快。
5.2 MyBatis报Invalid bound statement (not found)
现象:启动时不报错,一调用某个Mapper方法就抛Invalid bound statement (not found)。
原因:Mapper接口方法找不到对应的XML SQL语句。三个常见来源:接口和XML的namespace不一致,方法的id没对上,或者XML文件根本没被扫描到。第三种最隐蔽,因为MyBatis的全局配置里mapperLocations没写对时,接口存在但XML没加载,只有运行时才发现。
解决:先检查Mapper接口的@MapperScan扫描包路径,再确认mybatis.mapper-locations指向classpath:mapper/*.xml。然后打开Mapper XML看namespace是否是接口的完整类名,比如com.hospital.ssm.mapper.ScheduleMapper。注意这三个地方只要有一个不一致,运行时就报这个错,排查时按「扫描路径 → namespace → id」的顺序逐个排除。
5.3 并发挂号时号源超卖
现象:用JMeter模拟20个并发用户同时抢最后1个号,结果挂号记录生成了2条以上。
原因:代码是先SELECT remain_count判断大于0,再UPDATE remain_count - 1,两个操作之间没有加锁,也没有用条件更新。并发场景下两个请求同时读取到remain_count=1,都通过了if判断,于是双双执行了扣减。
解决:弃用「先查后改」模式,改成我第4章写的UPDATE ... SET remain_count = remain_count - 1 WHERE id = ? AND remain_count > 0原子扣减,再通过受影响行数判断是否成功。这是最简洁的处理。如果你需要更强的可靠性和可追溯性,可以再加一层分布式锁,但对单机部署的SSM项目来说,条件更新已经足够,不要过度设计。
5.4 POST请求中文乱码
现象:表单里填的「张医生」传到后端变成乱码,偶尔GET请求正常但POST乱码。
原因:GET和POST编码处理机制不同。GET乱码往往是Tomcat的URIEncoding没配置,POST乱码则是请求体编码没被正确设置为UTF-8。即使web.xml里配了CharacterEncodingFilter,也有可能因为Filter配置在DispatcherServlet之后,导致SpringMVC处理请求时编码过滤器还没生效。
解决:确认CharacterEncodingFilter的forceEncoding参数为true,并且filter-mapping的url-pattern是/*,位置在DispatcherServlet注册之前。另外Tomcat连接器的URIEncoding="UTF-8"可以一并加上,双保险解决GET参数乱码。数据库连接串里的characterEncoding=utf-8也不能少,否则数据存进去也会变乱码。
5.5 CSS和JS静态资源加载不出来
现象:页面打开后只有HTML结构,没有样式和图片,F12看到css和js文件全是404。
原因:DispatcherServlet的url-pattern配置为/,会把所有请求都拦下来,包括css、js、图片这些静态资源。SpringMVC默认不知道如何处理静态资源,于是全部返回404。
解决:在spring-mvc.xml里加<mvc:default-servlet-handler/>,让SpringMVC把请求放行给Tomcat的DefaultServlet处理静态资源。如果想把静态资源放到指定目录,也可以用<mvc:resources mapping="/static/**" location="/static/"/>,二选一即可。这个问题是SSM项目最常见的「看着像代码问题其实是配置问题」的典型代表。
提示:排查这些问题的共同方法,是第一时间看Tomcat的日志文件而不是IDE控制台。IDE控制台经常只显示最后几行,而真正有用的Caused by信息在中段。养成翻日志的习惯,SSM项目的排错难度至少降一半。
6. 从「能跑」到「能答辩」:AOP日志、登录拦截与一次快速压测
前五章把系统从零写到能跑通,但「能跑」离「敢演示」还有距离。这一章我讲三个我觉得这个项目的进阶验证点:用AOP给挂号操作加上操作日志,用Filter做登录拦截避免绕过登录访问系统内部页面,再用JMeter做一次小规模压力测试验证并发安全。做完这三件事,这个挂号系统不只是在本地能跑,你还能拿出数据证明它可靠。
第一个进阶是操作日志。挂号、退号、修改排班这类操作应该被记录下来。在SSM里最优雅的方式不是在每个方法里手动写日志代码,而是用Spring AOP做一个切面,统一记录业务方法的入参、执行耗时、异常信息。
@Aspect @Component public class OperationLogAspect { @Around("execution(* com.hospital.ssm.service.impl.*ServiceImpl.*(..))") public Object logAround(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); String methodName = pjp.getSignature().getName(); Object[] args = pjp.getArgs(); try { Object result = pjp.proceed(); long cost = System.currentTimeMillis() - start; System.out.println("[操作日志] " + methodName + " 成功, 耗时=" + cost + "ms"); return result; } catch (Exception e) { long cost = System.currentTimeMillis() - start; System.out.println("[操作日志] " + methodName + " 失败, 耗时=" + cost + "ms, 原因=" + e.getMessage()); throw e; } } }这个切面的好处是业务代码完全无侵入,Service层的方法不需要增加任何日志语句,只需在spring-context.xml里开启<aop:aspectj-autoproxy/>即可。实际生产环境可以把输出替换成写入数据库或日志文件,这里用控制台是为了让你快速看到效果。切面的execution表达式建议精确到service.impl包,切得太宽会把MyBatis代理方法也拦进来,日志会多到刷屏。
第二个必备是登录拦截器。默认情况下,SSM项目没有登录保护,任何人能直接访问/patient/schedule看到排班数据。我在项目中会用HandlerInterceptor实现一个简单的登录校验。
public class LoginInterceptor 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; } }在spring-mvc.xml里注册它,并声明拦截哪些URL:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/patient/**"/> <mvc:mapping path="/admin/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.hospital.ssm.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>登录拦截器的价值在于:你的演示流程里,不用绕路重新打开所有页面,直接输入URL证明未登录用户会被拦在登录页外,这是答辩时一个极其直观的功能展示点。
第三个进阶是压测验证。很多人写完并发安全的代码但不验证,答辩被问一句「你怎么保证不超卖」就答不上来。JMeter是验证并发最常用的工具,新建一个线程组,设置50个并发用户同时点击挂号接口,观察两个指标:错误率为0且数据库里挂号记录数等于成功数、号源表的remain_count正确扣减,就说明并发扣减逻辑是稳的。
跑完压测后,我养成了一个习惯:压测完把那批测试数据清掉,再把tomcat重启一次,确保系统回到干净的初始状态。这个习惯帮我在很多次演示前避免了「上次测的数据还留在页面上」的尴尬,也希望这个习惯能帮到你。
做完这三件事,你手里的这个SSM+JSP挂号系统就不再是「能启动」的demo,而是带了可观测性、访问控制、并发验证的完整工程。你在跑的时候如果遇到配置上的问题,按第五章那份清单逐条排错,基本都能解决。希望今天的分享帮到你。
本文还有配套的精品资源,点击获取