简介:医院智能挂号系统是融合人工智能与自然语言处理的医疗信息化方案,面向系统开发人员、医院信息化建设者及软件工程专业学习者,针对预约挂号流程繁琐、医疗资源分配不均等痛点,提供了从需求分析、系统设计到实现细节的完整参考。资源包共1个文件,为PDF格式全文,大小仅1.88MB,便于下载后快速阅读与检索。该论文以《福建电脑》2020年刊载文章为底本,作者来自上海电机学院,内容涵盖手动选科、语音输入、图形挂科、症状科普、看病笔记及流程引导等功能模块,并介绍了小程序前端、腾讯云MySQL数据库、阿里云服务器JavaEE后端的三层架构。文中还讨论了自然语言理解、语音识别、高并发处理、数据库安全等关键技术挑战,以及与医院信息系统和支付平台的集成方案。目前已有99人学习,适合对智能就医系统开发或医疗信息化方向感兴趣的读者,可作为课题设计、毕业设计或工程实践的参考文献与专业指导。
1. 医院智能挂号系统:从科室排长队到源码落地的关键一跳
医院智能挂号系统的设计和实现,看着像一套普通的预约管理系统,可真要让它在门诊高峰期顶住几百号人同时抢号,问题就全暴露了:号源超卖、重复退号、叫号屏不同步、凌晨放号时数据库连接被打满。这些东西在单机开发环境里根本测不出来,恰恰是答辩和交付验收最容易被追问的细节。这篇内容写给两类人:准备拿它做毕业设计或课程设计的学生,以及需要独立交付一个小型医疗信息系统的工程师。我会从业务建模开始,一路拆到表结构、并发扣减、状态机、WebSocket叫号、排班生成和压测验证,把每一个能落地的方案连同参数一起讲清楚。
2. 业务流程与数据建模:写代码前先把挂号这件小事拆成四张表
2.1 角色权限与状态流转:患者、医生、挂号员各自能看到什么
医院挂号系统最容易被忽视的是「状态」这件事。患者在线预约、到院取号、分诊台签到、医生叫号、诊疗结束、复诊转科,每一步都在改变同一份数据的生命周期。如果一开始没定清楚状态机,后面每加一个功能就要改一遍判断逻辑,代码很快就烂掉。
我一般先画一版角色清单:患者负责预约、取消、取号、查看排队进度;医生负责出诊签到、呼叫下一位、结束就诊;挂号员负责现场建卡、手动挂号和退号处理;管理员负责排班、号源总量和科室信息维护。每个角色对应一套接口白名单,不需要复杂的RBAC框架,Spring Security的注解就能撑住,重点是把表结构里的角色字段规划清楚。
就诊状态建议用数字字典表管理,而不是写死在代码里。常见做法是:0已预约、1已取号、2候诊中、3就诊中、4已完成、5已取消、6已退号。注意「已取消」和「已退号」必须区分开,取消是未取号前患者自己操作,退号是取号后手续费处理流程,二者在资金和号源回滚逻辑上完全不一样。
状态流转方向也要提前定死:已预约只能走向已取号或已取消;已取号可以走向候诊中,也可以由挂号员操作走向已退号;候诊中一旦变成就诊中,就不能再退号,只能由医生结束。这个约束会在状态机代码里用枚举硬编码,避免出现「患者都看完了还能退号」这种答辩翻车现场。
2.2 核心表结构设计:排班表、号源表、预约单表、就诊记录表怎么建
挂号系统的核心数据模型其实就四张表,其余都是围绕它们做扩展。第一张是医生排班表,记录某位医生在某个时间段内坐诊;第二张是号源表,由排班生成,每个时段生成固定数量号源;第三张是预约单表,患者一次预约对应一条记录;第四张是就诊记录表,患者到院后从预约单衍生出来,承载医生诊断和状态流转。
先看排班表,字段建议做成这样:
CREATE TABLE doctor_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL COMMENT '医生ID', dept_id BIGINT NOT NULL COMMENT '科室ID', schedule_date DATE NOT NULL COMMENT '坐诊日期', start_time TIME NOT NULL COMMENT '开始时段', end_time TIME NOT NULL COMMENT '结束时段', total_slots INT NOT NULL DEFAULT 20 COMMENT '号源总量', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已发布 2已停诊', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_doctor_date (doctor_id, schedule_date, start_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生排班表';唯一索引一定要加在「医生+日期+开始时段」上,否则管理员在界面上多点两下就会生成重复排班。这个坑我踩过,当时线上出现同一个医生同一时间段两条排班,患者挂到号去诊室一看,医生根本没出诊。
号源表的设计决定了并发能力。不要把号源总量写死在排班表里,而是把每个时段的具体号位拆成独立行,这样扣号时只需要更新一行数据:
CREATE TABLE number_source ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, slot_no INT NOT NULL COMMENT '第几号', source_status TINYINT NOT NULL DEFAULT 0 COMMENT '0空闲 1锁定 2已占用', patient_id BIGINT DEFAULT NULL, version INT DEFAULT 0 COMMENT '乐观锁版本号', UNIQUE KEY uk_slot (schedule_id, slot_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='号源表';预约单表负责把患者和号源绑定起来,同时记录来源渠道,便于统计线上和现场挂号比例。就诊记录表则在取号后创建,主键建议直接用预约单号作为业务关联键,避免两张表各自维护一份状态互相矛盾。
2.3 从ER图到Spring Boot工程:一个最小可运行的后端骨架
数据模型确定后,后端骨架不需要一上来就堆微服务,单应用加MySQL加Redis足够支撑中小型医院门急诊流量。常见做法是新建一个Spring Boot工程,按controller、service、mapper、entity分层,包名按业务模块拆,不建议按技术类型拆成common、utils包堆一堆不相关代码。
下面这个依赖清单是我在这个项目里常用的最小集合:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>Redis在这里承担号源预扣和WebSocket心跳状态存储,MySQL负责所有持久化数据。application.yml里有一个关键参数我每次都会强调——数据库连接池的大小:
spring: datasource: url: jdbc:mysql://localhost:3306/hospital?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root hikari: maximum-pool-size: 20 minimum-idle: 5 redis: host: localhost port: 6379 timeout: 2000ms连接池最大20并不是拍脑袋定的,需要根据压测结果调整。如果高峰期数据库连接池被占满,应用日志里会出现大量的Connection is not available报错,那个时刻系统基本就瘫了。后面压测章节我会专门讲怎么调这个值。
3. 用Redis和状态机实现预约、退号与叫号:防超卖与幂等是关键
3.1 号源预扣:Redis原子操作挡住并发超卖
号源超卖是挂号系统里最经典的并发问题。场景是这样的:某个号源量还剩1个,两个患者同时在各自手机上点击提交,如果代码写成先查剩余量再判断再扣减,两个请求都看到剩余1个,就都通过了校验,最后同一号位被绑给两个人。
解决超卖最可靠的做法是借助Redis的单线程原子特性,把「检查号码和扣减号码」作为一个原子操作执行。我一般用Lua脚本把整个操作写进Redis服务端,脚本执行期间不会被其他命令打断:
local key = KEYS[1] local patientId = ARGV[1] local current = redis.call('HGET', key, 'remain') if tonumber(current) <= 0 then return 0 end redis.call('HSET', key, 'remain', tonumber(current) - 1) redis.call('SADD', key .. ':pending', patientId) return 1这段Lua脚本返回0表示没号了,返回1表示占号成功。占号成功后,再异步把预约单落库。这里有一个边界情况需要重点处理:如果Redis占号成功,但MySQL写入失败,号就被白占了。常见做法是同时把patientId写进一个待确认集合,由定时任务扫描这个集合,超过两分钟没有生成预约单的,执行号源回滚。
注意RN offer:在 Redis 里存放号源余量时,键结构建议用schedule_id做hash key,字段名用remain和pending集合。不要用简单的key-value字符串,因为后续退号回滚还要操作集合,hash结构更容易维护。
3.2 退号回滚:接口幂等性设计避免重复退费
退号和预约一样,也怕并发。患者点了退号,前端超时没收到响应,又点了一次,两次请求如果都执行成功,号是退回来了,但是退款接口也调了两次,这就出大问题。解决重复提交的核心是接口幂等性设计,常见做法是让前端在调用退号接口时携带一个幂等键,通常是一次退号操作只生成一次的requestId。
后端可以在Redis里保存这个requestId,处理前先检查是否存在,存在就直接返回上一次的处理结果,不存在才继续执行业务:
public boolean refund(Long appointmentId, String requestId) { String key = "refund:request:" + requestId; Boolean firstCall = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES); if (Boolean.FALSE.equals(firstCall)) { // 重复请求直接返回成功 return true; } return doRefund(appointmentId); }setIfAbsent方法就是Redis里的SETNX,只有第一次调用能写入成功,后续重复请求都会被挡在外面。这个方案的细节是过期时间必须比业务处理耗时更长,我一般设10分钟,足够覆盖一次完整的数据库事务和退款请求。
退号后的号源回滚也要注意顺序:先更新数据库预约单状态,再恢复Redis号源余量。反过来操作的话,Redis余量先恢复,但数据库事务失败导致预约单还是已预约状态,患者再挂一次号,两个预约单指着同一个号位,又是一个线上事故。
3.3 WebSocket叫号:心跳机制与状态机的联动
叫号系统是挂号体验的最后一公里。医生点击呼叫下一位,诊室外的大屏要立刻显示号码,患者手机端也要同步收到推送。轮询接口虽然也能做,但延迟高、请求量大,这里应该用WebSocket做服务端推送。
WebSocket在弱网环境下的稳定性是个痛点,移动端网络切换时连接经常静默断开,服务端还认为连接活着,消息推过去就石沉大海了。解决方式是心跳机制:前端每30秒发送一个ping帧,服务端收到后回pong,如果超过60秒没收到任何心跳,就判定连接已死,清理连接资源。
服务端用Netty的IdleStateHandler来检测空闲连接:
ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new IdleStateHandler(60, 0, 0)); ch.pipeline().addLast(new WebSocketHandler()); } });IdleStateHandler三个参数分别是读空闲、写空闲、全部空闲的超时秒数,我这里设了60秒读空闲检测,表示60秒没收到客户端任何数据就触发事件。后端在IdleState事件里关闭连接,前端同时监听onclose事件,在关闭后立即尝试重连,这样断线恢复时间控制在秒级。
叫号推送的数据结构不复杂,但要在里面带上当前号位、当前诊室号和候诊剩余人数三个字段。剩余人数这一项需要从Redis的queue里查,顺便把叫号状态推进到state_change标记,这样患者端每次收到推送都会刷新排队进度条,整体体验才跟得上。
3.4 就诊状态用状态模式约束:别让if else越写越乱
患者从预约到就诊完成,状态流转如果全用if else判断,很快会变成一堆散落的分支。推荐用状态模式来约束流转,这也是这个项目里最能体现设计模式价值的落点。定义一个State接口,每个状态一个实现类,每个实现类只负责自己的行为转移:
public interface VisitState { VisitState takeNumber(); VisitState cancel(); VisitState waitInLine(); VisitState startDiagnosis(); VisitState finish(); VisitState refund(); }已预约状态对应预约State类,它允许调用takeNumber进入已取号状态,也允许调用cancel进入已取消状态,但调用refund会抛出非法操作异常。这样写的好处是,新来的同事改代码时不需要全局搜索状态判断逻辑,每个状态只有几行代码,规则一目了然。
状态对象不建议存在单例里,因为单例是共享的,状态流转中如果持有患者上下文数据,会串数据。我一般用新建对象的方式创建状态实例,配合StateContext上下文保存当前状态和业务数据,每次流转通过context.setState方法切换。
4. 管理端排班与统计报表:从Excel手动排班到自动化生成的落地
4.1 排班生成算法:按科室规则填充坐诊时段
管理端最容易被当成「增删改查」的部分是排班,但实际做起来,排班规则复杂得很。有的科室固定每周二、四有专家门诊,有的医生一天只看半天,有的号源总量还要按职称区分。把这些规则全做成硬编码没什么弹性,合理的方案是把规则拆成两张配置表,一张配置医生和日期规则,一张配置时段规则。
生成排班的算法本质上是一个集合填充问题。给出医生ID、生效日期段、每周几坐诊、每个坐诊日时段数,循环生成每天的排班记录,再根据号源总量批量生成号源行。下面是排班生成的核心步骤:
public List<Long> generateSchedule(Long doctorId, LocalDate startDate, LocalDate endDate, List<Integer> weekdays, int slotsPerDay) { List<Long> scheduleIds = new ArrayList<>(); LocalDate cursor = startDate; while (!cursor.isAfter(endDate)) { if (weekdays.contains(cursor.getDayOfWeek().getValue())) { Long scheduleId = insertSchedule(doctorId, cursor, slotsPerDay); batchInsertSlots(scheduleId, slotsPerDay); scheduleIds.add(scheduleId); } cursor = cursor.plusDays(1); } return scheduleIds; }生成排班后要在同一次事务里创建号源数据,避免出现排班存在但号源为空的半成品状态。号源批量插入时注意单次插入数量,按每个时段最多50个号源算,一个月一档排班最多生成1500行,MyBatis批量插入一条SQL就能搞定。
排班发布前还需要做冲突检测。同一个医生在同一天不同时段可以坐诊两段,但时段不能重叠。实现上可以在插入SQL里利用之前说的唯一索引,插入失败就捕获DuplicateKeyException,提示管理员该时段已存在排班。
4.2 报表统计:SQL聚合与数据口径的统一
管理端报表的难点不是SQL写不出来,而是统计口径对不齐。挂号量到底算预约成功数量还是就诊完成数量?退号率的分母是当天号源总量还是当天预约量?这些问题如果不定义清楚,技术侧每做一版报表就会被业务方追着改。
我一般第一时间先出一张口径说明表,再按口径写SQL。挂号量取预约单创建成功数,按预约日期统计;就诊量取就诊记录里状态为已完成的数量,按实际就诊日期统计;爽约数取已预约但未取号且超过就诊时间的记录。口径定了,后面的聚合查询才有意义。
统计报表的SQL其实不复杂,下面这条是查某一天各科室挂号量的核心语句:
SELECT d.dept_name, COUNT(a.id) AS appointment_count, SUM(CASE WHEN a.visit_status = 4 THEN 1 ELSE 0 END) AS finished_count FROM appointment a JOIN doctor_schedule ds ON a.schedule_id = ds.id JOIN department d ON ds.dept_id = d.id WHERE ds.schedule_date = #{queryDate} GROUP BY d.dept_name ORDER BY appointment_count DESC;注意JOIN的字段最好都走索引:schedule_date本身是排班表的普通索引,dept_id是主键。数据量超过50万行时,这类聚合查询建议限制在当日数据范围内,不要全表扫描做统计。如果医院要求月底汇总报表,再考虑建一张按日预聚合的统计表,每天凌晨定时任务把前一天数据汇总进去,月底直接查汇总表,速度会快很多。
报表模块里另一个常见需求是退号原因分析。退号原因得在退号操作那一层就做结构化记录,不能靠患者填文本备注。建议退号接口接收一个原因编码参数,比如1时间冲突、2医生停诊、3误挂科室,统计时直接分组即可。
4.3 缓存与数据库一致性:号源数据别让Redis和MySQL打架
Redis存了号源余量,MySQL存了号源明细行,这两处数据在退号、锁号时都可能被修改,如果同步不及时就会打架。最典型的场景是Redis显示还有5个号,MySQL剩余可挂号量只剩3个,患者挂成功了,数据库却写不进去。
解决这个问题的关键是把Redis当成调度前台,MySQL当成最终账本。预约时Redis先扣,数据库后写;退号时数据库先改状态,Redis后恢复;定时任务兜底对账。对账脚本每五分钟扫描一次预约单表,把超过3分钟未完成的预占请求找出来,回滚Redis余量。这个兜底逻辑能缓解大半缓存不一致问题。
延迟双删是另一个保底措施。更新MySQL排班数据后,先删除Redis缓存,等500毫秒再次删除,避免请求在第一次删除后把旧值重新写入缓存。这个500毫秒的间隔是根据业务请求完成时间估算的,设置太短则第二次删除太早,没意义;设置太长则中间有一段脏读窗口。500毫秒在大多数门诊场景是可接受的。
5. 医院智能挂号系统避坑清单:5个高频翻车点的排查记录
5.1 现象:凌晨放号瞬间数据库连接被打满
零点准时放号是很多医院挂号的硬规则。放号后一分钟内,数据库连接池会被预约请求瞬间打满,用户端看到的就是转圈和报错。排查日志时能看到HikariPool超时,数据库端大量线程处于Sleep状态但连接就是释放不掉。
原因有两层:一是号源余量查询走了Redis,但创建预约单时所有请求都去数据库做INSERT,连接池20个连接撑不住瞬间并发;二是业务代码里事务范围偏大,把HTTP请求处理、第三方接口调用都包在一个事务里,连接占用时间过长。
解决方法是把数据库操作压缩到最小事务,只保留INSERT预约单和UPDATE号源状态两个操作;同时把预约请求改成异步入队,前端拿到排队ID,后端由消费者线程池批量落库。MySQL连接池20个不够时,可以先通过Redis挡掉大量无效请求,不在数据库层面硬扛。
5.2 现象:患者退号后号源忽多忽少
线上出现过患者退号后,用户端显示可挂号数增加了,但再次挂号却提示号源不足。检查Redis余量已经恢复,但号源明细表里没有空闲号位可绑定。
原因在于退号流程里先删除了预约单与号源表的关联,再恢复Redis余量,但删除操作走的是逻辑删除,号源行里的source_status仍然是已占用状态。重新挂号时系统只查找source_status为0的号源行,找不到,于是报错。
解决方法是退号时一定要把号源行的source_status改回0,释放patient_id字段。这个更新和预约单状态修改要在同一个数据库事务里,任何一步失败都回滚,绝不出现Redis已经加号,数据库还没释放号位的情况。
5.3 现象:WebSocket连接在弱网下频繁断开
住院楼和门诊楼中间隔了两堵承重墙,手机信号弱,患者端WebSocket连接隔几分钟就断一次。服务端推叫号消息时,有一部分患者的收不到。
原因是只做了连接建立时的鉴权,没有做连接存活检测,移动网络切换IP导致连接静默死亡时,服务端还维持着一条僵尸连接。消息推过去后TCP层没收到确认,消息被丢弃。
解决措施是两层:前端每30秒发一次心跳包,收到任何消息都重置计时器;服务端用IdleStateHandler检测60秒无读请求的连接并主动关闭。前端onclose事件触发后立即重连,并在重连成功后主动拉一次当前候诊人数,保证状态不丢失。
5.4 现象:医生端看到患者,患者端却还在排队
叫号系统上线后,医生端已经呼叫下一位,诊室外大屏也显示了号码,但患者手机端一直停在「候诊中」,迟迟不更新到叫号状态。
原因是患者端WebSocket连接已经断开,服务器端推送失败后又没有做消息补推。WebSocket是单向连通的,连接断开时服务器往通道写数据静默失败,不会主动告警。
解决方法是推送消息时增加ACK机制。服务端推送叫号消息后,要求客户端在5秒内返回ack标记,没有收到ack就把这条消息写入待推送队列,等客户端重连成功后从队列里拉取未确认消息重新推送。这个机制能挡住大部分弱网场景下的消息丢失。
5.5 现象:统计报表和实际缴费对不上
财务核对时发现门诊收入报表比实际缴费少了十几笔,一查全是爽约退号记录。预约单状态是已取消,但报表统计挂号量时把这部分记录也算进去了,空挂了一笔账。
原因是开始设计统计口径时没有区分预约成功和实际就诊,后台报表SQL直接统计了预约单总数。业务上挂号费只在取号时或就诊前收取,预约时不收钱,因此预约成功不等于产生收入。
解决方法是把「挂号量」指标拆分出来,预约成功数归预约成功数,实际到院就诊数归实际就诊数,退号数单独列一行,报表页面展示时不允许让这些指标直接相加减。数据口径表同步发给财务和运营确认,避免后续再扯皮。
6. 上线前的压测与验证:用JMeter跑出真实负载再让医院用
系统开发完不是直接交付给医院用,至少要先在测试环境用JMeter模拟一轮抢号高峰。我一般会压两类场景:一是放号瞬间的并发预约,二是叫号消息的并发推送。两种场景的负载模型完全不同,前者吃数据库和Redis,后者吃网络连接和内存。
JMeter里配置线程组时,我习惯把线程数压到放号并发的两倍左右,比如目标高峰是200人同时抢号,就开400个线程,每个线程循环3次预约操作。加一个同步定时器模拟同一瞬间发起请求,再用聚合报告观察错误率和响应时间。这个环境里MySQL和Redis都跑在独立机器上,避免本地开发机的资源干扰测试结果。
响应时间没有统一标准,但阈值得先定:预约接口在P95级别不应该超过1秒,失败率低于千分之一,Redis命令的平均耗时控制在5毫秒以内。如果预约接口的响应时间超过2秒,先看数据库慢查询日志,再看连接池等待时间,多半是SQL没有走索引,或者连接池太小导致排队。
上线前还有一件小事容易被忽略:把JWT登录态和验证码的代码过一遍。预约接口必须验证登录态,验证码在前端生成时要做时间戳校验,防止自动化脚本刷号。我吃过一次亏,压测时发现脚本直接把预约接口打穿了,前端风控形同虚设,后来在后端又加了一道对同一患者ID每分钟创建订单数的限制,才把刷号这条路堵住。
这个项目做完给我最大的教训是:医院挂号系统最大的复杂度永远在并发和状态,不在界面美观。功能三个月能写完,但压测、对账、断线重连这些收尾工作占了后面两个月。开发阶段多留一些时间给这些非功能性需求,答辩时反而能讲出比别人更深的细节。希望帮到你。
本文还有配套的精品资源,点击获取