1. 儿童医院挂号场景的特殊性:比通用挂号系统多出来的那几层需求
先说我为什么要做这套系统。之前帮某儿童医院信息科做过一次门诊流程调研,发现儿科挂号和其他科室挂号完全是两码事。成人挂号通常是患者自己操作,选科室、选医生、选时段,流程比较直接。儿科则几乎全是家长代办,而且家长对“哪个科室”的判断经常是模糊的——“孩子发烧咳嗽,到底挂儿科内科还是儿科呼吸科?”这个问题每天在导诊台被问几百遍。所以我做这个基于Java+SpringBoot+SSM的儿童医院挂号管理系统时,第一件事不是写代码,而是把儿科门诊的特殊场景拆清楚。
这套系统面向三类使用对象:家长(在线预约、取消预约、查询记录)、医生(查看排班、处理叫号、填写就诊状态)、管理员(维护科室和医生信息、设置排班规则、管理号源参数)。它解决的核心问题是:把过去窗口排队式的挂号流程,变成线上可查询、可预约、可追溯的完整闭环。适合用来学习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依赖service,service依赖mapper,没有人跨层调用。我见过不少项目把SQL写在controller里,当时方便,后面改一个需求要牵连一堆地方,所以这个习惯最好从一开始就建立。
依赖配置方面,核心依赖就几个:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>配置文件中主要关注数据源、MyBatis映射路径、日期格式这几项:
spring: datasource: url: jdbc:mysql://localhost:3306/child_hospital?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 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) { List<NumberSource> 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 Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { ... } public static <T> Result<T> fail(String message) { ... } }业务层是重点。查询可预约列表时,要返回的数据包括:医生姓名、职称、科室名称、排班日期、剩余号数、时段列表。SQL用多表关联查询,一次查出基础信息,时段列表可以单独再查,避免一条大SQL过于复杂。
<select id="findAvailableSchedules" resultType="map"> 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、role(1家长/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注入它来调用,事务边界就正确了。
修完这两个问题,再压测一次,结果彻底正常了。修复前后的数据对比如下:
| 压测场景 | 并发线程数 | 实际预约成功数 | 号源最终状态 | 是否超卖 |
|---|---|---|---|---|
| 修复前(先查后改) | 50 | 17 | 负数 | 是 |
| 修复前(条件更新+this调用) | 50 | 9 | 负数 | 是 |
| 修复后(事务拆分生效) | 50 | 5 | 0 | 否 |
这个踩坑过程让我深刻意识到:并发问题不能靠“想当然”解决,必须靠压测验证。写代码时觉得“应该没问题”,一上量就会露馅。事务失效是Spring开发中非常经典的问题,面试也爱考,我建议所有做这个项目的同学都亲手复现一遍,比看十遍文档都记得牢。
7. 调试文档与讲解演示:交付这套系统真正费心的地方
项目源码写完之后,最容易被忽视的就是调试文档和演示准备。我在这个过程里踩了不少坑,也总结了一些对后来人有价值的经验。所谓调试文档,不是把代码注释抄一遍,而是让一个完全不了解系统的人,拿到项目后能快速跑起来,并且能复现每个功能场景。
调试文档里必须先写清楚环境版本。Java版本用8还是17、MySQL版本用5.7还是8.0、Maven版本用哪个,这些不写清楚,别人很可能在启动阶段就卡住。我就遇到过项目用JDK8写,演示环境装了JDK17,SpringBoot启动直接报错的情况。建议在文档开头用一个表格列明环境版本,并标注“推荐使用主流的JDK8环境”,因为很多学校机房和老项目都还在用JDK8。
测试数据是调试文档另一个核心部分。我预置了三类数据:科室数据(儿科内科、儿科呼吸科、儿科消化科等)、医生排班数据(覆盖工作日、周末、上午、下午不同场景)、用户数据(家长、医生、管理员三类账号)。测试数据要能达到“每种状态都有记录”的效果,比如预约记录里既有已完成,也有待支付、已取消的,这样演示时可以快速切入任意状态讲解。
接口测试脚本也很重要。我直接用Postman导出了一组JSON文件,按模块分文件夹保存。每个请求都写清楚:接口路径、请求方法、请求参数示例、预期返回结果。特别是预约创建和取消这两个接口,参数里带上真实存在的排班ID和号源ID,确保别人照着调用就能成功。很多人调试时喜欢自己临时测试,但重新造数据又慢又容易出错,不如把现成接口用例交付出去。
演示前的流程也要过一遍。我一般会按这个顺序演示:管理员登录维护排班,生成号源;切换到医生账号查看排班;再切换到家长账号查询号源、创建预约、取消预约。中间穿插展示数据库变化。比如创建预约后,随手打开MySQL客户端执行一条SQL,查一下号源状态变成了1,排班剩余数减了1,这种“代码和数据库联动”的演示最能证明系统是真实可用的,比单纯点页面有说服力得多。
这套系统做完回头看,整个开发过程最值得沉淀的不是某个炫酷技术,而是对业务状态和数据一致性的把握。一点建议给正在做类似项目的同学:先把状态流转图画清楚,再写代码;先设计好表关系和索引,再实现功能。有了这两样,编码只是体力活,不会反复推倒重来。