
简介一套基于Java语言、SSM框架与Vue/JSP前端技术构建的医院门诊挂号系统项目面向需要完成毕业设计或希望深入理解前后端分离开发的读者。项目采用Spring、SpringMVC、MyBatis搭建后端前端融合Vue组件化与JSP动态渲染覆盖预约挂号、医生排班、患者管理等核心门诊业务运行环境为JDK1.8、Tomcat7与MySQL5.7符合教学与毕设场景。压缩包约15.3MB主要包含项目源码、MySQL数据库脚本与功能介绍文档源码可导入IDE二次开发脚本可快速初始化表结构与测试数据文档便于梳理模块和操作路径。已有2736人学习项目经严格调试可稳定运行既是毕业设计的完整参考也有助于掌握SSM整合、Vue交互及医院信息系统落地技巧。1. 为什么医院门诊挂号系统还在用SSMJSP别急着追新看到「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思想的工程化实现ServletSpringMVC的DispatcherServlet负责接收请求和调度JavaBeanServiceMapper负责业务和数据处理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的SqlSessionservice不出现HttpServletRequestmapper接口和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配置串起SSMweb.xml、spring-mvc.xml、mybatis-config.xmlSSM整合之所以让新手头疼是因为三个框架各管一摊配置互相引用。我把它们拆成三条线来看Spring管service和mapper的bean创建与事务SpringMVC管controller与视图解析是web入口MyBatis管SQL映射不参与请求处理。web.xml是总入口它干两件事用ContextLoaderListener启动Spring容器并注册DispatcherServlet加载SpringMVC配置。我写过一个最小可运行的web.xml配置web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd version3.1 !-- 加载Spring容器配置 -- context-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-context.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener !-- 编码过滤器必须在最前面解决POST中文乱码 -- filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping !-- SpringMVC前端控制器 -- servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping /web-app这段配置里load-on-startup必须写1意思是Tomcat启动时就初始化DispatcherServlet否则第一次请求才会加载SpringMVC容器启动时的配置错误会延迟到线上才暴露。CharacterEncodingFilter则一定要放在DispatcherServlet之前注册因为它拦的是/*如果请求先进了SpringMVC再进FilterPOST参数在解析时就已经是乱码了。spring-mvc.xml里最需要注意的是视图解析器前缀后缀必须和你的JSP目录对得上另外一定要加静态资源放行配置这个问题我在第5章会重点讲mvc:annotation-driven/ context:component-scan base-packagecom.hospital.ssm.controller/ bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/jsp// property namesuffix 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.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/hospital_db?useUnicodetruecharacterEncodingutf-8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.password你的数据库密码serverTimezone必须显式设置MySQL 8.x默认时区是UTC不设置的话查询时间会和本地时间差8小时挂号记录的时间显示会错位。另外useSSLfalse是避免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 ) ENGINEInnoDB 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停用 ) ENGINEInnoDB 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) ) ENGINEInnoDB 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) ) ENGINEInnoDB 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) ) ENGINEInnoDB 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) { // 查所有启用科室用于页面下拉框 ListDepartment deptList scheduleService.listAllDepartments(); // 查排班带分页 PageResultScheduleVO 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 prefixc urihttp://java.sun.com/jsp/jstl/core % table classtable table-hover thead trth科室/thth医生/thth职称/thth日期/thth时间段/thth剩余号/thth费用/th/tr /thead tbody c:forEach items${page.list} varschedule 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 /tableJSP的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_count1都认为是最后一个号都去执行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 iddecreaseRemain UPDATE tb_schedule SET remain_count remain_count - 1 WHERE id #{scheduleId} AND remain_count gt; 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. 部署与避坑SSMJSP项目最常见的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_count1都通过了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连接器的URIEncodingUTF-8可以一并加上双保险解决GET参数乱码。数据库连接串里的characterEncodingutf-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里注册它并声明拦截哪些URLmvc:interceptors mvc:interceptor mvc:mapping path/patient/**/ mvc:mapping path/admin/**/ mvc:exclude-mapping path/login/ mvc:exclude-mapping path/static/**/ bean classcom.hospital.ssm.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptors登录拦截器的价值在于你的演示流程里不用绕路重新打开所有页面直接输入URL证明未登录用户会被拦在登录页外这是答辩时一个极其直观的功能展示点。第三个进阶是压测验证。很多人写完并发安全的代码但不验证答辩被问一句「你怎么保证不超卖」就答不上来。JMeter是验证并发最常用的工具新建一个线程组设置50个并发用户同时点击挂号接口观察两个指标错误率为0且数据库里挂号记录数等于成功数、号源表的remain_count正确扣减就说明并发扣减逻辑是稳的。跑完压测后我养成了一个习惯压测完把那批测试数据清掉再把tomcat重启一次确保系统回到干净的初始状态。这个习惯帮我在很多次演示前避免了「上次测的数据还留在页面上」的尴尬也希望这个习惯能帮到你。做完这三件事你手里的这个SSMJSP挂号系统就不再是「能启动」的demo而是带了可观测性、访问控制、并发验证的完整工程。你在跑的时候如果遇到配置上的问题按第五章那份清单逐条排错基本都能解决。希望今天的分享帮到你。本文还有配套的精品资源点击获取