基于JAVA的医院门诊系统:从CRUD到高并发与状态机设计
做门诊管理系统这类项目的人我见得太多了十个里有八个是把增删改查套个页面就交差美其名曰管理系统。但你要是真把这套东西拿去做课程设计答辩或者写进简历里稍微被追问几个问题就会露馅号源扣减并发怎么办医生和收费员看到的数据为什么不一样挂号退号的状态怎么流转我当初做这个选题的时候也吃过亏重新做了第二版才勉强像个能用的系统。这篇就把我做基于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关联号源表而不是直接关联医生idvisit_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);这段代码在并发下必然出错。两个线程同时读到booked19都判断还没满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_userid, username, password必须加密存储, real_name, role_idsys_roleid, role_name, role_code如doctor、cashier、adminsys_menuid, menu_name, parent_id, url, order_numsys_role_menurole_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里存datetimeJava侧用LocalDateTime接收正常情况没问题。但如果你部署的服务器时区和数据库时区不一致会出现预约8点到库变成4点这种诡异现象。解决方案是统一所有环境成UTC8并且在JDBC连接串里显式指定serverTimezoneAsia/Shanghai。别小看这个事我当时排了一个下午才发现是时区导致号源日期全错位了。第二个是事务里别做远程调用。我第一版在挂号事务里集成了短信通知服务用了第三方HTTP接口结果某天短信服务超时整个挂号事务也跟着回滚了。后来我把短信改成异步发送先commit挂号事务再用线程池发短信即使短信失败也不影响核心挂号流程。这个原则叫核心路径与旁路逻辑分离做订单类系统的人都知道做管理系统的往往忽略。第三个是状态字段别用String描述也别用魔法数字。项目里至少用常量类集中管理定义有条件用枚举类型。Java枚举在处理状态机上尤其顺手可以把允许流转的规则直接定义在枚举内部代码可读性会提高一个档次。第四个是关于退号的边界情况。门诊系统里最容易被忽略的是患者挂号后没来就诊第二天的号源怎么算这里有业务决策——是直接核销还是保留一段时间。我在系统里做了一个定时任务每天凌晨把前一天待就诊状态且过期的挂号单自动改成已过期同时归还号源。你可以在项目里加上这个定时任务它在答辩时是很加分的点体现出了你对异常流程的闭环思考。这个项目做到最后我最大的体会是管理系统表面上是一堆CRUD实际上的难点全在业务规则的正确落地。号源怎么扣才不出错、状态怎么流转才不乱、权限怎么控制才不越权、日志怎么留才能追溯这些才是程序员的本事。技术栈用Spring Boot加MyBatis还是SSH都无所谓把业务这条线讲清楚你的项目就已经赢过大半人了。如果你正在做这个题目我的建议是不要急着敲代码先把第1节里的核心链路画清楚把第2节的状态流转表写出来然后从挂号这个入口开始一步一步往后做。做完挂号再做医生接诊然后是收费最后补上药房出库。每一步都确保流程闭环了再往上面堆功能。这样出来的系统不管是在课程设计答辩还是在日后的面试项目介绍里都能站稳脚跟。