
说起宠物医院养过猫狗的朋友应该都有画面前台是厚厚一本纸质登记簿候诊区里猫包挨着航空箱初诊、复诊、狂犬疫苗、清耳朵的宠物全挤在一起护士每隔几分钟就得扯着嗓子喊名字。预约基本靠电话和现场排队医生哪天坐诊全看排班表贴在门口复诊时想再找同一个医生完全碰运气。这个场景我盯了很久最后用 Java 和 SSM 框架做了一套宠物医院诊断预约管理系统。它解决的问题很具体让宠主在线上选科室、选医生、选时段让前台不用再翻本子登记让医生能提前看到自己的候诊队列让每一笔预约单从提交到就诊结束都有状态跟踪。这篇文章就把这套系统的完整设计思路、数据库结构、核心代码实现和踩过的坑都写出来适合正在做 SSM 课程设计、或者想拿 Java 练手做业务系统的朋友参考。1. 宠物医院排队的真实痛点与需求拆解1.1 前台登记簿背后的混乱为什么会想到做这套系统写代码的人最容易犯一个毛病需求还没聊清楚就开始建表和写接口。我这次没有直接动手而是先去一家社区宠物医院蹲了半天把前台的流程捋了一遍。实际的流程是这样的宠主到店或者打电话预约前台在纸质本子上记录宠物名字、主人电话、预约日期、想看的科室。当天现场来的宠物就直接排进一个“来一个记一个”的临时队列。问题很快就暴露了科室和医生不绑定。登记簿上只写了“要看皮肤科”但当天哪个皮肤科医生在岗、还有几个号前台自己也不清楚只能现场打电话问。复诊连续性完全靠运气。上次给宠物看病的医生这周六不上班宠物主根本不知道来了之后只能换医生病情沟通成本很高。疫苗和诊断混在一起排队。给猫打第三针疫苗的和来看皮肤病的、来拆线的共用同一个候诊队列导致诊断医生经常被非诊断业务打断。没有任何提醒和爽约机制。预约了不来的宠物不在少数前台只能干等医生诊室空转。这些问题本质上不是“缺一个 Excel”而是缺少一套以“预约单”为核心、能贯穿宠物档案、医生排班、诊断记录的业务系统。所以我在设计需求时没有做成一个简单的“挂号网站”而是按真实工作流来拆。1.2 三类角色、六大模块功能清单一开始就要列清楚系统里一共有三类用户宠物主、前台/管理员、医生。宠物主关注的是“我家宠物什么时候能看上病”前台关注的是“今天哪些医生有号、哪些预约到了”医生关注的则是“我的候诊队列里是谁、初诊还是复诊、之前看过什么病”。围绕这三个角色我把功能拆成六大模块模块核心功能使用角色用户与权限注册、登录、角色区分、密码加密全部宠物档案管理添加宠物、维护品种/年龄/疫苗状态宠物主科室与医生管理维护科室、医生、职称、排班管理员预约管理选科室选医生选时段、取消预约宠物主诊断叫号与记录候诊队列、开始就诊、填写诊断结果前台/医生统计与通知按医生/科室统计预约量、到诊率管理员这里面最容易被忽略的是“通知”。我在实际使用中发现光有预约单还不够宠物主经常忘记预约时间。后来加了站内信和简单的短信提醒接口爽约率明显下降。如果只做课程设计站内信就够用了但设计的时候要留出这个扩展点。1.3 诊断预约和疫苗/美容的边界别把所有业务都塞进来和很多外包项目的通病一样需求方总想一个系统搞定一切能约诊断、能约疫苗、能约美容洗澡、还能卖宠物粮。我坚持只做“诊断预约”这一条主链路因为诊断业务有几个特性是其他业务不具备的诊断按医生排班且初诊通常比复诊时间长。初诊可能需要 20 到 30 分钟复诊可能 10 分钟就够了时段长度不能一刀切。诊断结果需要回到病历里。预约系统如果只是“约个时间”那和美容预约没有区别但诊断预约结束后必须能填写诊断记录形成闭环。急诊不能走预约。必须给现场急诊留绿色通道否则预约系统会把紧急情况也卡在“选时段”里。所以最终系统的定位是围绕“宠物档案 医生排班 预约单 诊断记录”这条主线疫苗和美容暂时不做只在前端提示用户自行电话预约。这个取舍让整个系统边界非常清晰开发工作量可控也更容易讲清楚业务逻辑。2. 从Spring Boot到SSM这套体系的框架选型逻辑2.1 为什么没用Spring BootSSM到底赢在哪我承认如果今天从零开始做一个生产级项目我大概率会选 Spring Boot自动配置省掉一大堆 XML。但这套系统我有意选了 SSM也就是 Spring SpringMVC MyBatis。原因不是守旧而是 SSM 让你被迫理解“每一块配置到底在干什么”。Spring Boot 把数据源、事务、包扫描、视图解析器全部藏起来了你写 CRUD 很爽但你不知道一个请求从 URL 到 Service 再到数据库之间发生了什么。SSM 则强迫你手动装配这三层尤其是三个 Spring 配置文件的分工一旦你亲手配过一遍后面看 Spring Boot 的自动配置源码会轻松很多。这套系统是课程设计级别的典型项目选 SSM 还有一个现实好处答辩的时候可以讲的东西更多。框架越“重”你被追问“这个配置为什么要写”的概率越小因为你确实知道答案。2.2 包结构与三层架构Controller不写业务是最低要求项目包结构我按典型的 Maven 单模块方式组织不要搞多模块课程设计里多模块纯属增加复杂度。com.pet.hospital ├── controller // 接收请求、参数校验、返回视图或JSON ├── service // 业务逻辑事务边界 ├── dao // MyBatis Mapper接口 ├── entity // 数据库实体类 ├── interceptor // 登录拦截、角色权限控制 ├── vo // 前端展示用的组合对象 └── util // 公共工具类比如日期格式化这里有一个非常重要的纪律Controller 里只做参数接收和简单校验真正的业务规则比如“这个时段约满了没有”“这只宠物是不是当前用户的”全部放到 Service。我见过太多同学把业务逻辑写在 Controller 里一个方法上百行还连查三张表最后想加事务都不知道从哪加起。分层不是框架的要求是人为降低认知负担的方式。2.3 三个Spring配置文件的分工与常见配置陷阱SSM 的配置通常分成三份spring-dao.xml、spring-service.xml、spring-mvc.xml。spring-dao.xml管数据源、SqlSessionFactory、Mapper 扫描。spring-service.xml管 Service 层 Bean、事务管理器、事务切面。spring-mvc.xml管 SpringMVC 的组件比如视图解析器、Controller 扫描、静态资源放行。一个我实际踩过的配置坑是包扫描边界。如果 spring-mvc.xml 里扫描了com.pet.hospital.service而 spring-service.xml 也扫描了同一个包会造成 Service Bean 被实例化两次事务配置混乱。正确的做法是Spring 容器父容器扫描 Service 和 Dao。SpringMVC 容器子容器只扫描 Controller。许多项目出现“明明是配置了事务但回滚不了”的问题一大半都出在这事务方法所在的 Service 被 SpringMVC 子容器扫描了而事务配置在父容器里子容器的 Bean 享受不到父容器的事务增强。MyBatis 方面我开启了驼峰映射map-underscore-to-camel-case: true这样数据库字段create_time能自动映射到实体类的createTime省去大量 resultMap。数据源用 Druid至少设置 initialSize5、maxActive20避免并发高时频繁建连。视图层用的是 JSP JSTL。虽然现在看有点复古但配合 SpringMVC 的 InternalResourceViewResolver 非常直观做这套系统时前后端没分离JSP 直接渲染数据开发效率很高。3. 数据模型设计7张表和一个预约单状态机3.1 核心表结构梳理数据库设计是整个系统成败的关键。我前后改了三版才稳定下来核心就是这七张表表名说明关键字段user用户表id、username、password、salt、role、phonepet宠物档案表id、user_id、pet_name、pet_type、breed、birthdaydepartment科室表id、dept_name、description、statusdoctor医生表id、user_id、dept_id、real_name、title、introschedule排班表id、doctor_id、work_date、slot_minutes、begin_time、end_time、max_countappointment预约单表id、pet_id、user_id、doctor_id、appt_date、time_slot、status、diagnosis_typediagnosis_record诊断记录表id、appointment_id、pet_id、doctor_id、content、prescription、create_time有几个字段容易想当然实际开发时踩过坑我说一下password绝对不能明文存。我用的是 MD5 随机 salt虽然现在更推荐 BCrypt但课程设计里讲清楚“为什么加盐”比用什么算法更重要。pet_type不要只存“猫/狗”最好再存 breed 品种字段。皮肤科医生在诊断前往往需要知道品种有些品种有遗传皮肤病倾向。appointment 表里要有pet_id不能只关联user_id。一个用户有多只宠物如果只存用户 ID你后面压根不知道这次预约的是哪只猫。schedule 表用slot_minutes而不是固定死 30 分钟因为初诊和复诊时长不同排班可以灵活调整。3.2 预约单的状态流转是业务流程的浓缩状态机是这套系统里最值得设计的部分。一开始我只设计了两个状态有效和取消结果发现根本不够用。最终状态定义如下状态枚举含义触发动作0 PENDING待确认用户提交预约后1 BOOKED已确认前台确认或系统自动确认2 VISITING就诊中前台点击“开始就诊”后3 FINISHED已就诊医生提交诊断记录后4 CANCELLED已取消用户或管理员取消5 MISSED爽约预约时间已过但未到诊这里面的关键逻辑是不是所有预约都能直接进入 BOOKED。考虑到宠物医院的实际情况初诊预约必须由前台电话确认因为初诊可能要问一些细节比如宠物有没有既往病史、是否在发情期。复诊预约则可以系统自动确认因为病情已经掌握。状态字段我用 tinyint 类型存储不用字符串查询效率高。前端用一个 Map 把枚举值翻译成中文不要到处写魔法数字至少要在 Service 层定义一个常量类。3.3 防止同一时段重复预约的数据库层设计预约最怕的就是“超约”——同一个医生同一天同一时段被约了 20 个人结果只看得了 10 个。我在数据库层面做了两层设计。第一层预约单表上建一个唯一索引uk_user_pet_time (pet_id, appt_date, time_slot)确保同一只宠物不会在同一时段被重复预约。这样即使代码里漏了校验数据库也会兜底报错。第二层在 schedule 表里加remain_count字段每次预约成功就扣 1。但这个字段的扣减不能写成“先查出来判断再 update”那样并发下一定出问题具体解法我在第 4 章专门讲。如果你想把时段做细比如把上午拆成 9:00-9:30、9:30-10:00更规范的做法是建一张schedule_slot表每个排班预先生成好所有时段每个时段一行状态是“可约/已约/停诊”预约直接锁定某一行。这样更清晰但表会变大查询也多个 join。我这套系统因为时段不是强固定粒度所以采用time_slot字符串存“2024-06-15 09:00-09:30”配合 schedule 的remain_count做容量控制已经够用。4. 预约诊断核心功能的落地过程4.1 科室-医生两级联动与排班数据组织预约页面的核心交互是先选科室再选医生最后选时段。这个交互背后对应三条查询-- 查询科室列表 SELECT * FROM department WHERE status 1; -- 根据科室查医生 SELECT * FROM doctor WHERE dept_id #{deptId} AND enable 1; -- 根据医生查一周内可约时段排班总容量 - 已预约数 SELECT s.id, s.work_date, s.begin_time, s.end_time, (s.max_count - IFNULL(a.cnt, 0)) AS remain_count FROM schedule s LEFT JOIN ( SELECT schedule_id, COUNT(*) AS cnt FROM appointment WHERE status IN (0, 1, 2) GROUP BY schedule_id ) a ON a.schedule_id s.id WHERE s.doctor_id #{doctorId} AND s.work_date BETWEEN #{startDate} AND #{endDate} AND s.max_count - IFNULL(a.cnt, 0) 0前端的交互是 ajax 三级联动每一级变化都重新拉取下一级数据。这里我一开始犯了个小错误把医生列表接口写成了“查出所有医生再在前端过滤”数据量小的时候没感觉但一旦用户量上来请求体积和渲染时间都会变长。改成“每次都查库”反而更合理因为本身就是小查询加索引后毫秒级返回。排班数据的生成我写了一个后台功能管理员选择医生、日期范围、每天的门诊时段系统自动生成 schedule 记录。不要指望手工一条条插那太反人类了。4.2 名额扣减用乐观锁解决并发超约这个问题我差点栽了。最开始写的是最自然的逻辑public boolean createAppointment(AppointmentDTO dto) { Schedule schedule scheduleMapper.selectById(dto.getScheduleId()); if (schedule.getRemainCount() 0) { return false; } schedule.setRemainCount(schedule.getRemainCount() - 1); scheduleMapper.updateById(schedule); appointmentMapper.insert(dto); return true; }看起来没毛病但放到并发环境一试超约了。原因是两个请求同时读到remain_count 1都认为还能约都执行了 update最终约了两个。MySQL 默认隔离级别是 REPEATABLE READ两个事务各自有快照查出来的都是 1。我改用乐观锁把判断和扣减合并成一条带条件的 updateint rows scheduleMapper.decreaseCount(scheduleId); if (rows 0) { throw new BusinessException(该时段已约满请重新选择); }对应的 SQL 是这样UPDATE schedule SET remain_count remain_count - 1 WHERE id #{scheduleId} AND remain_count 0update 语句自带行锁并且remain_count 0作为条件同一时刻只有一条 update 能成功另一个的 affected rows 为 0直接抛出友好提示。这个改动带来的效果非常直接——用 JMeter 模拟 20 个并发请求抢最后 5 个名额只有 5 个成功其余全部干净地失败。关键点在于把“读-判断-写”三步压缩成“条件写”一步。这不是什么高深技巧但很多人写业务代码时不习惯这样思考总觉得要先查出来看看才放心。4.3 候诊叫号与诊断记录回填的闭环预约管理不只是“约上就完事”关键闭环在就诊环节。前台打开医生的当日列表按预约时间排序显示每笔预约的状态。当宠物到店后前台点击“到诊”预约单从 BOOKED 变成 VISITING医生端就能在自己的账户里看到“正在就诊”的宠物和宠物档案。诊断结束后医生填写诊断内容和处方保存后预约单变成 FINISHED同时向 diagnosis_record 表插入一条诊断记录。这个设计的价值在于下一次复诊时医生可以直接看到这只宠物上一次的诊断记录。我在 Service 层做了一个查询预约详情页面会把该宠物最近三条 diagnosis_record 一并带出来。这个功能被实际用过的人都说是最实用的因为宠物不会说话病史全靠记录能串起来才是真正的诊疗闭环。Controller 层的一个典型写法RequestMapping(/finish) ResponseBody public Result finish(RequestParam(appointmentId) Integer id, RequestParam(content) String content, RequestParam(prescription) String prescription, HttpSession session) { Doctor doctor (Doctor) session.getAttribute(doctor); if (doctor null) { return Result.error(请先登录医生账号); } diagnosisService.finishDiagnosis(id, doctor.getId(), content, prescription); return Result.success(); }注意前端传回来的 appointmentId 不能直接信任是当前医生名下的预约Service 层必须校验医院绑定关系否则 A 医生可以给 B 医生名下的患者写诊断属于越权操作。4.4 登录拦截与角色权限的配置细节权限控制我用 SpringMVC 拦截器实现没上 Shiro 和 Spring Security因为这个体量用安全框架反而增加学习成本。拦截器逻辑分成两层。第一层是登录拦截。校验 session 中是否有 user/doctor 对象没有就跳转到登录页。第二层是角色拦截。我定义了一个RequireRole注解标在 Controller 方法上拦截器根据 session 中的角色判断是否放行。比如排班管理只有管理员能操作提交诊断只有医生角色能调用。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); return false; } return true; } }拦截器注册时要记得放行登录页、注册页、静态资源路径否则 CSS 和 JS 全被拦了页面样式直接崩掉。这个坑我踩过排查了一半天才发现是拦截器把/static/**给拦了。5. 开发过程踩过的坑与修复记录5.1 事务悄悄失效一场“假成功”事故的排查链路有一次提交预约后页面提示失败但数据库里已经有了一条预约记录。当时第一反应是事务没生效一路查下来问题出在自调用上。我的一个 Service 内部有个私有方法做预约核心逻辑然后在外部公共方法里直接this.createCore(...)调用。Spring 事务是基于 AOP 代理的this调用不会经过代理对象所以事务注解完全没生效。这个叫事务自调用失效。排查链路大致是先确认数据库连接本身没问题单独执行 SQL 正常。在 Service 方法里加日志发现前半段执行了后半段抛异常。检查事务配置确认 spring-service.xml 的tx:annotation-driven和aop:config都配了配置无误。最后怀疑代理机制把方法改为注入自身代理或者拆到另一个 Service 里问题解决。这让我养成了一个习惯凡是加了 Transactional 的方法绝不在同类内部用 this 调用。如果非要自调用要么拆类要么通过 ApplicationContext 拿代理对象。5.2 日期格式与时区不同导致的预约错乱还有一个非常隐蔽的坑前后端日期处理不一致。前端传的字符串是2024-06-15 09:00后端用 SimpleDateFormat 解析后存数据库结果预约清单里时间全部差了 8 小时。原因是 JDBC 连接串没指定时区MySQL 驱动用服务器本地时区解析而应用服务器是 UTC数据库是 UTC8两边不一致。修法是统一规范做了三件事数据库连接 URL 加上serverTimezoneAsia/Shanghai。数据库 date/datetime 类型用yyyy-MM-dd HH:mm:ss格式统一存字符串反正 MySQL 也会校验。前端页面用fmt:formatDate标签格式化不手动拼字符串。这也提醒我做任何涉及时间的系统一开始就要统一时区和格式约定不然后面排查起来非常痛苦。5.3 排序字段SQL注入风险与白名单处理做医生列表和预约列表时我想让前端可以传排序字段于是写了ORDER BY ${sortField} ${sortDir}。刚开始没意识到问题但后来一想${}是直接拼接 SQL 的用户传sortField id; DROP TABLE user这种值进去SQL 库就没了。MyBatis 的#{}是预编译占位符${}是直接拼接二者有本质区别。我的处理方式是白名单后端只允许create_time、appt_date、status三个字段参与排序前端传其他值一律回退到默认排序。排序方向也做了校验只接受asc或desc其余全部忽略。这不是 MyBatis 技巧而是基本的输入校验思维。String sortField id; if (appt_date.equals(param)) { sortField appt_date; } else if (create_time.equals(param)) { sortField create_time; }5.4 图片上传路径映射和部署环境的坑宠物档案要上传宠物头像这就涉及文件上传。开发时我把图片保存到项目目录下的/upload文件夹一切正常。部署到服务器后用 Tomcat 直接跑图片路径却 404 了。原因是文件虽然写到了磁盘但 Tomcat 不会自动把项目外路径映射成 URL 访问路径。后来我在 spring-mvc.xml 里配置了资源映射同时把上传目录固定到 Tomcat 之外的独立目录避免项目重新部署时图片被清空mvc:resources mapping/upload/** location/opt/pet-hospital/upload/ /上传文件的命名也不能用用户文件名容易包含特殊字符和安全风险。我改成UUID 时间戳 后缀名既避免重名覆盖也隔离了可能的路径穿越问题。一个额外的建议如果你要实现这套系统我建议你从预约单的状态机开始设计先想清楚 PENDING、BOOKED、FINISHED、CANCELLED 这些状态在业务里分别对应什么动作再动手建表。状态定义清楚了Service 方法的边界也就清楚了Controller 怎么写都顺。我自己做完最大的体会是一个看似普通的预约管理真正难的不是 CRUD而是并发下的名额控制、状态的一致性、以及医生和宠物档案之间的关联历史。“能跑起来”和“能在真实场景里不出错”中间隔着好几个坑希望这篇文章能帮你少跳几个。