做一个基于SSM的宠物医院管理系统听起来不像电商平台那样宏大但它覆盖了一个Java Web项目从零到一的几乎所有经典环节分模块设计、数据库建模、权限控制、预约并发、文件上传、数据统计。这篇文章我想把这个项目从需求分析到设计实现完整过一遍把开发过程中踩过的坑也一并放出来。适合刚学完Java基础、打算拿SSM练手做一个完整项目的同学也适合正在准备系统类项目答辩的人参考。很多人对这个项目的疑惑往往是宠物医院管理有什么好设计的实际上只要把预约挂号和病历流转想明白这个系统就能成为一套很好的业务案例。即使后续换Spring Boot核心思想依然通用。1. 项目整体设计与需求拆解——宠物医院管理到底在管什么做系统最忌讳一上来就建表写代码尤其是这种“医院管理”类项目业务角色多、状态流转多提前把流程理顺后面能省出一半的返工时间。宠物医院和人的医院不一样它的体量通常不大但麻雀虽小五脏俱全。一个真实的宠物医院里每天发生的不是“看病”这一个动作而是一连串信息流转主人带着宠物到店前台登记、安排医生医生问诊、写病历、开药主人缴费取药最后可能还有回访。这个系统要管的就是这条链路上的所有数据和状态。1.1 先想清楚角色和业务流程再谈代码宠物医院管理系统里的用户角色极少需要做成大而全的菜单树核心其实就是三类人前台、医生、管理员。宠物主人一般不会直接登录后台而是通过前台人员录入信息所以一开始就不要把“用户端”做得太重重点放在内部业务支撑上。前台/护士负责预约登记、宠物和主人建档、安排就诊、收费和发药。医生负责查看候诊列表、书写电子病历、开具处方和检查建议。管理员负责员工账号维护、药品库存管理、基础数据字典、统计报表查看。这三类角色的权限边界非常清晰后续做登录拦截和功能菜单时就是按这个划分来设计。业务流程方面可以简化为一条主线宠物主人通过电话或到店预约前台录入预约信息此时状态是“待就诊”。宠物到店后前台根据预约记录或现场建档为宠物生成就诊记录状态变为“就诊中”。医生选择当前就诊宠物填写诊断结果、症状描述、医嘱系统生成病历。医生开处方关联具体药品、数量、用法药房或前台根据处方发药并收费。收费完成后更新状态同时扣减药品库存。管理员在后台查看流水和统计报表。这一步想清楚之后功能模块就自然浮出水面了。1.2 为什么偏偏是SSM这套组合这个项目用的是SSM也就是Spring SpringMVC MyBatis。现在新项目多半用Spring Boot很多刚入门的朋友会疑惑既然有更快的框架为什么还要折腾SSM原因很简单SSM能逼你把底层的分工搞清楚而不是靠自动配置一把梭。Spring负责管理对象也就是依赖注入和控制反转。医院系统里服务层的类很多如果每个Controller都去new一个Service对象代码很快变成一锅粥。用Spring容器统一管理谁依赖谁由容器注入开发和测试都轻松很多。SpringMVC负责Web层专门处理URL请求和页面跳转。它把请求接收、参数绑定、校验、响应返回都规范化了。你只需要写Controller方法加上注解剩下的请求分发和视图解析交给框架。MyBatis负责数据库访问让Java代码和SQL解耦。相比原生的JDBC它省掉了大量的ResultSet取值代码相比Hibernate它保留了对SQL的完全控制权写多表查询、分组统计时更顺手适合这种业务逻辑明确的系统。有人把SSM比作餐厅运营DispatcherServlet是前台服务员接收点单Controller是厨师长分派任务Service是切配工负责业务规则MyBatis是采购员专门和仓库数据库打交道。这个类比虽然简单但能帮助理解各层的职责。1.3 核心功能模块地图我用一张表把主要模块列出来方便后续设计数据表时对照。模块核心功能涉及数据实体操作角色系统登录与权限登录认证、菜单权限控制系统用户、角色前台、医生、管理员宠物主人管理客户建档、修改、联系方式维护宠物主人前台宠物档案管理宠物信息、免疫记录、绑定主人宠物前台、医生预约挂号管理预约登记、候诊列表、状态变更预约、就诊记录前台诊疗病历管理病史查看、病历填写病历医生处方药品管理开处方、药品库存扣减处方、药品明细医生、前台收费与统计收费记录、营业额统计、药品消耗统计收费单前台、管理员系统管理员工账号、数据字典、操作日志系统用户、字典管理员这个列表不需要做得特别全但每个模块都必须能在数据库中找到对应的表否则就是“凭空写功能”后期维护会很痛苦。项目里我只保留了图中这些主干模块像“外借宠物笼”“寄养管理”这类非核心场景暂时不做原因很简单先把主流程做完整比功能多但不闭环重要得多。2. 数据库设计把“关系”模型想清楚再动手数据库设计是这个项目里最值得花时间的地方。谁和谁是一对多谁和谁是多对多状态字段放哪张表都会直接影响后面所有代码的复杂度。我在初期已经用E-R图把人、宠物、预约、病历、处方、收费这六类核心数据的关系画了一遍然后才动手建表。2.1 核心表结构与字段设计这套系统我规划了七张核心表实际开发中还会有系统用户表、字典表、操作日志表但主表就是下面这些。第一张是宠物主人表用来存联系人信息字段大概是CREATE TABLE pet_owner ( id BIGINT PRIMARY KEY AUTO_INCREMENT, owner_name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, address VARCHAR(100), id_card VARCHAR(30), remark VARCHAR(200), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) );手机号加唯一索引是因为一个主人可能带多只宠物来就诊但手机号是实际业务中最常用的检索条件。不要用手机号做主键因为手机号变更很常见主键一旦对外暴露后续修改成本极高。第二张是宠物档案表它依赖主人表CREATE TABLE pet ( id BIGINT PRIMARY KEY AUTO_INCREMENT, owner_id BIGINT NOT NULL, pet_name VARCHAR(50) NOT NULL, pet_type VARCHAR(20) NOT NULL, breed VARCHAR(50), gender TINYINT, birth_date DATE, weight DECIMAL(5,2), status TINYINT DEFAULT 1, remark VARCHAR(200), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_owner (owner_id) );这里建议把“宠物类型”和“品种”分开比如pet_type是‘猫’还是‘狗’breed是‘英短’还是‘柯基’。后续按类型统计就诊数据时这种拆法会方便很多。体重的字段类型必须用DECIMAL而不是FLOAT原因很简单FLOAT在数据库里是近似值做精度运算时会出现0.10.2不等于0.3的问题价格和体重这类数据不能冒险。预约挂号表是这个系统里状态变化最多的一张表CREATE TABLE appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, pet_id BIGINT NOT NULL, doctor_id BIGINT, appoint_date DATE NOT NULL, appoint_time VARCHAR(20), symptom_desc VARCHAR(500), status TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_pet_date (pet_id, appoint_date) );status字段是整个流程的关键我用数字表示0待就诊1就诊中2已完成3已取消。很多新手喜欢用字符串“WAIT”“DONE”来表示状态也不是不行但在数据库里存数字再配字典表解释查询效率更高写代码也更好判空。预约记录一定要有pet_id和doctor_id的联合索引因为页面上最常做的查询是“某一天某个医生的候诊列表”。病历表、处方表、药品表和收费表也可以依次设计出来。需要注意的是处方明细应该单独拆一张表因为一次处方可能包含多种药品。主表存处方单号、医嘱、宠物和医生信息明细表存药品ID、数量、单价和小计金额。这种“主表明细表”的结构是订单类系统的标准做法不要嫌麻烦。2.2 主外键关系与常见建模误区主人和宠物是一对多关系宠物和预约是一对多关系预约和医生是多对一关系病历和宠物是一对多关系处方和病历是多对一关系。这些关系可以通过外键约束在数据库层面保证一致性但实际开发中MyBatis项目里很多团队选择不做物理外键只在Java代码层面维护逻辑关系。我的建议是表设计图上保留外键关系明确谁引用谁建表SQL中可以不写FOREIGN KEY但必须给关联字段建立普通索引否则多表关联查询会全表扫描。不建物理外键的好处是插入数据时少了一些约束检查删除时也更灵活坏处是如果有人直接操作数据库可能出现孤儿数据。作为练手项目我更推荐用逻辑外键因为改起来不折腾。另外一个非常容易踩的坑是“删除策略”。宠物档案、病历这类数据不应该物理删除而应该加status字段做逻辑删除。前面pet表里我加了status字段就是为这个准备的。比如某只宠物离开了医院后台应该把它标记为“失效”或“停用”而不是直接DELETE否则它的历史病历和处方记录全部断裂统计报表也会出现漏洞。在实际设计时我甚至不建议给“删除”按钮更多是用“停用”代替。数据库字段命名也是一个隐藏很深的坑。列名用下划线风格pet_nameJava属性用驼峰风格petName这是最常见的约定。MyBatis默认情况下不会自动把pet_name映射到petName需要开启map-underscore-to-camel-case开关或者在查询SQL里写别名。这个坑后面详说。3. 从零搭一套SSM后台关键配置与核心代码实现数据库设计完毕之后才是环境搭建和代码实现。这里把关键步骤记录下来尤其是几个配置文件的作用。3.1 环境准备与工程结构这个项目的基础环境不需要太新JDK 1.8、Maven 3、MySQL 5.7左右、IDEA或Eclipse本地Web容器用Tomcat。Maven依赖需要引入Spring、SpringMVC、MyBatis、MyBatis-Spring、数据库驱动、数据库连接池、JSTL、Jackson等。如果在一个空工程里手动加依赖我习惯先确认版本兼容比如Spring 5.x和MyBatis 3.5.x搭配使用是比较稳的组合。工程目录建议采用标准Maven war包结构src/main/java/com/pethms ├── controller // 控制器 ├── service // 业务接口 │ └── impl // 业务实现 ├── mapper // MyBatis数据访问接口 ├── entity // 实体类 └── dto // 参数和返回对象 src/main/resources ├── spring │ ├── spring.xml │ ├── spring-mvc.xml │ └── mybatis-config.xml └── mapper // MyBatis XML文件 src/main/webapp ├── WEB-INF │ └── views // JSP页面 └── static // 静态资源把包分层分好后边写具体代码的时候会减少很多“这个类该放哪”的纠结。Service层拆成接口和实现是为了后面方便改实现、加事务的时候不会动到Controller。3.2 Spring、SpringMVC、MyBatis三个配置文件的职责SSM工程里三个配置文件的分工一开始容易搞混。简单来说spring.xml管Service和Mapperspring-mvc.xml管Controller和页面跳转mybatis-config.xml管SQL执行细节。spring.xml核心内容大致是!-- 扫描Service层实现 -- context:component-scan base-packagecom.pethms.service.impl/ !-- 数据源 -- bean iddataSource class... property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/pethms?useUnicodetrueamp;characterEncodingutf8/ property nameusername valueroot/ property namepassword value123456/ /bean !-- SqlSessionFactory -- bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nameconfigLocation valueclasspath:spring/mybatis-config.xml/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean !-- Mapper接口扫描 -- bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.pethms.mapper/ /bean !-- 事务管理器 -- bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/这里每一个配置都有实际意义。数据源负责建立数据库连接SqlSessionFactory是MyBatis的核心它需要知道XML文件位置MapperScannerConfigurer负责扫描Mapper接口自动生成代理对象事务管理器加上注解驱动是为了让Service方法上的Transactional生效。spring-mvc.xml则是另一个关注点context:component-scan base-packagecom.pethms.controller/ mvc:annotation-driven/ bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean mvc:resources mapping/static/** location/static//注意这里的component-scan只扫描controller包不要把Service也扫进MVC容器里否则可能出现“一个类被两个容器各管一次”的问题事务也不会按预期生效。视图解析器的prefix和suffix决定了Controller返回字符串时对应到哪个JSP路径。web.xml里的重点则是编码过滤器和SpringMVC入口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 /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/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这些配置写一遍之后就能理解Spring Boot帮忙做掉的很多事。如果把配置文件比作施工图纸那每个标签都是在和容器“谈判”告诉它谁负责什么。3.3 预约挂号功能从页面到数据库怎么串起来预约挂号是核心功能我拿这个功能完整走一遍代码流程。先写Controller接收页面参数Controller RequestMapping(/appointment) public class AppointmentController { Autowired private AppointmentService appointmentService; PostMapping(/add) ResponseBody public Result add(RequestBody AppointmentDTO dto) { appointmentService.createAppointment(dto); return Result.success(); } }Service实现里要做三件事参数校验、业务规则校验、保存数据。业务规则里最容易被忽视的是“同一只宠物在同一时间段不能重复预约”。简单的做法是保存前查一次但这个方案在并发场景下有漏洞后面第4章专门讲怎么补。Service public class AppointmentServiceImpl implements AppointmentService { Autowired private AppointmentMapper appointmentMapper; Override Transactional(rollbackFor Exception.class) public void createAppointment(AppointmentDTO dto) { if (dto.getPetId() null || dto.getAppointDate() null) { throw new ServiceException(请选择宠物和预约日期); } Appointment appointment new Appointment(); BeanUtils.copyProperties(dto, appointment); appointment.setStatus((byte) 0); appointment.setCreateTime(new Date()); appointmentMapper.insert(appointment); } }对应的Mapper接口public interface AppointmentMapper { int insert(Appointment appointment); ListAppointment selectList(Param(date) String date, Param(doctorId) Long doctorId); }Mapper XML里写SQLinsert idinsert parameterTypecom.pethms.entity.Appointment useGeneratedKeystrue keyPropertyid INSERT INTO appointment (pet_id, doctor_id, appoint_date, appoint_time, symptom_desc, status, create_time) VALUES (#{petId}, #{doctorId}, #{appointDate}, #{appointTime}, #{symptomDesc}, #{status}, #{createTime}) /insert这个链路看起来平淡无奇但每一步都有值得注意的细节。比如useGeneratedKeystrue是为了让MyBatis把数据库自动生成的主键回填到实体对象中因为后面可能马上要用这个主键去关联病历表Transactional负责保证如果后面还要同时写病历任何一个失败都能整体回滚。3.4 页面和接口对接JSP动态页面还是前后端分离这个项目里我选择的是JSP JSTL Bootstrap的组合原因很直接SSM本来就是服务端渲染为主不需要额外搭一个前端工程学习成本低。列出预约列表时Controller把List放到ModelAndView里JSP用c:forEach循环渲染表格即可。但如果是一些对交互要求高的页面比如填写表单时要实时校验宠物是否存在、异步查询医生排班我会用AJAX配合ResponseBody返回JSON。这时候要注意Jackson对日期类型的序列化问题默认情况下java.util.Date会输出时间戳我一般在实体日期字段加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)保证前端拿到的是可读字符串。还有一个小细节属于页面开发的经验JSP页面表单提交时日期控件用字符串提交后端用DateTimeFormat(pattern yyyy-MM-dd)接收。不要图省事把数据库里的日期直接传给前端格式化问题会占掉很大一部分排查时间。4. 开发过程中很容易踩的五个坑这个项目看着不难但真把我卡住的都是小问题。我把最容易踩的五个坑整理出来每个都是实际开发中会遇到的高频问题。4.1 中文乱码不是改一处就能解决SSM项目中文乱码几乎是必踩。麻烦在于乱码可能出现在页面、请求参数、数据库三个环节任何一个环节编码不统一都会出问题。我调试时发现即使web.xml加了CharacterEncodingFilter如果数据库连接的URL没有指定characterEncodingutf8插入的中文依然会变成问号。连接参数那行必须是useUnicodetruecharacterEncodingutf8这个不可省略。JSP页面头部也要声明% page contentTypetext/html;charsetUTF-8 languagejava %如果使用AJAX提交中文还要保证页面本身以UTF-8保存JS文件编码也要统一。检查乱码的顺序应该是先看页面编码再看请求过滤器最后看数据库连接和表字符集。表默认字符集如果是latin1也会发生“插入没问题查询出来乱码”的情况可以执行ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4修复。4.2 Transactional事务不生效的典型原因事务是这个项目里必须具备的能力因为医生开处方时要同时写病历主表、处方表、药品明细表和扣减库存任何一步失败都不能留下脏数据。但很多人在测试时发现Service方法里故意抛个异常数据居然被写进去了这就是事务没生效。常见原因有三个。第一spring.xml里没有加tx:annotation-driven注解形同虚设。第二事务注解加在了实现类的方法上但该方法是被同一个类中的另一个方法调用的Spring用的是代理对象内部调用不走代理事务自然不会生效。第三异常被try-catch捕获了事务管理器看不到异常默认不会回滚。我的习惯是Service方法只向上抛业务异常不捕获或者指定Transactional(rollbackFor Exception.class)保证任何异常都能触发回滚。4.3 MyBatis查询返回null字段全丢了这是一个让我花了一下午才找到的问题。实体类里明明定义了petName属性数据库里也有值但查询出来全是null。原因是开启下标转驼峰配置之前MyBatis不会自动把pet_name映射成petName。在mybatis-config.xml中加入下面内容settings setting namemapUnderscoreToCamelCase valuetrue/ /settings如果不想改全局配置也可以在XML查询SQL里为每个字段写别名比如SELECT pet_name AS petName FROM pet。前者适合全项目统一后者适合个别字段特殊映射。另外如果你用了resultType而不是resultMap那么列名和属性名对应全靠规则这个开关特别关键。4.4 文件上传后的路径问题宠物档案需要上传头像这个功能看似简单实际坑很多。刚开始我把文件直接保存到项目目录下的某个文件夹结果重启Web容器后图片就404了。原因是IDE重新部署项目时会刷新目录临时目录里的文件被清掉。正确做法是把文件保存到Tomcat之外的一个固定磁盘路径然后在spring-mvc.xml里配置虚拟路径映射mvc:resources mapping/upload/** location/data/pet-upload//这样前端通过/upload/xxx.jpg访问时映射到磁盘上的真实目录不会因为重启丢失。另外要注意校验文件大小和类型别只判断后缀名最好也判断文件头。上传文件命名不要用用户提交的原名建议用UUID或时间戳重命名避免中文文件名和路径冲突。4.5 并发预约下的重复挂号问题这是我把项目做得更健壮时专门思考的场景。简单实现是先select判断有没有冲突预约再insert但两个请求同时通过判断时可能都认为没有冲突最后出现重复挂号。解决方式有很多种最轻量的是在数据库上加唯一约束。比如对“宠物预约日期时段”建唯一索引重复插入时直接报错。不过我更喜欢用“状态更新法”预约成功后先插入一条状态为“待确认”的记录真正分配医生时用一条带条件的UPDATE判断UPDATE appointment SET doctor_id #{doctorId}, status 1 WHERE id #{id} AND status 0这条SQL返回的影响行数如果是0说明有别人已经抢先分配了业务层直接提示冲突。这种利用数据库行锁和原子更新的方式比单纯靠Java代码判断安全得多。当然如果后续并发量真的很大还可以引入Redis分布式锁但对这个项目来说一条UPDATE足够。5. 让项目从“能跑”变成“可展示”的加分项很多人的项目做完了功能也能跑但答辩或展示时拿不出亮点。这里分享几个我实际用过的加分做法不用引入太多新框架却能明显提升系统完成度。5.1 用拦截器实现简单权限控制SSM里实现权限控制常用HandlerInterceptor。写一个LoginInterceptor在preHandle里判断Session中是否有当前用户再写一个AdminInterceptor判断用户角色是否为管理员。在spring-mvc.xml里配置mvc:interceptors mvc:interceptor mvc:mapping path/admin/**/ mvc:exclude-mapping path/admin/login/ bean classcom.pethms.interceptor.AdminInterceptor/ /mvc:interceptor /mvc:interceptors这种方法虽然比不上Spring Security那么专业但用来演示“为什么前台不能直接访问后台管理页面”非常直观。关键是要保持拦截路径和菜单路径一致否则就会出现“页面能打开接口被拦”这种让人摸不着头脑的情况。5.2 让演示数据更有说服力要说项目做得好数据演示也是重要一环。我见过不少项目数据库里只有三四条测试数据统计报表画出来也没有说服力。建议把演示数据做得更接近真实每种宠物类型至少8-10只预约记录覆盖最近一个月病历内容写得像真实病例收费记录金额合理。这么做有两个好处一是展示列表时有翻页效果二是统计图表看起来不空。统计报表可以用ECharts实现后端按日期分组返回营业额前端画柱状图或折线图。这个功能本身不复杂但展示效果非常好。“宠物就诊类型分布”和“药品销售Top10”是我觉得最加分的两张图配合表格一起展示说服力很强。5.3 一点后续扩展建议做完SSM版本之后如果想继续提升我建议三个方向。第一是把它迁移到Spring Boot你会发现大部分业务代码可以直接复用只改配置和依赖即可这个过程能帮你更深刻理解框架差异。第二是给预约功能引入Redis缓存把当天医生的排班信息缓存起来减少数据库压力。第三是加入简单的日志注解通过AOP统一记录操作日志这也是很多企业系统真实在用的方式。不过扩展的前提是核心功能稳定、数据关系清晰不要在基础还没打牢的情况下急着堆噱头。做这个项目的过程中我最深的体会是系统设计阶段的取舍决定后面代码的复杂程度。一开始总觉得要覆盖的模块越多越好做到后来才发现真正核心的只有预约、病历、处方这几条业务线其他都是辅助。先想清楚流程再设计表结构然后才写代码顺序别搞反。这套项目做完之后我对Spring容器管理对象、MyBatis的映射原理、事务边界都有了更实际的理解这些光看视频学不来。最后再分享一个小技巧写代码时遇到不确定的问题先别急着网上搜索试试看把相关配置和XML输出出来读一遍很多答案就藏在这些文件里。