简介这是一套面向计算机专业毕业设计与课程设计场景的宠物医院管理系统源码基于SSMSpringSpringMVCMyBatis框架与MySQL数据库实现覆盖宠物信息管理、医生排班、预约挂号、病历记录、药品管理、收费结算等核心业务模块可帮助开发者快速理解JavaWeb项目从后台持久层到前端页面的完整实现流程。压缩包共1278个文件大小约14.92MB包含120个Java源码文件、109个JSP页面、364个JavaScript脚本、146个CSS样式以及数据库SQL、XML配置、项目说明文档和多种图片资源目录结构清晰便于按模块查阅。系统要求JDK1.8、Maven3.3以上、Tomcat7以上可使用Eclipse或IDEA导入运行配套说明文档对架构设计、功能模块划分、数据库表结构等均有介绍适合作为毕业设计选题参考、论文辅助材料或二次开发基础。目前已有76人学习该资源适合具备Java基础并希望深入SSM框架应用的学习者。1. 先看明白你在跑什么宠物医院管理系统源码ssmmysql说明文档zip 的打开方式每年毕业设计季总能看到不只一个同学电脑里躺着“宠物医院管理系统源码ssmmysql说明文档.zip”这种压缩包。很多人第一步就卡住解压出来一堆目录不知道先改哪个文件也不知道点哪里能把它跑起来。这个标题背后的东西其实不复杂它是用 Spring SpringMVC MyBatis 三件套串起来的一个标准后台管理系统加上 MySQL 存数据、说明文档交代环境参数业务上覆盖宠物建档、挂号、开药、收费、库存这一整条经营闭环。你要把它变成自己的作品关键不在背代码而在搞明白三层架构和配置文件怎么咬合。适合谁正打算拿它当毕设底子、需要换皮改功能的中级 Java 学生以及想让项目在“能登录”之外再往前走一步的入门开发者。下面我按自己接手这类 zip 的常见顺序从骨架拆到部署、踩坑、扩展和答辩验证一步步讲清楚。2. 从 zip 加载到登录页SSMMySQL 宠物医院管理系统的骨架与导入动作2.1 三层框架分别管什么请求从 URL 到数据库的完整路径SSM 是 Spring、SpringMVC、MyBatis 三个框架的简称这三个框架在宠物医院管理系统里分工非常明确。SpringMVC 管 HTTP 层浏览器发起一个挂号请求先被 DispatcherServlet 拦住然后根据 RequestMapping 找到对应的 Controller 方法Controller 不写业务逻辑只做参数接收、调用 Service、返回视图。Spring 管的是 Service 层和对象的生命周期service 类上用 Service 标注Controller 里用 Autowired 注入这样对象创建、依赖装配都不用手动 new。MyBatis 管数据库访问Mapper 接口定义方法对应的 XML 文件里写 SQLMyBatis 在运行时生成接口实现类把结果集映射成宠物主人、挂号单这些实体对象。对应的 ssm 常用注解你会在源码里频繁看到Controller 声明控制器RequestMapping 映射 URLService 声明业务组件Transactional 声明事务边界Param 给 SQL 传参。整条数据流是 JSP 页面提交表单 → SpringMVC 封装成对象 → Controller → Service → Mapper 接口 → Mapper XML 里的 SQL → MySQL 返回结果 → 反向逐层返回。这样的分层不是摆设它让每一层都能单独替换将来把 JSP 换成 Vue 前端Controller 层返回值改一下就行把 MySQL 换成 Oracle只动 Mapper XML 的方言部分。这也是为什么很多老师不让你直接上 Spring Boot 做毕设而指定 SSM —— 它把 Servlet、过滤器、事务传播、对象装配这些 Java Web 基础概念都裸露出来答辨时能问的东西太多了。Spring Boot 把这些配置全部自动完成新手反而说不清楚里面发生了什么。理解了数据流之后你再看源码里的 controller、service、mapper 包名心里就有地图了。2.2 建库建表宠物医院管理系统至少需要哪几张核心表解压源码后一般会看到 doc 或 sql 目录里带着 pet_hospital.sql。这个脚本就是整个系统的数据库蓝图对应的表都是有业务逻辑在里面的sys_user 存管理员和医生账号pet_owner 存宠物主人pet 存宠物档案pet_registration 存挂号记录pet_medical_record 存病历pet_charge 存收费单drug_info 存药品目录drug_stock 存库存。你可以先执行脚本跑通但最好自己动手建一遍因为在答辩时“为什么设计这八张表”是必问的问题。下面是一个和典型源码结构一致的建库脚本片段我做了一部分精简字段类型和注释可以直接用。CREATE DATABASE IF NOT EXISTS pet_hospital DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE pet_hospital; CREATE TABLE pet_owner ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, real_name VARCHAR(50) NOT NULL COMMENT 主人姓名, phone VARCHAR(20) NOT NULL COMMENT 联系电话, address VARCHAR(200) COMMENT 住址, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 建档时间 ) ENGINEInnoDB COMMENT宠物主人表; CREATE TABLE pet ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, owner_id BIGINT NOT NULL COMMENT 关联 pet_owner.id, pet_name VARCHAR(50) NOT NULL COMMENT 宠物名, pet_type VARCHAR(20) COMMENT 猫/狗/其他, breed VARCHAR(50) COMMENT 品种, birthday DATE COMMENT 生日, gender TINYINT DEFAULT 1 COMMENT 1公 0母, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT宠物档案表; CREATE TABLE pet_registration ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 挂号单号, pet_id BIGINT NOT NULL COMMENT 宠物 id, doctor_id BIGINT NOT NULL COMMENT 接诊医生 sys_user.id, symptom VARCHAR(500) COMMENT 主人描述的症状, status TINYINT DEFAULT 0 COMMENT 0待接诊 1就诊中 2已完成 3已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 挂号时间 ) ENGINEInnoDB COMMENT挂号表;这段 SQL 里有几个参数值得留意。字符集我推荐直接写成 utf8mb4而不是 utf8因为 MySQL 8.0 里 utf8 实际是 utf8mb3存不了 Emoji 和个别生僻字宠物主人备注里万一出现特殊字符utf8mb4 不会让插入报错。状态字段不要用自增主键去表达业务态而是用 TINYINT 加注释这样代码里读 0、1、2 的时候一看注释就懂。外键我一般不用物理外键只保留逻辑关联理由是三张主表在大量 like 查询和 left join 时物理外键会带来额外的约束检查和删表顺序问题这个观点答辩时老师反而更认可。2.3 导入 IDEA 并配置连接从解压到能看到登录页的关键动作拿到 zip 先别急着双击解压先翻一遍压缩包里的说明文档通常叫“系统说明.docx”或者“readme.txt”里面写了环境要求、MySQL 版本、初始账号密码。这个动作多数人都跳过了结果卡了半小时才意识到 MySQL 密码不对。然后把你解压后的目录放到一个没有中文、没有空格的路径下比如 D:\pet_hospital不要放在桌面因为 Tomcat 和 Maven 对中文路径的处理在 Windows 下有时候会翻车。IDEA 里选择 Open直接选中解压目录下的 pom.xmlIDEA 会识别为 Maven 工程并开始下载依赖。依赖下载完以后找到 src/main/resources/db.properties这是整个系统连接 MySQL 的入口jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/pet_hospital?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai jdbc.usernameroot jdbc.password123456如果你的 MySQL 是 5.7driver 可以写成 com.mysql.jdbc.Driver如果是 8.0必须写成 com.mysql.cj.jdbc.Driver同时 url 里带上 serverTimezoneAsia/Shanghai否则会报时区错误。这一段就是网上大量“mysql ssl连接错误”“mysql 5.7.44 安装过程详细”教程的高频配套参数配置错了项目连启动都过不去。接下来导入数据库用命令行或者 Navicat 执行 sql 目录下的 pet_hospital.sql执行完确认有 pet_owner 这些表。最后配置 Tomcat。点击 Run → Edit Configurations新增 Tomcat Server LocalDeployment 选项卡里加 Artifact选择 war explodedApplication context 填 /pet_hospital端口默认 8080。启动后浏览器访问 http://localhost:8080/pet_hospital/login.jsp如果能看到登录页面说明环境已经打通。这个 zip 通常不是加密压缩包正常右键解压就可以不需要去搜什么 zip 密码移除工具别在这方面浪费时间。3. 登录、挂号、收费三条主链路串起来核心代码片段与参数调优3.1 登录拦截器与角色权限不是所有页面都能裸访问能登录不代表系统安全。我见过不少毕设项目登录之后把所有 JSP 页面都暴露在根路径下别人只要知道文件名就能绕过登录直接访问。SSM 项目里最常见的做法是写一个拦截器统一拦住所有页面只放行登录页、登录请求和静态资源。下面这个代码片段和大多数宠物医院管理系统源码里的实现思路一致。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); Object loginUser session.getAttribute(loginUser); if (loginUser null) { response.sendRedirect(request.getContextPath() /login.jsp); return false; } return true; } }拦截器本身不难难在 SpringMVC 配置文件里怎么声明放行规则。在 springmvc-servlet.xml 里加上这一段mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/user/login.do/ mvc:exclude-mapping path/login.jsp/ mvc:exclude-mapping path/assets/**/ bean classcom.pethospital.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptors这段配置要注意两个参数mapping 里的 ** 表示包含子路径exclude-mapping 的 /assets/** 对应 JSP 页面里引用的 js、css、图片目录不放开的话页面会白板。如果你在源码里看到 Controller 上同时有权限校验逻辑那一般是根据 session 里的 roleId 判断操作权限比如只有医生能写病历、只有收费员能开收费单。这个设计在答辩时很加分比只拦登录高级一个档次。3.2 挂号与病历录入MyBatis 动态 SQL 让查询条件可按需拼装挂号是宠物医院最核心的动作业务上要先查主人电话是否已建档没建档先建主人和宠物档案然后插入挂号记录同时生成一条空白病历主记录。这中间有两个数据库写操作不是两个独立动作必须放在同一个事务里。我们看 Service 层代码Transactional(rollbackFor Exception.class) public int register(PetRegistration reg) { PetOwner owner petOwnerMapper.findByPhone(reg.getOwnerPhone()); if (owner null) { throw new BusinessException(该主人未建档请先去宠物主人管理页面建档); } petMapper.checkPetOwned(reg.getPetId(), owner.getId()); registrationMapper.insert(reg); MedicalRecord record new MedicalRecord(); record.setRegistrationId(reg.getId()); record.setPetId(reg.getPetId()); record.setStatus(0); return medicalRecordMapper.insert(record); }Transactional 的 rollbackFor Exception.class 是必须写的因为 Spring 默认只对 RuntimeException 回滚而 BusinessException 这类自定义异常通常是 Exception 的子类不写 rollbackFor 的话挂号记录插入成功、病历插入失败时不会回滚数据库里就会多出一条孤立的挂号单。这是 java 面试题里常考的 Spring 事务失效场景放在答辩里讲出来很容易引起老师兴趣。与挂号相关的查询场景还有一个典型需求医生需要在病历列表里按主人姓名、宠物姓名或手机号模糊搜索。如果用 JDBC你只能拼字符串很容易拼出 SQL 注入。MyBatis 的解决方式是动态 SQL下面这段是 mapper XML 里常见的写法select idlistByKeyword resultTypecom.pethospital.entity.MedicalRecordVO SELECT mr.*, p.pet_name, po.real_name, po.phone FROM pet_medical_record mr LEFT JOIN pet p ON mr.pet_id p.id LEFT JOIN pet_owner po ON p.owner_id po.id where if testkeyword ! null and keyword ! AND (p.pet_name LIKE CONCAT(%, #{keyword}, %) OR po.real_name LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! null AND mr.status #{status} /if /where ORDER BY mr.create_time DESC /select这里的 标签会自动去掉开头的 AND避免关键字为空时 SQL 语法错误。LIKE 拼接不要直接写成 %${keyword}%${} 是文本替换会把引号变成语法的一部分存在注入风险#{} 走的是 PreparedStatement 参数占位符靠这个就能防住最常见的注入方式。status 类型的查询用 if 而非 choose原因是 status 为 null 时不需要拼条件而 TINYINT 的 0 值在 MyBatis 判断里不会走 if 判空分支之外的 else写起来反而绕。3.3 收费与库存扣减事务传播和乐观锁怎么配合收费这条链路涉及三张表收费单表插入收费明细、挂号单改为已完成、药品表扣库存。这三个操作任何一个失败都不允许另外两个成功典型的多表事务。而且收费场景还有并发问题同一个药同时有两个收费员卖最后两盒如果先查库存再扣减两个请求都读到 5 盒最后都卖出去库存就变负数了。常见做法是直接用乐观锁把查询和扣减合并成一条 update 语句Transactional(rollbackFor Exception.class, timeout 3) public ChargeResult charge(ChargeDTO dto) { int rows drugStockMapper.deductStock(dto.getDrugId(), dto.getNum()); if (rows 0) { throw new BusinessException(库存不足或药品已被其他窗口售出); } chargeMapper.insert(dto.toChargeEntity()); registrationMapper.updateStatus(dto.getRegistrationId(), 2); return ChargeResult.ok(); }对应的 mapper XML 扣减语句是update iddeductStock UPDATE drug_stock SET stock stock - #{num}, update_time NOW() WHERE drug_id #{drugId} AND stock #{num} /update这条 update 的 WHERE 条件里带上 stock #{num}MySQL 对符合条件的行加锁两个并发请求只有一个能执行成功另一个影响行数为 0业务层拿到 rows 0 就抛异常回滚。这比先 select 再 update 的代码少一次数据库往返还天然避免了超卖。timeout 3 这个参数很实用它的单位是秒超过 3 秒拿不到数据库锁就报错避免一个窗口卡住拖垮排查。这个细节一般源码里没有属于你自己加上去的亮点。这里还想提一个很多人写错的点Service 里的 try-catch 一定要慎用。如果你在 Transactional 方法里 catch 住了异常不往外抛Spring 拿不到异常信号事务会正常提交库存可能就扣多了。我一般只在最外层 Controller 用全局异常处理器统一 catch业务层全部让异常抛出去。Transactional 注解默认传播级别是 REQUIRED也就是有事务就加入、没有就开新事务这正好符合收费场景不用改。4. SSM 毕设 zip 的避坑手册从解压到部署常见的 5 条踩坑记录4.1 MySQL 驱动类找不到或 SSL 连接失败现象Tomcat 启动时报 ClassNotFoundException: com.mysql.jdbc.Driver或者控制台出现 Communications link failure后面跟着 SSL 相关报错。原因pom.xml 里的 mysql-connector-java 版本和你本地 MySQL 版本不匹配。MySQL 5.7 时代的老驱动类名是 com.mysql.jdbc.DriverMySQL 8.0 之后驱动类名改成 com.mysql.cj.jdbc.Driver8.0 驱动默认开启 SSL 握手如果 jdbc url 里没写 useSSLfalse就会出现 SSL 协商失败。解决先看说明文档确认源码当初是基于 MySQL 5 还是 8 开发的。如果本地装的是 MySQL 8把 db.properties 里的 driver 换成 com.mysql.cj.jdbc.Driverurl 加上 useSSLfalseserverTimezoneAsia/Shanghai。如果是 MySQL 5.7保持老驱动即可同时也要检查 mysql-connector-java 的版本是否在 5.1.49 左右别把 8.x 驱动配到 5.7 上。4.2 项目部署后 404或者页面有数据但样式和图片全丢现象访问 localhost:8080 是 Tomcat 欢迎页而不是项目登录页或者打开登录页时没有任何 CSS 样式控制台一堆 404 请求。原因IDEA 的 Artifact 没有部署到 TomcatApplication context 写的是 / 而源码里跳转路径写死了项目名更常见的是 JSP 里静态资源用了硬编码路径比如 /pet_hospital/js/jquery.min.js一旦你的 context 不叫 pet_hospital全部 404。解决先确认 Deployment 里有 war explodedApplication context 填 /pet_hospital。然后全局搜 JSP 里的静态资源引用统一改成 EL 取上下文路径的方式在页面头部写 c:set varctx value${pageContext.request.contextPath}/后面引用写成 ${ctx}/js/xxx.js 这样。这个坑在换皮改造时特别容易踩因为压缩包给的路径本来就是原来的项目名你改成自己的英文名就得全局替换。4.3 中文乱码JSP 页面、HTTP 请求、MySQL 存储三层都要查现象登录后新增的宠物主人姓名在数据库里显示成或者页面回显乱码Tomcat 日志里也有中文乱码。原因乱码有四个源头任何一个不统一就出问题。JSP 文件本身没声明 UTF-8表单 POST 请求没配置编码过滤器MySQL 连接 url 没带 characterEncodingutf8数据库表字符集是 latin1。解决我一般按这个顺序排查。先看 JSP 第一行是不是 % page contentTypetext/html; charsetUTF-8 pageEncodingUTF-8%再看 web.xml 里有没有 CharacterEncodingFilter没有就加并且把 forceEncoding 设为 true再确认 db.properties 里 characterEncodingutf8最后确认建表语句用的 utf8mb4。这个方法同样适用 MySQL 8.0 和 5.7改完重启 Tomcat 再测试新增一条中文数据。此时如果还乱码检查 MySQL 连接工具是不是用了 GBK 客户端跟源码没关。4.4 Tomcat 端口被占用或者数据库连接池隔一段时间报连接不可用现象启动 Tomcat 时提示 Port 8080 was already in use或者系统跑了一会儿点击页面查询时报 Connection is not available, request timed out。原因8080 被本机其他 Java 进程占用MySQL 默认 wait_timeout 是 8 小时而连接池里放着的旧连接长时间没用被数据库端断开代码又没做连接有效性检测拿到一个失效连接直接报错。解决端口占用最简单把 config/server.xml 里的 Connector port 改成 8081或者查 netstat -ano 找到占用进程关掉。连接池失效这个坑更隐蔽如果是 druid 连接池在配置文件里加 testWhileIdletrue、validation-querySELECT 1、time-between-eviction-runs-millis60000让连接池每分钟做一次心跳验证C3P0 则配置 testConnectionOnCheckouttrue。这个属于生产环境才会暴雷的问题毕设里提前处理好答辩时讲出来会让人觉得你考虑到了真实运维。4.5 Lombok 与 JDK 版本不匹配导致编译报错现象IDEA 里 build 时报 java: cannot find symbol指向实体类的 getXxx() 方法但你的实体类里明明用 Data 标注了。原因pom.xml 里的 Lombok 版本太老而你本地 JDK 是 17老版本 Lombok 在处理新版 Java 编译时生成不了 getter/setter。我见过不少同学刚拿到 zip 时本地配的是 JDK 17一编译就翻车。解决把 Lombok 版本升级到 1.18.30 以上然后在 pom.xml 里确认 Lombok 的 optional 是 true它只在编译期需要不应该打进 war 包。或者干脆放弃 Lombok直接在实体类上用 IDEA 生成 getter/setter代码量大一点但交付时不会有环境兼容问题。这个取舍在毕设场景里我建议选后者毕竟答辩老师打开你的代码看到一堆注解还要解释半天IDE 自动生成的代码人人都看得懂。5. 把“能跑”变成“能用”给宠物医院管理系统扩展一个预约看板功能5.1 数据表变更给预约表加状态字段和取消原因很多宠物医院管理系统源码里预约和挂号是分离的宠物主人先预约到店后转成挂号单。原始表一般只有预约时间、宠物 id、医生 id 三个字段缺状态管理不做状态就没法做“待确认、已到店、已取消”的看板。这个扩展适合所有想去掉“学生作品感”的人做。先看清楚现有预约表结构再执行变更。下面这段 SQL 是一般源码里会出现的改动ALTER TABLE pet_appointment ADD COLUMN status TINYINT NOT NULL DEFAULT 0 COMMENT 0待确认 1已确认 2已到店 3已取消 AFTER doctor_id, ADD COLUMN cancel_reason VARCHAR(200) DEFAULT NULL COMMENT 取消原因 AFTER status, ADD COLUMN update_time DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT 状态变更时间 AFTER cancel_reason;这里有一个参数技巧update_time 用 ON UPDATE CURRENT_TIMESTAMP这样后续每次 update 状态时时间戳自动刷新不需要在 Java 代码里手动 new Date()。status 默认值设成 0 而不是 null前端拿到 null 时会显示空白你就得有一个判空逻辑给 0 之后页面直接显示“待确认”逻辑清爽。cancel_reason 放在 status 后面取消的时候一起填这就是数据字典和字段冗余的门道。5.2 后端三层补代码从 Mapper 到 Controller 的完整路径新增字段后实体类 Appointment 要加上 status、cancelReason、updateTime 三个属性。Service 层加取消预约的方法取消操作必须校验预约还没到店到店后的预约不能取消只能转挂号。这个业务规则的校验放在 Service 而不是 Controller是为了避免 Controller 被绕过。Mapper 接口加一个更新状态的方法int updateStatus(Param(id) Long id, Param(status) Integer status, Param(cancelReason) String cancelReason);对应的 Mapper XML 里写 update 语句要特别注意只更新需要更新的字段update idupdateStatus UPDATE pet_appointment set if teststatus ! null status #{status}, /if if testcancelReason ! null and cancelReason ! cancel_reason #{cancelReason}, /if /set WHERE id #{id} /update标签会自动去掉末尾逗号这样取消预约时只传状态和原因不把预约时间覆盖掉。Service 里先查询预约记录判断当前状态状态为 2 已到店时抛异常然后调用 updateStatus。Controller 层接收前端传来的 id、status、cancelReason 三个参数用 RequestParam 分别绑定这样比传入一个 JSON 对象要好排查前端传错了字段名会直接报错。5.3 前端 JSP 页面与 AJAX 局部刷新预约看板页面一般是三个列表待确认、已确认、已到店每行都放操作按钮。按钮点击后用 jQuery 发 AJAX而不是直接表单提交整页刷新这样医生在看板里连续处理几条预约时不用来回滚动页面。$(#btn-confirm).click(function () { var appointmentId $(this).data(id); $.ajax({ url: ctx /appointment/updateStatus.do, type: POST, data: { id: appointmentId, status: 1 }, dataType: json, success: function (res) { if (res.code 200) { location.reload(); } else { alert(res.message); } }, error: function () { alert(网络请求失败请检查后端服务); } }); });这里的 ctx 变量来自 JSP 里的 c:setAJAX 请求路径必须带上下文路径否则部署在 /pet_hospital 下时请求会发到根路径 404。dataType 设为 json后端 Controller 返回一个统一结果对象 Result格式固定为 {code: 200, message: OK, data: null}前端拿到 code 判断逻辑这样后端抛 BusinessException 时能被全局异常捕获并转成 code 500前端弹提示。这是很轻的接口约定比每次返回裸字符串强得多。这个扩展做下来你的项目就从“老师布置的作业”变成一个能讲清业务闭环的作品。答辩时你可以明确说“原系统只有预约登记我补充了预约状态机并用事务保证了取消预约和释放医生时间段的原子性。”这比说“我熟悉增删改查”有力得多。6. 交付前先过一遍自测从“能跑”到“能答辩”的检查清单系统的运行只是及格线答辩前一定要按业务路径完整走一遍自测而不是只点几个菜单就说完成。下面这张表是我做类似项目收尾时必过的检查项你可以直接复制到笔记软件里挨个打勾。功能模块自测操作预期结果登录与权限分别用管理员、医生账号登录直接访问未授权页面未登录跳登录页低权限访问被拦截建档与档案查询新增主人和宠物按电话模糊查询新增成功查询结果里电话和姓名完全匹配挂号与病历未建档主人挂号重复电话挂号第一单跳出建档提示第二单拒绝重复电话收费与库存药品库存设为 3连续并发扣减 5 次第 4 次起提示库存不足库存不为负数中文输入与上传新建主人姓名含生僻字数据库和页面显示一致不出现问号答辩时老师最爱追问的通常不是 SpringMVC 怎么工作而是事务和并发这些一深入就露馅的点。比如“挂号单和病历为什么要分两张表合一张行不行”答案强调职责分离挂号是预约行为病历是诊疗过程后续统计接诊量只需要 count 挂号表。再比如“并发收费时你怎么保证库存不超卖”把乐观锁 update 那行 SQL 背出来同时说清楚 WHERE stock num 的作用老师基本就满意了。我自己当年做过类似系统自测时偷懒只测了单线程答辩当天演示收费时连续点两次保存库存从 5 跳到 3直接少扣了一盒气氛非常尴尬。后来我才意识到事务方法里我用了 try-catch 把异常吞了Transactional 根本感知不到异常那个版本最后回滚逻辑是靠手动补数据圆回去的。从那以后我养成了一个习惯凡是涉及金额和库存的操作一定用两个浏览器窗口并发点一遍再查数据库库存和收费单数量是否对得上。这个习惯不值什么钱但能让你的 demo 在答辩桌上少翻一次车也希望帮到你。本文还有配套的精品资源点击获取