news 2026/10/21 4:40:39

基于SpringBoot+SSM的儿童医院挂号系统设计与并发号源管理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于SpringBoot+SSM的儿童医院挂号系统设计与并发号源管理

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: true

map-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注入它来调用,事务边界就正确了。

修完这两个问题,再压测一次,结果彻底正常了。修复前后的数据对比如下:

压测场景并发线程数实际预约成功数号源最终状态是否超卖
修复前(先查后改)5017负数是
修复前(条件更新+this调用)509负数是
修复后(事务拆分生效)5050否

这个踩坑过程让我深刻意识到:并发问题不能靠“想当然”解决,必须靠压测验证。写代码时觉得“应该没问题”,一上量就会露馅。事务失效是Spring开发中非常经典的问题,面试也爱考,我建议所有做这个项目的同学都亲手复现一遍,比看十遍文档都记得牢。

7. 调试文档与讲解演示:交付这套系统真正费心的地方

项目源码写完之后,最容易被忽视的就是调试文档和演示准备。我在这个过程里踩了不少坑,也总结了一些对后来人有价值的经验。所谓调试文档,不是把代码注释抄一遍,而是让一个完全不了解系统的人,拿到项目后能快速跑起来,并且能复现每个功能场景。

调试文档里必须先写清楚环境版本。Java版本用8还是17、MySQL版本用5.7还是8.0、Maven版本用哪个,这些不写清楚,别人很可能在启动阶段就卡住。我就遇到过项目用JDK8写,演示环境装了JDK17,SpringBoot启动直接报错的情况。建议在文档开头用一个表格列明环境版本,并标注“推荐使用主流的JDK8环境”,因为很多学校机房和老项目都还在用JDK8。

测试数据是调试文档另一个核心部分。我预置了三类数据:科室数据(儿科内科、儿科呼吸科、儿科消化科等)、医生排班数据(覆盖工作日、周末、上午、下午不同场景)、用户数据(家长、医生、管理员三类账号)。测试数据要能达到“每种状态都有记录”的效果,比如预约记录里既有已完成,也有待支付、已取消的,这样演示时可以快速切入任意状态讲解。

接口测试脚本也很重要。我直接用Postman导出了一组JSON文件,按模块分文件夹保存。每个请求都写清楚:接口路径、请求方法、请求参数示例、预期返回结果。特别是预约创建和取消这两个接口,参数里带上真实存在的排班ID和号源ID,确保别人照着调用就能成功。很多人调试时喜欢自己临时测试,但重新造数据又慢又容易出错,不如把现成接口用例交付出去。

演示前的流程也要过一遍。我一般会按这个顺序演示:管理员登录维护排班,生成号源;切换到医生账号查看排班;再切换到家长账号查询号源、创建预约、取消预约。中间穿插展示数据库变化。比如创建预约后,随手打开MySQL客户端执行一条SQL,查一下号源状态变成了1,排班剩余数减了1,这种“代码和数据库联动”的演示最能证明系统是真实可用的,比单纯点页面有说服力得多。

这套系统做完回头看,整个开发过程最值得沉淀的不是某个炫酷技术,而是对业务状态和数据一致性的把握。一点建议给正在做类似项目的同学:先把状态流转图画清楚,再写代码;先设计好表关系和索引,再实现功能。有了这两样,编码只是体力活,不会反复推倒重来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 4:39:55

FAIRChem 上手指南:用 UMA 机器学习势把催化剂 DFT 筛选提速千倍

FAIRChem 上手指南:用 UMA 机器学习势把催化剂 DFT 筛选提速千倍 【免费下载链接】ocp FAIR Chemistrys library of machine learning methods for chemistry 项目地址: https://gitcode.com/GitHub_Trending/oc/ocp FAIRChem 是 Meta FAIR Chemistry 团队的化学机器学…

作者头像 李华
网站建设 2026/10/21 4:39:21

如何系统化管理个人技能:从标签、等级到证据链的完整实操指南

1. 从“skills”这个标题说起&#xff1a;一个被严重低估的硬核话题“skills”这个词&#xff0c;乍一看平平无奇&#xff0c;甚至有点太泛了。但如果你在技术社区、职场分享或者个人成长类内容里泡久了&#xff0c;就会发现一个很有意思的现象&#xff1a;越是这种看似简单的词…

作者头像 李华
网站建设 2026/10/21 4:38:29

基于Java Web的动物疾病诊断系统设计与实现:从规则匹配到加权评分

做毕业设计或者课程设计的时候&#xff0c;最怕碰到哪种题目&#xff1f;不是那种框架一搭就结束的管理系统&#xff0c;也不是纯算法能蒙混过关的“数据挖掘类”。最怕的是那种“看着像管理系统&#xff0c;但仔细一想里面还藏着一条业务逻辑线”的题目。“基于Java Web的动物…

作者头像 李华
网站建设 2026/10/21 4:38:03

TCP可靠吗?网络层可达才是真正前提

很多做开发的同事一听到 TCP 就下意识觉得“这是个可靠协议”&#xff0c;仿佛只要用了 TCP&#xff0c;数据就一定能毫发无损地从 A 端到达 B 端。我刚转岗到网络组那阵子也这么想&#xff0c;直到有一天在现网抓包时被老工程师问了一句&#xff1a;“你把这个 TCP 包发出去&a…

作者头像 李华
网站建设 2026/10/21 4:38:00

多智能体GraphRAG性能优化:从4.8秒到1.2秒的架构实践

大半年里&#xff0c;我前后搭了三版多智能体 GraphRAG 系统&#xff0c;终于把线上问答的平均首字延迟从 4.8 秒压到 1.2 秒以内&#xff0c;图谱召回率也稳在了 91% 上下。这个项目本身不算新&#xff0c;真正恶心的是“可控”和“可扩展”这两件事放在一起时暴露出来的连锁瓶…

作者头像 李华
网站建设 2026/10/21 4:37:05

2026年交通大模型落地指南:从路况预测到信号控制的全场景实践

2026年再聊交通大模型&#xff0c;如果还停留在“把大模型接进交通系统做个问答机器人”的层面&#xff0c;多少有点浪费这个方向了。我这两年带着iTSTech这套体系在几个不同类型的城市做场景验证&#xff0c;最明显的体感是&#xff1a;大模型正在重塑交通行业处理信息的完整链…

作者头像 李华