简介:这是一套面向计算机专业学生毕业设计场景的宠物医院管理系统源码包,采用Java语言结合SSM(Spring、SpringMVC、Mybatis)框架与MySQL数据库开发,适配JDK1.8、Maven3.3及Tomcat7以上环境,可在Eclipse或IDEA中导入运行。系统围绕宠物医院日常运营展开,涵盖病历管理、预约挂号、药品管理、财务收费等核心模块,适合作为毕业设计、课程设计参考或SSM框架入门练手项目。压缩包共1278个文件,约14.92MB,包含120个Java源文件、109个JSP页面、364个JavaScript脚本、146个CSS样式及174张PNG图片等,另附项目说明文档与SQL脚本,便于理解架构设计与数据库结构。目前已有76人学习下载。读者可借此掌握SSM整合流程、JSP页面开发与数据库编程技巧,并参考说明文档梳理业务流程、功能模块设计与实现思路,为论文撰写与答辩提供完整素材。
1. 宠物医院管理系统到底解决什么问题:从挂号到库存的一条业务闭环
宠物医院和人类医院最大的差别不在诊疗本身,而在业务颗粒度。一只猫从进门到离开,涉及建档、挂号、候诊、诊断、处方、收费、住院、回访八个环节,每个环节产生的数据格式都不一样。用 Excel 管,三天就乱;用通用 ERP 改,改到怀疑人生。宠物医院管理系统就是把这八个环节塞进一套 SSM 架构里,让前台、医生、药房、库管四个角色看到同一份数据。
这套源码的技术栈是 SSM(Spring + SpringMVC + MyBatis)+ MySQL,属于 JavaWeb 项目里最经典的组合。它适合两类人:一是计算机毕业设计需要完整业务闭环的,二是想拿一个真实 CRUD 项目练手 MyBatis 映射和事务控制的。源码包里通常带说明文档,但文档质量参差不齐,真正值钱的是表结构和业务逻辑的对应关系。下面从环境搭建讲到核心模块实现,再到部署踩坑,每一步都给出可复现的操作。
2. 环境搭建与数据库初始化:把 SSM 骨架跑起来
2.1 JDK、Tomcat、Maven 的版本匹配
SSM 项目对版本很敏感,尤其是 Spring 和 JDK 的搭配。常见做法是 JDK 8 + Tomcat 8.5 + Maven 3.6,这个组合在大多数毕业设计源码里验证过。JDK 11 以上会遇到javax包迁移问题,Spring 5 虽然支持,但源码里的web.xml配置方式会报错。MySQL 用 5.7 或 8.0 都行,区别在驱动类名和连接串参数。
安装完 JDK 后验证:
java -version # 期望输出:java version "1.8.0_xxx" mvn -version # 期望输出:Apache Maven 3.6.xMaven 的settings.xml里建议配阿里云镜像,否则拉 Spring 依赖会等到超时。在mirrors节点加:
<mirror> <id>aliyun</id> <mirrorOf>central</mirrorOf> <url>https://maven.aliyun.com/repository/public</url> </mirror>这一步的逻辑是:SSM 项目的pom.xml会引入 spring-webmvc、mybatis-spring、mysql-connector-java 等十几个依赖,中央仓库在国内访问不稳定,镜像能省掉大量重试时间。参数上注意mirrorOf写central而不是*,避免把项目私服也劫持了。
2.2 建库建表:从 SQL 文件到字符集确认
源码包里一般有pet_hospital.sql或类似命名的文件。导入前先建库:
CREATE DATABASE pet_hospital DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE pet_hospital; SOURCE /path/to/pet_hospital.sql;字符集必须用utf8mb4,因为宠物品种名里可能出现生僻字,utf8三字节存不下会截断。导入后用SHOW TABLES;确认表数量,典型系统有 12 到 18 张表,核心几张是:
| 表名 | 作用 | 关键字段 |
|---|---|---|
owner | 宠物主人 | owner_id,phone,name |
pet | 宠物档案 | pet_id,owner_id,species,breed |
appointment | 挂号预约 | appt_id,pet_id,doctor_id,status |
medical_record | 病历 | record_id,appt_id,diagnosis |
prescription | 处方 | presc_id,record_id,drug_id,qty |
drug | 药品库存 | drug_id,stock,price |
pet表通过owner_id外键关联owner,appointment又关联pet和医生表。这个链式关系决定了后面 MyBatis 里要用嵌套查询还是关联查询。如果导入时报Foreign key constraint fails,多半是导入顺序不对,先导主表再导从表,或者临时SET FOREIGN_KEY_CHECKS=0;。
2.3 连接池与 MyBatis 配置的四个必调参数
数据库通了之后,改jdbc.properties:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/pet_hospital?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=你的密码四个参数逐个说:serverTimezone不写会报The server time zone value is unrecognized,这是 MySQL 8 驱动强制要求;useSSL=false去掉启动时的 SSL 警告;characterEncoding=utf8保证中文不乱码;驱动类名 MySQL 8 用com.mysql.cj.jdbc.Driver,5.7 用com.mysql.jdbc.Driver,写错直接ClassNotFoundException。
连接池如果用 Druid,在applicationContext.xml里配initialSize=5、maxActive=20、validationQuery=SELECT 1。毕业设计并发量低,这几个值够用,调大了反而占内存。MyBatis 的mapperLocations要指向classpath:mapper/*.xml,否则启动时报Invalid bound statement,这个错后面避坑章节还会细说。
3. 核心业务模块的代码实现:挂号、病历、库存三块怎么串
3.1 挂号模块:Controller 到 Mapper 的完整调用链
挂号是整个系统的入口,它的实现能代表所有模块的套路。先看 Controller:
@Controller @RequestMapping("/appointment") public class AppointmentController { @Autowired private AppointmentService appointmentService; @RequestMapping("/add") @ResponseBody public Map<String, Object> add(@RequestBody Appointment appt) { Map<String, Object> result = new HashMap<>(); // 校验宠物是否存在 if (appt.getPetId() == null) { result.put("code", 400); result.put("msg", "请先选择宠物"); return result; } // 默认状态为待就诊 appt.setStatus(0); appt.setCreateTime(new Date()); int rows = appointmentService.insert(appt); result.put("code", rows > 0 ? 200 : 500); result.put("msg", rows > 0 ? "挂号成功" : "挂号失败"); return result; } }逻辑说明:@RequestBody接收前端 JSON,@ResponseBody把 Map 转成 JSON 返回。status=0表示待就诊,1 表示就诊中,2 表示已完成,这个状态机是后面医生端和收费端联动的关键。参数上petId必须非空,因为挂号本质是给某只宠物排号。
Service 层做事务控制:
@Service public class AppointmentServiceImpl implements AppointmentService { @Autowired private AppointmentMapper appointmentMapper; @Override @Transactional(rollbackFor = Exception.class) public int insert(Appointment appt) { // 插入挂号记录 int rows = appointmentMapper.insert(appt); // 同时更新宠物表的最近就诊时间 appointmentMapper.updatePetLastVisit(appt.getPetId()); return rows; } }@Transactional的rollbackFor = Exception.class是血泪经验:默认只回滚RuntimeException,如果 Mapper 抛的是受检异常,事务不回滚,数据就脏了。两个操作要么都成功要么都失败,这是挂号模块必须保证的。
Mapper XML 里对应两条 SQL:
<insert id="insert" parameterType="com.pet.entity.Appointment"> INSERT INTO appointment (pet_id, doctor_id, appt_time, status, create_time) VALUES (#{petId}, #{doctorId}, #{apptTime}, #{status}, #{createTime}) </insert> <update id="updatePetLastVisit"> UPDATE pet SET last_visit = NOW() WHERE pet_id = #{petId} </update>#{}是预编译占位符,能防 SQL 注入;${}是字符串拼接,除非做动态排序否则别用。parameterType写全限定类名,MyBatis 才能正确映射。
3.2 病历与处方:一对多关联查询的两种写法
一次就诊对应一条病历,一条病历对应多条处方,这是典型的一对多。查询病历时要把处方一起带出来,有两种写法。
第一种是嵌套结果,用resultMap的collection:
<resultMap id="RecordWithPresc" type="com.pet.entity.MedicalRecord"> <id property="recordId" column="record_id"/> <result property="diagnosis" column="diagnosis"/> <collection property="prescList" ofType="com.pet.entity.Prescription"> <id property="prescId" column="presc_id"/> <result property="drugName" column="drug_name"/> <result property="qty" column="qty"/> </collection> </resultMap> <select id="selectRecordWithPresc" resultMap="RecordWithPresc"> SELECT r.record_id, r.diagnosis, p.presc_id, p.drug_name, p.qty FROM medical_record r LEFT JOIN prescription p ON r.record_id = p.record_id WHERE r.record_id = #{recordId} </select>第二种是嵌套查询,collection里写select属性指向另一条 SQL。前者一次查询搞定,后者会产生 N+1 问题。数据量小的时候差别不大,但病历列表页如果每页 20 条,嵌套查询就是 21 次数据库往返,明显拖慢。我一般用嵌套结果,除非处方表数据特别大需要分页。
3.3 药品库存扣减:并发下的乐观锁处理
处方开出去要扣库存,这是最容易翻车的地方。两个医生同时开同一种药,如果直接UPDATE drug SET stock = stock - #{qty},在并发下可能扣成负数。常见做法是加乐观锁:
<update id="deductStock"> UPDATE drug SET stock = stock - #{qty}, version = version + 1 WHERE drug_id = #{drugId} AND stock >= #{qty} AND version = #{version} </update>Service 层先查再改:
public boolean deduct(Long drugId, Integer qty) { Drug drug = drugMapper.selectById(drugId); if (drug.getStock() < qty) { throw new RuntimeException("库存不足"); } int rows = drugMapper.deductStock(drugId, qty, drug.getVersion()); if (rows == 0) { // 版本号被改过,说明有人抢先扣了 throw new RuntimeException("库存已变动,请重试"); } return true; }stock >= #{qty}是兜底条件,即使版本号没变也不会扣成负数。version字段在drug表里默认 0,每次更新加一。这个方案在毕业设计场景下够用,真实生产环境可能还要考虑分布式锁,但那就超出 SSM 的范围了。
4. 避坑与排查:部署 SSM 项目最常见的五个翻车点
4.1 启动报 Invalid bound statement
现象:Tomcat 启动没报错,但一访问接口就抛org.apache.ibatis.binding.BindingException: Invalid bound statement (not found)。
原因:MyBatis 没找到 Mapper XML。要么mapperLocations配错,要么 XML 没被编译到target/classes下。Maven 默认只编译src/main/java下的.java,src/main/resources下的 XML 才会进 classpath。如果 XML 放在java目录里,需要在pom.xml的build节点加:
<resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> </resources>解决:确认 XML 路径和mapperLocations通配符匹配,再确认target/classes下确实有 XML 文件。两个都对了还报错,检查 XML 里的namespace是否和 Mapper 接口全限定名完全一致,一个字母都不能差。
4.2 中文乱码:从数据库到页面的三层排查
现象:宠物名字在数据库里正常,页面上显示问号。
原因:字符集在三个环节可能出问题——数据库连接串、表字段、Tomcat 的server.xml。连接串里characterEncoding=utf8必须写;表字段用utf8mb4;Tomcat 的 Connector 加URIEncoding="UTF-8"。
解决:按顺序查。先SHOW CREATE TABLE pet;看字段字符集,再确认连接串,最后改 Tomcat。如果用了@ResponseBody返回 JSON,还要在spring-mvc.xml里配StringHttpMessageConverter的默认编码为 UTF-8,否则 JSON 里的中文照样乱。
4.3 事务不生效:Service 没被 Spring 管理
现象:@Transactional加了,但插入失败后前面的操作没回滚。
原因:最常见的是 Service 类没加@Service,或者 Spring 配置里没开<tx:annotation-driven/>。还有一种隐蔽情况:在同一个类里 A 方法调 B 方法,B 方法的事务注解不生效,因为没走代理。
解决:确认applicationContext.xml有<tx:annotation-driven transaction-manager="transactionManager"/>,确认 Service 在组件扫描范围内。同类内部调用要事务生效,得通过AopContext.currentProxy()拿代理对象,或者把方法拆到另一个 Service 里。
4.4 静态资源 404:DispatcherServlet 拦截了所有请求
现象:CSS、JS、图片全部 404,但接口正常。
原因:web.xml里 DispatcherServlet 的url-pattern配了/,所有请求都进 SpringMVC,静态资源找不到对应 Controller 就 404。
解决:在spring-mvc.xml加:
<mvc:default-servlet-handler/> <mvc:annotation-driven/>default-servlet-handler会把找不到映射的请求交回给 Tomcat 的默认 Servlet 处理,静态资源就能正常访问。注意这两行要一起写,只写annotation-driven不解决问题。
4.5 MySQL 8 时区与驱动类名不匹配
现象:启动报Cannot load driver class: com.mysql.jdbc.Driver或The server time zone value '?D1ú±ê×?ê±??' is unrecognized。
原因:MySQL 8 的驱动类名变成了com.mysql.cj.jdbc.Driver,且强制要求指定时区。旧教程里的配置直接抄会翻车。
解决:驱动类名改com.mysql.cj.jdbc.Driver,连接串加serverTimezone=Asia/Shanghai。如果 MySQL 是 5.7,两个驱动类名都能用,但建议统一用cj版本,避免以后升级再改。
5. 从能跑到好用:三个让答辩加分的进阶技巧
5.1 用拦截器做登录校验和角色隔离
毕业设计答辩时,老师最爱问「你的权限怎么控制的」。如果只在前端隐藏菜单,后端接口裸奔,一问就露馅。加一个 HandlerInterceptor:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { HttpSession session = req.getSession(); Object user = session.getAttribute("loginUser"); if (user == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return false; } // 角色校验:医生只能访问 /doctor/**,前台只能访问 /reception/** String role = (String) session.getAttribute("role"); String uri = req.getRequestURI(); if (uri.contains("/doctor/") && !"doctor".equals(role)) { resp.sendError(403, "无权限"); return false; } return true; } }在spring-mvc.xml里注册:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.pet.interceptor.AuthInterceptor"/> </mvc:interceptor> </mvc:interceptors>exclude-mapping把登录接口和静态资源放行,其余全部拦截。角色判断用 URI 前缀做粗粒度隔离,够毕业设计用。答辩时能说清「前端隐藏 + 后端拦截」双层控制,比只说前端隐藏高一个档次。
5.2 用 AOP 记录操作日志
系统里谁改了病历、谁扣了库存,出问题要能追溯。用 Spring AOP 加一个日志切面:
@Aspect @Component public class LogAspect { @Autowired private LogMapper logMapper; @Around("@annotation(com.pet.annotation.OperationLog)") public Object around(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); Object result = pjp.proceed(); long cost = System.currentTimeMillis() - start; // 记录方法名、耗时、操作人 OperationLog log = new OperationLog(); log.setMethod(pjp.getSignature().getName()); log.setCost(cost); log.setCreateTime(new Date()); logMapper.insert(log); return result; } }自定义注解@OperationLog打在需要记录的 Controller 方法上。@Around能拿到方法执行前后的时间,算出耗时。这个功能代码量不大,但答辩演示时能直观看到「操作留痕」,是个加分项。注意日志插入本身不要再触发切面,否则死循环。
5.3 用 PageHelper 做分页,别手写 limit
病历列表、药品列表都要分页。手写LIMIT #{offset}, #{size}要自己算 offset,还要单独写 count 查询,容易出错。用 PageHelper 一行搞定:
PageHelper.startPage(pageNum, pageSize); List<MedicalRecord> list = recordMapper.selectAll(); PageInfo<MedicalRecord> pageInfo = new PageInfo<>(list); // pageInfo.getTotal() 总条数,pageInfo.getList() 当前页数据startPage只对紧接着的第一条查询生效,中间不能插别的查询。PageInfo封装了总页数、总条数、是否首页末页,前端直接拿 JSON 渲染分页条。参数上pageNum从 1 开始,传 0 会报错,前端要保证不传 0。
5.4 答辩前必做的三项验证
第一,把数据库导出成 SQL 再重新导入一次,确认没有依赖本地环境的脏数据。第二,换一台没装过开发环境的电脑部署,验证 JDK、Tomcat、MySQL 版本兼容性。第三,用 Postman 把所有接口跑一遍,特别是边界情况:空参数、超长字符串、并发扣库存。我自己的习惯是答辩前一晚把这三项过一遍,比背稿子管用。希望帮到你。
本文还有配套的精品资源,点击获取