1. 儿童医院挂号场景的特殊性比通用挂号系统多出来的那几层需求先说我为什么要做这套系统。之前帮某儿童医院信息科做过一次门诊流程调研发现儿科挂号和其他科室挂号完全是两码事。成人挂号通常是患者自己操作选科室、选医生、选时段流程比较直接。儿科则几乎全是家长代办而且家长对“哪个科室”的判断经常是模糊的——“孩子发烧咳嗽到底挂儿科内科还是儿科呼吸科”这个问题每天在导诊台被问几百遍。所以我做这个基于JavaSpringBootSSM的儿童医院挂号管理系统时第一件事不是写代码而是把儿科门诊的特殊场景拆清楚。这套系统面向三类使用对象家长在线预约、取消预约、查询记录、医生查看排班、处理叫号、填写就诊状态、管理员维护科室和医生信息、设置排班规则、管理号源参数。它解决的核心问题是把过去窗口排队式的挂号流程变成线上可查询、可预约、可追溯的完整闭环。适合用来学习SpringBoot整合SSM的同学也适合做医院信息化相关课题的开发者参考如果你正准备做类似的预约类管理系统这篇文章的完整链路拆解可以直接拿来对照。儿科挂号场景里有一个细节容易被忽略号源不能只按“医生”维度设置必须拆到“时间段”维度。同一个医生上午和下午的放号量可能完全不同周一和周六的排班也可能不同甚至某个时间段要预留几个现场号。我一开始把排班表和号源表合并成一张表结果每次调整都要动结构后来才拆成排班主表加号源子表这个后面会详细讲。预约状态机的建模也要提前想清楚。一个预约记录至少要经历待支付、已锁定、已完成、已取消、爽约这几个状态。我在设计时还加了一个“待确认”状态用来处理“家长提交预约但没在限定时间内完成支付”的情况由定时任务自动把超时订单释放回号源池。这一步看似额外实际上线之后非常关键儿科患者的就诊计划变动频繁没有超时释放机制号源很快就会被无效订单占满。再说功能边界。这套系统的定位是“挂号管理系统”不是完整的医院HIS系统。所以它不做在线支付对接不做电子病历不做药房管理专注做好预约挂号这一条主线的数据准确性和状态一致性。把边界划清楚项目的代码量和可维护性都能控制在一个合理范围内这也是很多毕业设计和中小型项目最应该学习的思路。2. 技术选型复盘SpringBoot作为底座、SSM作为骨架的整合方式现在很多同类项目一上来就微服务、分布式、Redis缓存堆一大堆技术栈实际业务可能就几张表。我做这个系统时定了一个原则技术选型服从业务复杂度。儿童医院挂号管理系统的并发量远没有到需要微服务的程度核心难点在业务状态流转和号源一致性所以我选择SpringBoot作为基础框架内部沿用SpringMVC加MyBatis这套SSM组合。先说为什么不是纯SSM。纯SSM需要手动配置大量的XML、web.xml、Spring和SpringMVC的装配项目跑起来之前的配置工作比写业务代码还多。SpringBoot把这些自动化了内嵌Tomcat一个jar包直接启动开发体验好很多。相当于整个项目的“地基”用SpringBoot打好了而SpringMVC负责Web层的请求分发MyBatis负责数据库操作这仍然符合SSM的经典分层思想。工程结构我按职责分包看图更直观com.hospital.child ├── controller # 控制层接收请求、参数校验、返回结果 ├── service # 业务层预约、取消、排班等核心逻辑 │ └── impl ├── mapper # MyBatis数据访问接口 ├── entity # 实体类用户、医生、排班、号源、预约记录 ├── config # 拦截器、跨域、定时任务配置 ├── common # 统一返回结果、异常处理、枚举常量 └── utils # 日期处理、加密工具等这种分层的好处是依赖方向清晰controller依赖serviceservice依赖mapper没有人跨层调用。我见过不少项目把SQL写在controller里当时方便后面改一个需求要牵连一堆地方所以这个习惯最好从一开始就建立。依赖配置方面核心依赖就几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency配置文件中主要关注数据源、MyBatis映射路径、日期格式这几项spring: datasource: url: jdbc:mysql://localhost:3306/child_hospital?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.hospital.child.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case这个配置强烈建议打开不然数据库字段childName映射到实体属性child_name很别扭。很多新手在这里踩坑报错“找不到属性xxx”多半就是驼峰映射没开。有一点要和做毕业设计的同学说明白SpringBoot加SSM组合不是“两套框架混用”而是SpringBoot项目里自然包含SpringMVC和MyBatis只是不再写传统SSM那一堆XML配置。面试或者答辩时被问到可以这样解释SpringBoot负责自动配置和启动SpringMVC负责Web层请求处理MyBatis负责持久化三者职责不重叠是互补关系。3. 数据模型设计科室、医生排班、号源与预约记录的耦合关系数据模型是整个系统的地基。我设计这套表结构时反复改过好几版核心思路是把“排班规则”和“号源实例”分开把“号源”和“预约记录”通过状态字段联动。最终核心表包括科室表、医生表、排班表、号源表、预约记录表、用户表下面逐个说设计要点。科室表和医生表是基础数据。科室表字段很简单科室名称、科室位置、接诊类型、状态。医生表要存科室ID、职称、简介、每日可预约数上限。医生和科室是多对一关系一个医生只属于一个科室这在儿科医院场景下是合理的不需要做多对多。排班表是业务开始的地方。一个排班记录的含义是某医生在某一天、某个时间段内出诊。字段包括医生ID、排班日期、开始时间、结束时间、总号源数、剩余号源数、状态。为什么要把总号源数和剩余号源数都存下来因为列表查询要展示“剩余多少号”这个数据如果每次实时count预约记录数据量大了之后查询会越来越慢直接冗余一个字段是典型的以空间换时间做法。号源表的设计是另一个关键。我最初没有这张表只有排班表的剩余号源数但后来发现无法处理“某个时段已经被预约了哪几个具体号码”的查询需求。所以加了号源表一个排班记录会生成多个号源记录每个号源有唯一的序号、就诊时间段、状态可预约/已锁定/已使用/已释放。生成号源的逻辑放在排班创建之后用代码批量插入。假设排班设置了100个号源自动拆成10个时段每时段10个号public void generateSource(Schedule schedule, int totalCount, int slotMinutes) { ListNumberSource list new ArrayList(); LocalTime start schedule.getStartTime().toLocalTime(); LocalTime end schedule.getEndTime().toLocalTime(); int slotCount (int) Duration.between(start, end).toMinutes() / slotMinutes; int perSlot totalCount / slotCount; for (int i 0; i slotCount; i) { for (int j 0; j perSlot; j) { NumberSource source new NumberSource(); source.setScheduleId(schedule.getId()); source.setSlotStart(start.plusMinutes(i * slotMinutes)); source.setSlotEnd(start.plusMinutes((i 1) * slotMinutes)); source.setStatus(0); // 0可预约 list.add(source); } } numberSourceMapper.batchInsert(list); }预约记录表是业务核心字段包括预约单号、用户ID、医生ID、排班ID、号源ID、预约日期、就诊时段、状态、创建时间、更新时间、取消时间。预约单号不要用数据库自增ID直接暴露给前端建议用日期加随机数生成一个业务单号方便后续查询和对账。预约记录的状态流转要严格定义我最终定下来的状态图是待支付初始创建到已锁定模拟支付成功到已完成就诊结束待支付超时变已取消已锁定状态家长可以主动取消变成已取消对应号源回滚为可预约。这套状态流转在代码里用常量类集中管理不允许在业务代码里散落数字魔法值。再说索引设计。预约记录表最常用的查询是“按用户查我的预约”和“按排班查某天预约情况”所以user_id, create_time和schedule_id这两个组合索引必不可少。号源表schedule_id, status也要建联合索引。很多项目业务逻辑没问题但一上线查询就慢问题基本都出在索引缺失这属于前期设计阶段就要考虑好的事。4. 从选科室到锁定号源预约核心链路的编码实现预约核心链路是整套系统最需要抠细节的地方从用户选科室开始到最后成功锁定号源中间经过查询排班、选择时段、创建预约、扣减号源、更新排班剩余数这一连串操作。我按请求流转顺序把关键代码和设计意图拆开讲。控制器层遵循“接收参数、简单校验、调用服务、返回统一结果”的原则。统一返回结果类用泛型设计public class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ... } public static T ResultT fail(String message) { ... } }业务层是重点。查询可预约列表时要返回的数据包括医生姓名、职称、科室名称、排班日期、剩余号数、时段列表。SQL用多表关联查询一次查出基础信息时段列表可以单独再查避免一条大SQL过于复杂。select idfindAvailableSchedules resultTypemap SELECT s.id AS scheduleId, d.name AS doctorName, d.title AS doctorTitle, dep.name AS deptName, s.scheduleDate, s.remainingCount FROM schedule s JOIN doctor d ON s.doctor_id d.id JOIN department dep ON d.dept_id dep.id WHERE s.schedule_date #{date} AND s.remaining_count 0 AND s.status 1 ORDER BY d.name, s.schedule_date /select创建预约是整个链路的核心方法事务边界要卡在这里。方法逻辑分成四步校验用户、校验号源状态、创建预约记录、扣减号源。四步必须在一个事务里任何一步失败都要整体回滚。Transactional(rollbackFor Exception.class) public OrderResult createOrder(Long userId, Long scheduleId, Long sourceId) { // 1. 校验用户是否存在且状态正常 User user userMapper.selectById(userId); if (user null || user.getStatus() ! 1) { throw new BusinessException(用户状态异常); } // 2. 校验号源状态 NumberSource source numberSourceMapper.selectByIdForUpdate(sourceId); if (source null || source.getStatus() ! 0) { throw new BusinessException(该号源已被预约请选择其他时段); } // 3. 创建预约记录状态为待支付 Appointment order new Appointment(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setSourceId(sourceId); order.setStatus(0); appointmentMapper.insert(order); // 4. 锁定号源 int updated numberSourceMapper.lockSource(sourceId); if (updated 0) { throw new BusinessException(号源锁定失败请重试); } return new OrderResult(order.getOrderNo()); }注意第2步我用的是selectByIdForUpdate也就是SELECT FOR UPDATE悲观锁。很多人问为什么不用乐观锁。我的回答是号源表是高频热数据乐观锁更新失败率高反而会导致大量重试而悲观锁只锁单行冲突窗口极小在单机部署场景下足够可靠。等有分布式需求时再换成Redis预扣方案也不迟。号源锁定的SQL要写成条件更新UPDATE number_source SET status 1 WHERE id #{id} AND status 0这样即使两个请求同时进来数据库层面也只有一个更新成功天然具备原子性。事务提交后再异步更新排班表的剩余号源数。取消预约是预约链路的反向操作补偿逻辑要完整。取消时先判断预约状态是否是“已锁定”如果不是提示不能取消。然后更新预约状态为已取消同时把号源状态回滚为0排班剩余数加1。这三个操作同样要在同一事务里。Transactional(rollbackFor Exception.class) public void cancelOrder(Long orderId, Long userId) { Appointment order appointmentMapper.selectByIdForUpdate(orderId); if (order null || !order.getUserId().equals(userId)) { throw new BusinessException(预约记录不存在); } if (order.getStatus() ! 1) { throw new BusinessException(当前状态不可取消); } order.setStatus(5); order.setCancelTime(new Date()); appointmentMapper.updateById(order); numberSourceMapper.releaseSource(order.getSourceId()); scheduleMapper.increaseRemaining(order.getScheduleId()); }还有一个细节超时未支付的订单我是用SpringBoot自带的Scheduled定时任务每五分钟扫一次把超过30分钟未支付的订单批量取消并释放号源。定时任务方法要加Scheduled(cron 0 */5 * * * ?)并且做好幂等处理避免重复扫描导致重复释放。5. 三端权限与登录体系如何让同一套用户表撑起三类角色挂号系统天然有三类用户家长、医生、管理员。很多人会把它们拆成三张用户表我建议不要这么做。三张表会导致登录接口要判断“去哪张表查”跨角色操作时又要额外关联非常麻烦。实现思路是共用一张用户表通过role字段区分角色相关业务表再用角色ID关联。用户表的核心字段是id、username、password、realName、role1家长/2医生/3管理员、phone、status、createTime。医生角色额外维护在doctor表里通过user_id关联而doctor表又关联department表。家长预约的时候查医生信息就是通过doctor表拿展示数据用户表只负责登录认证。登录流程用拦截器做统一校验比在每个Controller里写判断干净得多。先创建一个LoginInterceptor实现HandlerInterceptor接口在preHandle方法里取token解析用户信息放行或者返回未登录提示。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || token.isEmpty()) { response.setStatus(401); response.getWriter().write(未登录); return false; } User user TokenUtil.parseToken(token); if (user null) { response.setStatus(401); response.getWriter().write(登录已过期); return false; } request.setAttribute(currentUser, user); return true; } }角色校验也放在拦截器里通过自定义注解实现。定义一个RequireRole注解标注在Controller方法上拦截器判断当前用户角色是否匹配。比如管理员删除医生的接口标注RequireRole(3)只有role为3的用户能访问。这样即使用户登录了没有权限也会被拦截避免每个方法里重复写角色判断。密码存储必须用加密算法我使用BCrypt。BCrypt的优点是每次加密结果都不同密文自带盐值存储成本低安全性高。登录校验时用BCrypt.matches(rawPassword, encodedPassword)比对不要把密码明文存数据库这是安全底线。// 注册时加密 String encoded BCrypt.hashpw(password, BCrypt.gensalt()); // 登录时校验 if (!BCrypt.checkpw(rawPassword, user.getPassword())) { throw new BusinessException(用户名或密码错误); }token生成我用了JWT标准。JWT的特点是无状态服务端不用存session解析出用户信息后直接使用。我封装了一个简单的TokenUtil生成时放userId和role进去过期时间设为24小时前端每次请求放在Header里携带。这样设计后后续扩展微信小程序或者App端也方便不用改登录认证逻辑。还有一类特殊账号要处理演示账号。因为系统交付后要给别人演示或测试我预设了一个家长账号、一个医生账号、一个管理员账号初始化密码统一为123456。但这种账号有一个危险如果不限制演示环境很容易被其他人改密码。所以我在初始化SQL里特别加了一个标记字段演示账号禁止修改密码管理员可以在系统里单独维护演示账号列表。这个小设计在答辩演示和项目交付时非常实用。6. 并发压测踩坑记录号源超卖与事务失效的完整排查这部分是整个开发过程中最折磨人、也最有价值的环节。系统基本功能做完之后我用模拟并发请求去做压测结果发现一个严重问题号源会出现超卖。所谓超卖就是一个号源被多个用户同时预约成功数据库中剩余号数变成了负数。复现步骤很简单用并发工具同时发50个预约请求目标号源原本只有5个剩余名额最终却有十几条预约成功记录。排查思路我按下面几条逐一确认第一反应是查数据库。发现number_source表确实被多条预约记录关联了说明扣减逻辑出了问题。再仔细看代码发现我在创建预约时先用selectById查号源状态判断状态为0后再执行update。问题出在这个“先查后改”不是原子操作两个线程都查到状态为0都通过校验然后都执行update最后一个把前面覆盖了。本质是典型的时间窗口竞态。修复方式是去掉“先查”直接执行条件更新用更新返回值判断是否成功。数据库行锁保证同一时刻只有一个事务能更新这一行另一个事务的update会阻塞或者返回0这样就能把并发控制在数据库层面。UPDATE number_source SET status 1 WHERE id #{id} AND status 0第二个坑更隐蔽。按照上面的方式改完之后我又跑了一次压测发现仍然是超卖而且日志里出现了事务相关异常。排查下来是我的Service方法被同类内部方法调用导致的。我把创建预约的入口方法放在AppointmentService里它内部调用了this.lockSource()方法。这个lockSource方法虽然加了Transactional注解但通过this调用时事务注解不生效。因为Spring事务是基于AOP代理的只有通过代理对象调用方法事务才会被拦截处理this调用走的是原始对象注解形同虚设。解决办法有两个一是把涉及事务的方法拆分到不同Service类中让Spring代理生效二是使用AopContext.currentProxy()获取代理对象再调用。我采用的是第一种更直观也更容易理解。创建一个NumberSourceService专门负责号源扣减和释放AppointmentService注入它来调用事务边界就正确了。修完这两个问题再压测一次结果彻底正常了。修复前后的数据对比如下压测场景并发线程数实际预约成功数号源最终状态是否超卖修复前先查后改5017负数是修复前条件更新this调用509负数是修复后事务拆分生效5050否这个踩坑过程让我深刻意识到并发问题不能靠“想当然”解决必须靠压测验证。写代码时觉得“应该没问题”一上量就会露馅。事务失效是Spring开发中非常经典的问题面试也爱考我建议所有做这个项目的同学都亲手复现一遍比看十遍文档都记得牢。7. 调试文档与讲解演示交付这套系统真正费心的地方项目源码写完之后最容易被忽视的就是调试文档和演示准备。我在这个过程里踩了不少坑也总结了一些对后来人有价值的经验。所谓调试文档不是把代码注释抄一遍而是让一个完全不了解系统的人拿到项目后能快速跑起来并且能复现每个功能场景。调试文档里必须先写清楚环境版本。Java版本用8还是17、MySQL版本用5.7还是8.0、Maven版本用哪个这些不写清楚别人很可能在启动阶段就卡住。我就遇到过项目用JDK8写演示环境装了JDK17SpringBoot启动直接报错的情况。建议在文档开头用一个表格列明环境版本并标注“推荐使用主流的JDK8环境”因为很多学校机房和老项目都还在用JDK8。测试数据是调试文档另一个核心部分。我预置了三类数据科室数据儿科内科、儿科呼吸科、儿科消化科等、医生排班数据覆盖工作日、周末、上午、下午不同场景、用户数据家长、医生、管理员三类账号。测试数据要能达到“每种状态都有记录”的效果比如预约记录里既有已完成也有待支付、已取消的这样演示时可以快速切入任意状态讲解。接口测试脚本也很重要。我直接用Postman导出了一组JSON文件按模块分文件夹保存。每个请求都写清楚接口路径、请求方法、请求参数示例、预期返回结果。特别是预约创建和取消这两个接口参数里带上真实存在的排班ID和号源ID确保别人照着调用就能成功。很多人调试时喜欢自己临时测试但重新造数据又慢又容易出错不如把现成接口用例交付出去。演示前的流程也要过一遍。我一般会按这个顺序演示管理员登录维护排班生成号源切换到医生账号查看排班再切换到家长账号查询号源、创建预约、取消预约。中间穿插展示数据库变化。比如创建预约后随手打开MySQL客户端执行一条SQL查一下号源状态变成了1排班剩余数减了1这种“代码和数据库联动”的演示最能证明系统是真实可用的比单纯点页面有说服力得多。这套系统做完回头看整个开发过程最值得沉淀的不是某个炫酷技术而是对业务状态和数据一致性的把握。一点建议给正在做类似项目的同学先把状态流转图画清楚再写代码先设计好表关系和索引再实现功能。有了这两样编码只是体力活不会反复推倒重来。