☰
基于JAVA的医院门诊系统:从CRUD到高并发与状态机设计
2026/9/30 9:21:22 网站建设 项目流程

做门诊管理系统这类项目的人我见得太多了,十个里有八个是把增删改查套个页面就交差,美其名曰"管理系统"。但你要是真把这套东西拿去做课程设计答辩,或者写进简历里,稍微被追问几个问题就会露馅:号源扣减并发怎么办?医生和收费员看到的数据为什么不一样?挂号退号的状态怎么流转?我当初做这个选题的时候也吃过亏,重新做了第二版才勉强像个能用的系统。这篇就把我做"基于JAVA的医院门诊信息管理系统"的完整思路和踩坑过程拆开讲,重点不是界面多好看,而是数据怎么流转、并发怎么处理、代码怎么分层。适合正在做Java课程设计、毕业设计,或者想从"会写CRUD"进阶到"会设计业务系统"的同学参考。

1. 这个项目最容易被做成"增删改查堆砌",先弄清门诊的核心链路

先说一个扎心的事实:大部分课程设计版本的"医院门诊管理系统",功能菜单长得都差不多——患者管理、医生管理、挂号管理、收费管理。打开数据库一看,每张表都是孤立的,患者表和挂号表之间没有外键关系,挂号单状态全靠一个int字段凭感觉填。这样做的结果就是,代码能跑通Demo,但业务流程完全经不起推敲。

我第二版动手之前,先画了一遍真实门诊的流程,整个系统的核心链路其实只有一条主线:

患者建档 -> 选择科室和医生 -> 挂号(扣号源) -> 候诊 -> 医生叫号 -> 就诊(医生开处方) -> 患者缴费 -> 药房发药

这条链路上的每一步,都会产生一个业务动作,而每个业务动作都会改变某些数据的状态。比如挂号这个动作,它不只是往挂号表里插一条记录,还要同时把对应医生的号源数减一,把号源状态从"可预约"改成"已占用"。如果代码只写了"insert into 挂号表",那你做的就不是门诊系统,是个登记表。

围绕这条链路,系统里至少要有这几类角色配合:患者(或被监护人代办)、挂号员/前台、医生、收费员、药房药师、系统管理员。每一个角色关注的界面和数据维度都不同,这就引出了第二个关键设计——权限模型。我见过很多项目把"权限"做成了登录后跳转不同首页来糊弄,那是不对的,后面我会单独讲权限这块怎么做才算及格。

我在这条链路上吃过最大的亏是:第一版没做号源表,直接在doctor表里加一个"剩余号数"字段。表面看也没问题,挂号时减一就行。但一旦涉及"按日期排班",问题就来了——同一个医生周一和周三的号源应该是分开计算的,你只有一个total字段,周一挂完了周二怎么办?所以后来我把号源拆出来单独建表,按doctor_id + work_date维度记录,这才是门诊系统的正常做法。细节后面展开。

2. 数据库设计:把业务规则落成表和状态流转

如果你的数据库表设计对了,业务流程基本就通了一半。我这张系统的表不算多,但每个字段背后都有讲究,挑几个核心的说。

2.1 患者表与挂号单表:你的"主键"设计真的合理吗

患者表是最容易被想简单的。很多同学直接拿身份证号当主键,然后发现同一个患者在自助机和窗口各建了一次档案。更合理的做法是单独设自增id作为主键,身份证号只做唯一索引,姓名、性别、年龄、联系电话这些基础信息。建一个患者表看起来简单,但你要考虑到家族成员共享账户的情况——一个人给全家挂号,所以患者表里有个字段叫"监护人id"挺实用,默认自己就是监护人。

挂号单表是整个系统的灵魂,这张表的字段设计直接决定你的业务逻辑能走多远,我列一下关键字段:

  • id:挂号单号
  • patient_id:患者id(关联患者表)
  • doctor_schedule_id:排班id(关联号源表,而不是直接关联医生id)
  • visit_date和visit_time_slot:就诊日期、午别/时段,这两个字段要和排班对应
  • status:状态机字段——待就诊、已就诊、已取消、已退号、已完成
  • fee_status:费用状态——未收费、已收费
  • create_time和operator_id:记录谁在什么时间挂的这个号,方便回头对账

给state设一个int字段是最省事的做法,但有个严重问题——时间一长你根本记不住0代表什么、1代表什么。我建议设计一个status_desc字段做冗余显示,同时用常量类把状态统一管理起来,见后面的代码分层部分。

2.2 状态流转:用状态机思路替代"if else散弹式修改"

挂号单的status字段不是随便set的,它有严格的流转规则。我后面用了一个状态机模式的思路,把所有允许的流转关系集中定义,而不是在每个Controller里手写if (status == 1) status = 2。比如下面这些流转规则:

当前状态允许动作下一个状态
待就诊医生接诊已就诊
待就诊患者取消已取消
待就诊收费后退号(限当日)已退号
已就诊完成缴费已完成
已缴费药房发药已完成(发药完成)

有了这个表,代码里的逻辑就会变得非常清晰。我是在RegistrationService里写了一个状态流转校验方法,每次更新状态前先查一遍"这个状态能不能直接变成目标状态",不能就直接抛业务异常。

2.3 处方、药品、收费:三张表联动但各有负责人

医生开完处方,不是直接改库存的。处方表记录"医生开了什么药、开多少量",收费表记录"患者交了多少钱、什么方式交的"。药房发药的时候,才去扣减药品库存。这样做的好处是职责边界清晰:医生只负责开处方,收费员只负责收钱,药师只负责核对发药。任何环节出问题,都能从各自的表里查出来,而不是一笔糊涂账。

有一点我建议在数据库层面就约束好:处方表的费用总额应该等于处方明细里所有药品价格的数量加总。这个可以在插入明细后用SQL的SUM汇总,也可以在应用层计算后再写入。我两版都试过,最终选择在应用层计算并加一个数据库触发器做兜底,因为数据库触发器写太多东西后续维护会很痛苦。

顺带提醒一个新手容易踩的坑:药品表和药品库存表要分开。药品表存的是药品的静态信息(名称、规格、单价、生产厂家),库存表存储的是每个药品的实时剩余量。我看到不少项目把库存数量直接写在药品表里,出货时做减法——短期没问题,一旦你后面做药品入库批次管理,就会发现这个设计根本没法扩展。

2.4 号源与排班的数据库落法

号源表的核心字段是:doctor_id、work_date、time_slot(如"上午8:00-12:00")、total_slots(总号数)、booked_slots(已约号数)、version(乐观锁版本号,这个字段非常关键)。每个医生每天会有多条排班记录,对应不同时段。患者挂号时,就是针对某一条排班记录做预约或直接锁定。

这里有个背后的考量:为什么排班信息不直接写在挂号单表里?因为排班是一个独立的业务概念,它要在放号之前就生成好。你需要单独做一个"排班生成"功能,比如每周提前给全院医生生成下周一整周的号表,每个时段默认10个号。没有这个生成动作,挂号流程就无从谈起。

3. 并发挂号时的号源扣减:数据一致性比想象中麻烦

前面一直在说正常流程,现在来聊真正值得写到简历里的部分——并发。很多系统在单用户测试的时候一切正常,第一天上线就被真实流量教做人了。门诊挂号系统里最典型的并发场景是:某位专家号早上8点放号,100个患者同时冲进来抢20个号。如果代码是这么写的:

int booked = getBookedSlots(scheduleId); if (booked >= 20) { throw new BusinessException("号已挂完"); } schedule.setBookedSlots(booked + 1); updateSchedule(schedule);

这段代码在并发下必然出错。两个线程同时读到booked=19,都判断"还没满20",然后各自+1,最终数据库里booked变成20,但实际上挂了21个人出去。这种情况course design里不会被发现,但真实环境里就是事故。

解决思路要从数据库层面下手,我给你三种方案,复杂度递增,按实际需求取舍:

方案一:数据库行锁(悲观锁)

SELECT * FROM doctor_schedule WHERE id = #{id} FOR UPDATE;

在事务里先锁定这行排班记录,其他事务只能排队等,直到当前事务提交。这个方案实现最简单,正确性最高,代价是并发吞吐量会下降,但对于门诊挂号这个场景(每秒并发几十次已经是很多的了)完全够用。

方案二:乐观锁(版本号控制)在排班表上加一个version字段,执行更新时带上版本条件:

UPDATE doctor_schedule SET booked_slots = booked_slots + 1, version = version + 1 WHERE id = #{id} AND booked_slots < #{totalSlots} AND version = #{oldVersion}

如果更新的行数为0,说明有别人先改了或者号满了,重试或者报错。这个方案并发性能更好,不需要锁表,但实现上需要处理重试逻辑。

方案三:Redis预扣减先把号源数据加载到Redis,挂号时用DECR原子操作扣减,扣到负数说明满号。这个方案吞吐量最高,但在课程设计阶段我不太推荐——因为你需要额外引入Redis依赖,还要处理缓存与数据库的一致性,对项目复杂度是几何级提升。

我自己第二版用的方案一是行锁,第三版升级成方案二。如果只是做课程设计,方案一就足够拿高分了,关键是你要能在答辩中说出"为什么选这个方案、并发场景下是什么效果"。

题外话:挂了号之后,如果患者退号,号源要怎么还回去?这里有个细节,退号不能直接把booked_slots减一就完事,要考虑退号时间与候补队列的逻辑。我这个系统的简化处理是:退号后号源释放,排班表上的booked_slots减一;如果有"候补"状态的患者,可以按排队顺序通知候补。但候补通知这块做起来比较费工夫,课程设计阶段不做也不影响完整性。

4. 代码该有的分层:从Controller直达DAO是怎么变成维护噩梦的

我见过太多课程设计项目,一个Controller两三千行,里面又是参数校验、又是业务计算、又是拼SQL。短时间内确实爽,因为不用来回跳文件,但当你需要改一个业务规则,比如"挂号费从10块钱涨到12块钱",你得在所有写死这个数字的地方挨个改。那种痛苦,经历过一次就够了。

4.1 从Service层开始,而不是从Controller层开始

我的习惯是:先画好数据表,再写Service接口,最后才写Controller。Service层承载的是业务规则,Controller层只做参数接收和结果回传。对比一下两种写法:

// 错误示范:Controller里直接写业务规则 @PostMapping("/register") public Result register(@RequestBody RegisterRequest req) { DoctorSchedule schedule = doctorScheduleDao.selectById(req.getScheduleId()); if (schedule.getBookedSlots() >= schedule.getTotalSlots()) { return Result.error("号已挂满"); } schedule.setBookedSlots(schedule.getBookedSlots() + 1); doctorScheduleDao.updateById(schedule); registrationDao.insert(req); return Result.success(); }
// 正确示范:Service层封装业务 @Service public class RegistrationService { @Transactional(rollbackFor = Exception.class) public RegistrationResult register(RegisterRequest req) { // 1. 锁排班 DoctorSchedule schedule = doctorScheduleDao.selectByIdForUpdate(req.getScheduleId()); // 2. 校验号源 if (schedule.getBookedSlots() >= schedule.getTotalSlots()) { throw new BusinessException("号已挂满"); } // 3. 扣减号源 + 插入挂号记录,两个操作必须同事务 schedule.setBookedSlots(schedule.getBookedSlots() + 1); doctorScheduleDao.updateById(schedule); registrationDao.insert(...); // 4. 返回挂号结果 } }

你看,Controller只需要调用registrationService.register(req),所有规则都封装在服务里,其他模块(比如小程序端)也可以复用同一个Service方法。这就是分层的价值。

4.2 事务边界:哪些操作必须在同一个事务里

门诊系统里多表关联写入非常频繁,事务边界没划好,数据就乱了。我总结了三类必须同一事务的操作组合:

  • 挂号:扣减号源 + 插入挂号记录 + 记录操作日志(谁说日志不用进事务?这里建议进,因为日志跟业务强绑定)
  • 缴费:生成收费记录 + 更新挂号单fee_status + (如果涉及优惠)更新优惠记录
  • 退号:归还号源 + 更新挂号单status + 生成退号记录

@Transactional(rollbackFor = Exception.class)这条注解一定要写,不是可选的。注意默认情况下Spring事务只对RuntimeException回滚,你对自定义的业务异常如果不指定rollbackFor,事务是不会回滚的——这是个非常隐蔽的坑。我当时上线第一版就吃过这个亏:修改数据时抛了个Exception,结果事务没回滚,数据写了一半。从那时起,我给自己定了个铁律:所有事务方法统一写好rollbackFor。

4.3 面向对象不是加分项而是基本功

热词里经常能看到"面向对象编程java",很多人觉得这是面试题不是实际开发。但你看看门诊系统里的"实体",天然就适合用面向对象来建模。比如挂号单有状态,不同的状态对"取消"这个动作的响应不一样——待就诊的挂号单可以直接取消,已就诊的挂号单不能取消,已收费的挂号单取消时要多走一个退费流程。如果这些逻辑堆在Service层里面,代码会长这样:

if (order.getStatus() == 1) { cancelOrder(order); } else if (order.getStatus() == 3) { cancelAndRefund(order); } else { throw new BusinessException("当前状态不可取消"); }

这样写也不算错,但如果状态多了,这个if会越来越长,最后变成可怕的else if瀑布。更面向对象一点的做法,可以把"状态"本身设计成一个对象,把"取消"行为放进去:

public abstract class RegistrationState { public abstract void cancel(RegistrationOrder order); public abstract void complete(RegistrationOrder order); } public class PendingState extends RegistrationState { public void cancel(RegistrationOrder order) { // 待就诊状态取消逻辑,直接置为已取消,归还号源 } } public class PaidState extends RegistrationState { public void cancel(RegistrationOrder order) { // 已缴费状态取消逻辑,先走退费流程 } }

课程设计做到这个粒度,已经超过大多数同龄项目了。当然这不是说让你把简单问题复杂化,如果只有两三个状态且变动不频繁,if else完全够用。判断标准是:这个状态模型未来会不会扩展、当前系统有多少处地方需要根据状态走不同逻辑。门诊系统里挂号单、处方单、收费单都有状态,状态多了以后策略加状态模式明显更省心。

5. 权限与角色:门诊系统的访问控制不是跳转页面糊弄

门诊系统的角色天然是分开的:医生和收费员不应该看到同一套界面,更不应该能调用同一个接口。你要是把"权限"做成登录后redirect到不同url,那也就瞒过课程设计指导老师。真正能拿得出手的权限方案是RBAC模型——用户归属角色、角色归属权限、用户通过角色间接获得权限。

简化版本只需要三张表:用户表、角色表、用户角色关联表。权限点我建议直接做法到菜单,也就是说角色和菜单关联。再往细里做是操作权限(按钮级权限),课程设计里做到菜单这个粒度已经很好。

5.1 拦截器还是Spring Security

课程设计项目我建议用拦截器(HandlerInterceptor)+自定义注解来做权限控制。为什么不用Spring Security?因为Spring Security学习曲线陡峭,配置复杂,如果没吃透,出问题你都不知道去哪排查。拦截器在面试时反而更能体现"我知道权限控制的核心原理,而且能自己实现"。

实现思路是:

  • 自定义注解@RequireRole({"doctor", "admin"})
  • 写一个拦截器,在preHandle里取当前登录用户,查用户的角色列表,再看访问的方法有没有加这个注解,没权限就直接返回403
  • 菜单显示根据用户角色动态加载,后端接口再用注解做二次拦截

前端可以不控制按钮级权限,但后端接口必须做。因为接口是可以直接被调用的,前端藏起来不等于后端不存在。你可以登录一个收费员账号,直接请求医生的开处方接口,如果后端不拦,这个系统就是裸奔的。

5.2 一个典型的数据库表设计

这里给一个最小可用的权限表设计:

  • sys_user:id, username, password(必须加密存储), real_name, role_id
  • sys_role:id, role_name, role_code(如"doctor"、"cashier"、"admin")
  • sys_menu:id, menu_name, parent_id, url, order_num
  • sys_role_menu:role_id, menu_id

登录时查出用户的角色,再通过role_menu关联查出他能访问的菜单列表,存到session或生成token返回给前端。每次请求时,拦截器根据请求的url去匹配菜单表,如果该url不在用户角色可见的菜单范围里,直接拒绝。这就是一个很干净的RBAC闭环。

密码加密这事得单独提醒一下:千万别把用户密码明文存在数据库里。用BCrypt加密,这是最低要求了。虽然没有要求做密码找回功能,但设计时要留出来,否则后面要加就很头疼。我见过课程设计里真的有人把密码写成明文"123456"存进去,答辩的时候被老师一眼看穿,那场面挺尴尬的。

6. 课程设计答辩前,把这些最容易被追问的细节补上

如果你这学期就要交这个项目,或者说做完了想再打磨打磨,有几个地方我建议重点看,都是答辩时高频考点。

6.1 挂号高峰的性能怎么兜底

前面讲的并发扣号是一方面,性能兜底要从入口就做。我在挂号接口入口做了一层简单限流——基于Guava的RateLimiter,每秒钟最多处理N个挂号请求,超出直接返回"系统繁忙,请稍后重试"。你用信号量或者Redis计数器做都行,关键要让老师看到你有这个意识。不用做得很复杂,能讲清楚"为什么要限流、怎么限流、限流了之后那些被丢弃的请求用户怎么感知"就够了。

6.2 列表查询的性能:别忽略索引

门诊系统最频繁的查询是"某天某个科室有哪些医生的号可以挂",这个查询会join三个表——科室表、医生表、排班表。如果数据量一大(比如全市三甲医院级别的数据),没有合理索引,这个查询会非常慢。我建索引的原则是:where条件里的字段优先建,order by字段有条件也建。排班表核心索引是(doctor_id, work_date, time_slot)联合索引,挂号表的核心索引是(patient_id, visit_date)和(schedule_id, status, fee_status)。

关于MySQL索引,有一个比较典型的误区:不是索引建得越多越好。每个索引在写入时都要维护B+树,索引多了写入就慢了。用药房库存这种写多读少的业务,索引反而要克制。做的时候得学会用EXPLAIN查看执行计划,看到type = ALL就要警惕是不是全表扫描了。

6.3 日志留痕:出了事你得能说清是谁操作的

在门诊这种涉及钱的系统里,日志不是可选项。收费对不上账、药房发了数量不对的药、挂号单被取消了,这些都是需要追查的。我的做法是做一个通用操作日志表:operator_id、operation_type、target_table、target_id、before_data、after_data、operation_time、ip_address。在Service层的方法里手动记录,或者在数据库层面做触发器。用AOP做比较高级,但对课程设计来说太绕了,手动记录反而更直观,你要真能用AOP做好,那也算是亮点了。

6.4 关于界面的一个现实建议

别把精力花在把界面做得多花哨上,而是把"流程闭环"做清晰。我见过很多界面做得特别好看的项目,流程图却画不出来;也见过界面很朴素但业务闭环完整的项目被评为优秀。门诊系统的核心流程是挂号->就诊->收费->取药,每一步在界面上都应该有明显的入口和状态提示。比如医生端的接诊界面,打开就是当前队列的患者列表,点击"开始就诊"后状态自动变成"就诊中"、结束后"开处方"按钮亮起来。流程闭环了,界面就不丑;流程断着,界面再好看,老师一操作就会发现问题。

7. 一些我踩过的具体坑,和最后想说的话

最后分享几个做这个项目时踩过、令我很深刻的坑,希望能帮各位避雷。

第一个是Java时间字段的时区问题。MySQL里存datetime,Java侧用LocalDateTime接收,正常情况没问题。但如果你部署的服务器时区和数据库时区不一致,会出现"预约8点,到库变成4点"这种诡异现象。解决方案是统一所有环境成UTC+8,并且在JDBC连接串里显式指定serverTimezone=Asia/Shanghai。别小看这个事,我当时排了一个下午才发现是时区导致号源日期全错位了。

第二个是事务里别做远程调用。我第一版在挂号事务里集成了短信通知服务(用了第三方HTTP接口),结果某天短信服务超时,整个挂号事务也跟着回滚了。后来我把短信改成异步发送:先commit挂号事务,再用线程池发短信,即使短信失败也不影响核心挂号流程。这个原则叫"核心路径与旁路逻辑分离",做订单类系统的人都知道,做管理系统的往往忽略。

第三个是状态字段别用String描述,也别用魔法数字。项目里至少用常量类集中管理定义,有条件用枚举类型。Java枚举在处理状态机上尤其顺手,可以把允许流转的规则直接定义在枚举内部,代码可读性会提高一个档次。

第四个是关于"退号"的边界情况。门诊系统里最容易被忽略的是"患者挂号后没来就诊,第二天的号源怎么算?"这里有业务决策——是直接核销,还是保留一段时间。我在系统里做了一个定时任务:每天凌晨把前一天"待就诊"状态且过期的挂号单自动改成"已过期",同时归还号源。你可以在项目里加上这个定时任务,它在答辩时是很加分的点,体现出了你对异常流程的闭环思考。

这个项目做到最后,我最大的体会是:管理系统表面上是一堆CRUD,实际上的难点全在"业务规则的正确落地"。号源怎么扣才不出错、状态怎么流转才不乱、权限怎么控制才不越权、日志怎么留才能追溯,这些才是程序员的本事。技术栈用Spring Boot加MyBatis还是SSH都无所谓,把业务这条线讲清楚,你的项目就已经赢过大半人了。

如果你正在做这个题目,我的建议是:不要急着敲代码,先把第1节里的核心链路画清楚,把第2节的状态流转表写出来,然后从挂号这个入口开始一步一步往后做。做完挂号,再做医生接诊,然后是收费,最后补上药房出库。每一步都确保流程闭环了,再往上面堆功能。这样出来的系统,不管是在课程设计答辩,还是在日后的面试项目介绍里,都能站稳脚跟。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询