简介:基于Java语言开发的门诊服务聚合系统设计源码,面向医疗信息化开发者与Java后端学习者,以聚合门诊服务为核心,覆盖预约挂号、排队叫号、医疗记录管理等典型业务模块。压缩包内共51个文件,包含34个Java源文件、8个XML配置文件、1个YAML配置及1个JAR包等,配套Maven构建脚本与项目说明文档,整体大小为220KB。项目采用Spring Boot、Spring MVC与MyBatis技术栈,遵循模块化、服务化设计,前后端分离,重点展示医疗场景下接口定义、数据持久化及安全认证的实现方式。已有246人学习下载,适合需要借鉴完整门诊系统源码结构、了解Java企业级医疗项目配置与部署细节的读者。通过阅读源码可掌握多模块Maven工程的组织方式、YAML多环境配置及关键业务逻辑的代码组织,为同类系统设计提供直接参考。
1. 门诊服务聚合系统:一套能跑通挂号到诊疗流程的 Java 课程设计源码
医院门诊楼里最典型的场景,患者先在窗口排队挂号,再拿着挂号单去分诊台排队,然后被叫号看医生,医生开完检查单或处方,患者又要去收费窗口排队,最后再跑回诊室听结果。这个过程中患者的身份信息被反复录入,不同环节的系统互不相通,医生想看一眼患者三个月前的就诊记录都费劲,收费处也不清楚当前号源到底锁到了几号。基于 Java 语言的门诊服务聚合系统设计源码,要解决的正是这个痛点——把挂号、分诊、接诊、收费这些原本分散的服务聚合到一个统一入口,让患者一次登录就能走完整条诊疗链路,也让医生和收费员在各自的工作台上看到同一份患者数据。
这份源码适合两类人。一类是做 Java 课程设计或毕业设计的学生,需要一套结构清楚、能讲明白业务逻辑的完整工程,而不是零散的功能片段;另一类是刚接触医疗业务开发的从业者,想看看门诊场景下挂号、分诊、收费这些模块的状态流转到底怎么落代码。它不是一个只停留在增删改查的 Demo,而是把门诊主流程拆成了可扩展的服务模块,前后端能连起来跑。下面从业务建模、源码结构、核心模块到部署排错,把这份资源从头到尾拆开讲。
2. 业务建模与聚合设计:先看清门诊怎么跑,再谈代码怎么分
2.1 门诊主流程拆解:挂号-分诊-接诊-收费的链路设计
门诊系统的业务核心是一串有先后顺序的状态流转,不是孤立的功能点。患者先到挂号窗口选择科室和医生,系统锁定号源并生成挂号记录,此时状态是“已挂号”;随后患者到分诊台报到,护士确认候诊,状态变为“候诊中”;医生叫号接诊后,状态更新为“就诊中”,医生录入主诉、诊断并开出处方或检查单;患者去收费处缴费,状态变成“已缴费”,整个闭环才算完成。
这条链路里最容易出错的是状态边界。比如患者挂了号但没去分诊,号源算不算释放?医生接诊时患者还没缴费,检查单能不能先开?这份源码的处理方式是给每个环节都维护一个明确的状态字段,用常量去定义,而不是靠字符串散落在业务代码里。聚合设计的核心也在这里——不是让每个模块各自维护一份患者数据,而是通过一个聚合服务层把底层多个基础服务串联起来,对外暴露统一接口。
从代码层的角度说,这份源码用的是典型的分层结构:Controller 只做参数接收和结果封装,Service 层处理业务规则,Mapper 层负责数据库交互。聚合服务层放在 Service 之上,比如OutpatientAggregateService,它内部调用挂号、分诊、接诊、收费四个基础 Service,组合出一个完整的门诊流程接口。这样前端只需要调用一个聚合接口,后端各模块的职责边界却能保持清晰,后续要加检查单模块或者加药房发药模块,改动被限制在聚合层内部,不至于牵一发动全身。
2.2 为什么选“聚合服务”而不是单表 CRUD:门面模式的取舍
很多课程设计做医疗系统,写着写着就变成了一张大表的增删改查——一个registration表里既存患者信息又存医生信息还存缴费状态,前端页面直连数据库查询。这种写法开发速度快,但业务规则一变就崩:比如要限制一个医生上午最多接诊 30 个患者,这个校验逻辑应该放在哪里?如果直接写在 JSP 页面里,那换一个页面就要重写一遍。
这份源码走的是门面模式(Facade Pattern)的路线,把散落在各模块的业务逻辑统一收口到聚合服务层。前端拿到的返回结构始终是统一的Result<T>对象,包含状态码、提示信息和具体数据。比如挂号成功后返回{ "code": 200, "msg": "挂号成功", "data": { "registrationId": 1024, "queueNumber": 15 } },前端不用关心这个结果到底是挂号模块生成的还是排队模块生成的,只管解析数据结构。
选这个方案的好处集中体现在两个场景。第一是事务边界变清晰了:一次完整的“挂号+锁定号源+生成排队序号”是一个事务,这个事务以聚合服务的方法为边界,而不是散在多个 Controller 里各管一段;第二是扩展性有了明确方向,门诊系统后续增加体检预约、报告查询,只需要在聚合层加新方法,原有接口不动。缺点也有——聚合层代码会膨胀,所以这份源码里聚合 Service 内部做了分包,按业务域拆成registration、triage、consultation、charge四个子包,避免所有逻辑都堆在一个类里。
2.3 数据模型落库:患者、医生、号源、处方四大核心表结构
打开源码里的数据库脚本,先看到的是建库建表语句。整个库按业务域拆了十来张表,但真正决定门诊主流程跑通的是四张核心表:患者表、医生表、挂号表、处方表。先看患者表和医生表的设计逻辑。
CREATE TABLE `patient` ( `id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '患者主键', `card_no` VARCHAR(32) NOT NULL UNIQUE COMMENT '就诊卡号', `name` VARCHAR(32) NOT NULL COMMENT '姓名', `gender` TINYINT NOT NULL COMMENT '性别:0-女 1-男', `phone` VARCHAR(16) DEFAULT NULL COMMENT '手机号', `birthday` DATE DEFAULT NULL COMMENT '出生日期', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY `idx_card_no` (`card_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='患者表';患者表的关键是card_no加了唯一索引,这是患者的身份凭证,后续挂号、分诊、缴费都以它为关联条件。身份证号、手机号都只是附属信息,真正驱动业务流转的是这张就诊卡号。医生表则更侧重科室归属和排班状态,一个医生属于一个科室,每天可以有多条排班记录,每条排班记录对应一个时段和号源总数。
CREATE TABLE `registration` ( `id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '挂号主键', `card_no` VARCHAR(32) NOT NULL COMMENT '就诊卡号', `doctor_id` INT NOT NULL COMMENT '医生ID', `schedule_id` INT NOT NULL COMMENT '排班ID', `registration_type` TINYINT NOT NULL COMMENT '号别:0-普通 1-专家', `fee` DECIMAL(10,2) NOT NULL COMMENT '挂号费', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-已挂号 1-候诊中 2-就诊中 3-已缴费 4-已退号', `queue_number` INT NOT NULL COMMENT '排队序号', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_card_no` (`card_no`), KEY `idx_doctor_id` (`doctor_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='挂号记录表';挂号表是这个系统的状态中枢。status字段从 0 到 4 串起了整条门诊链路,queue_number是医生叫号的依据,schedule_id关联到排班表,目的是防止医生不在岗时患者还能挂上号。处方表则记录医生开出的药品明细,通过registration_id关联回挂号记录,收费模块直接读处方表算总价。
3. 源码结构与登录鉴权:从建库脚本到 Shiro 会话控制
3.1 源码包目录在讲什么:后端分层与前端页面一一对应
把源码拉下来后,先看目录结构再谈跑起来。整个工程是标准的 Maven 多模块布局,但实际业务代码集中在src/main/java下的几个包里面。controller包负责接收前端请求,service包放业务逻辑,mapper包放 MyBatis 的接口和 XML 映射文件,entity包对应数据库表结构,config包放 Shiro、拦截器和全局配置。前端页面在src/main/webapp/WEB-INF/jsp下,按patient、doctor、admin、charge四个角色目录划分。
一个值得注意的细节是config包里有一个GlobalExceptionHandler,用@ControllerAdvice注解统一捕获业务异常。这意味着前端拿到的错误信息格式是固定的,不会出现某个接口直接抛出一长串堆栈异常的情况。对于门诊这类对数据准确性要求高的业务场景,统一异常处理不是加分项,而是必需品。
3.2 建库脚本跑通:数据库初始化与核心数据准备
数据库初始化是大部分人第一次翻车的地方。源码里自带sql/init.sql,里面不只是建表语句,还插入了基础数据:管理员账号、两个科室、三个医生、几条排班记录和测试患者。执行时建议用 MySQL 5.7 及以上版本,字符集选择utf8mb4,排序规则选utf8mb4_general_ci。
mysql -uroot -p123456 < sql/init.sql执行成功后,用SHOW TABLES;验证是否生成了全部表。常见的问题是初始化脚本执行时 MySQL 的sql_mode限制了TIMESTAMP默认值,如果报Invalid default value for 'create_time',需要在 MySQL 配置里加上sql_mode=NO_ENGINE_SUBSTITUTION或者执行SET sql_mode='';后再跑脚本。这一步完成后,最重要的是确认测试患者数据已经存在,后续登录和挂号都要靠这批初始数据。
3.3 登录与权限控制:Shiro 过滤器链的配置要点
登录鉴权用的是 Apache Shiro,配置集中在config/ShiroConfig.java里。这套源码没有引入 Spring Security,原因是 Shiro 的过滤器链更直观,对于课程设计阶段的权限控制足够用。核心配置是这段:
@Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); // 登录接口和静态资源匿名访问,业务接口全部需要认证 Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>(); filterChainDefinitionMap.put("/login", "anon"); filterChainDefinitionMap.put("/logout", "logout"); filterChainDefinitionMap.put("/css/**", "anon"); filterChainDefinitionMap.put("/js/**", "anon"); filterChainDefinitionMap.put("/images/**", "anon"); // 权限控制:/doctor/** 需要医生角色,/admin/** 需要管理员角色 filterChainDefinitionMap.put("/doctor/**", "roles[doctor]"); filterChainDefinitionMap.put("/charge/**", "roles[charge]"); filterChainDefinitionMap.put("/admin/**", "roles[admin]"); filterChainDefinitionMap.put("/**", "authc"); factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); factoryBean.setLoginUrl("/login"); factoryBean.setUnauthorizedUrl("/403"); return factoryBean; }这段配置的逻辑是按 URL 前缀做角色隔离,医生只能访问/doctor/**下的页面,收费员只能访问/charge/**,管理员拥有全部权限。有一个参数需要特别关注:filterChainDefinitionMap用的是LinkedHashMap,保证匹配顺序是“先声明先匹配”,所以/login必须放在最前面,否则会被/**的authc规则拦截,导致登录页都进不去。
4. 核心模块实现拆解:挂号分诊与就诊记录的代码复现
4.1 挂号模块:号源锁定与排队队列的状态流转
源码里挂号模块的入口是RegistrationController,它接收科室 ID、医生 ID、就诊卡号三个参数,然后调用聚合服务层的createRegistration方法。这个方法的实现是整个挂号模块的核心,它的逻辑顺序很有讲究:先校验医生当天是否有排班,再校验号源是否已满,然后锁定号源、生成排队序号、创建挂号记录,最后返回结果。
@Service public class OutpatientAggregateService { private final RegistrationService registrationService; private final ScheduleService scheduleService; private final PatientService patientService; public RegistrationResult createRegistration(String cardNo, Integer doctorId, Integer scheduleId) { // 1. 校验患者是否存在且状态正常 Patient patient = patientService.getPatientByCardNo(cardNo); if (patient == null) { throw new BusinessException("就诊卡号不存在,请先建档"); } // 2. 校验排班当天是否有效,同时锁住排班记录 Schedule schedule = scheduleService.lockSchedule(scheduleId); if (schedule == null || schedule.getSurplus() <= 0) { throw new BusinessException("该医生当前时段号源已满"); } // 3. 计算排队序号:当前序号 + 1 int nextQueue = schedule.getCurrentQueue() + 1; // 4. 创建挂号记录,状态为已挂号 Registration registration = registrationService.create( cardNo, doctorId, scheduleId, nextQueue); // 5. 扣减剩余号源,返回聚合结果 scheduleService.deductSurplus(scheduleId); return RegistrationResult.success(registration); } }这里有两个容易被忽略的细节。第一个是scheduleService.lockSchedule方法,它不是简单的查表,而是用SELECT ... FOR UPDATE把排班记录锁住,目的是防止两个患者同时挂号时读到同一个剩余号源数——这就涉及到并发场景下的数据一致性,后面排查章节会展开讲。第二个是排队序号currentQueue + 1的生成方式依赖数据库中的当前值,如果挂号记录创建失败,这个序号会回滚,不会出现跳号或者重号。
4.2 分诊与接诊:医生工作台的忙闲状态控制
分诊模块在源码里的设计比较轻,原因在于它的核心逻辑其实是状态更新。护士在分诊台扫描患者的就诊卡号,系统把挂号记录的状态从“已挂号”(0)改成“候诊中”(1),然后医生端的工作台刷新列表时,只查询状态为 1 的记录。
@Transactional public void triage(String cardNo, Integer registrationId) { // 只有状态为"已挂号"的记录才能被分诊,防止重复报到 Registration registration = registrationMapper .findByIdForUpdate(registrationId); if (registration == null || registration.getStatus() != 0) { throw new BusinessException("该患者当前状态不可分诊"); } registration.setStatus((byte) 1); // 候诊中 registrationMapper.updateStatus(registration); }接诊模块的代码逻辑类似,但当医生点击“开始接诊”时,源码里还额外更新了医生表的状态字段,把医生的值班状态从“空闲”改成“忙碌”。这意味着源码里实际上维护了两份状态:患者的就诊状态和医生的忙闲状态。两份状态必须同时更新,因为后续患者的就诊记录会关联到医生当前状态,排班模块也要靠医生状态判断能否继续接诊。
4.3 收费与退费:事务边界和状态回滚处理
收费模块是整条链路里最强调事务边界的地方。患者拿着处方去收费窗口,收费员输入就诊卡号,系统调出待缴费的处方明细,确认金额后调用收费接口。这个接口内部是一个跨两个表的操作:更新挂号记录状态为“已缴费”,同时生成一条收费流水记录。如果只更新了挂号状态而收费流水写入失败,患者会看到“已缴费”但财务对不上账。
@Transactional(rollbackFor = Exception.class) public ChargeResult charge(String registrationId, BigDecimal paidAmount) { // 查询挂号记录,校验当前状态必须为"就诊中"(医生已写完病历) Registration registration = registrationMapper .findByIdForUpdate(registrationId); if (registration.getStatus() != 2) { throw new BusinessException("该患者尚未完成就诊,无法缴费"); } // 计算处方总金额,与支付金额对比 BigDecimal total = prescriptionMapper .sumAmountByRegistrationId(registrationId); if (total.compareTo(paidAmount) != 0) { throw new BusinessException("缴费金额与处方金额不一致"); } // 更新挂号状态 + 写入流水,同一个事务 registrationMapper.updateStatus(registrationId, (byte) 3); ChargeRecord record = new ChargeRecord(); record.setRegistrationId(registrationId); record.setAmount(total); record.setChargeTime(new Date()); chargeMapper.insert(record); return ChargeResult.success(total); }退费逻辑的边界条件更多。源码里规定退费只能在当天进行,且挂号记录状态必须是“已缴费”,退费操作会同时把状态回退为“已就诊”并把流水标记为作废,而不是物理删除。这个设计是合理的——财务数据不允许删除,只能冲销或者作废,否则对账时会出现“钱收了但流水凭空消失”的问题。@Transactional(rollbackFor = Exception.class)这一段值得单独拿出来说,rollbackFor指定了任何异常都触发回滚,如果不写这个参数,Spring 默认只在运行时异常时回滚,受检异常不会回滚,很容易造成半个事务提交的悬空状态。
5. 门诊系统常见问题排查:部署、乱码与患者数据不一致
5.1 启动即报 Bean 创建异常:大概率是 MyBatis 映射扫描路径没对上
现象:Spring Boot 启动时抛Invalid mapper interface或BeanCreationException,指向某个 Mapper 接口无法注入。原因:源码里的@MapperScan扫描路径写的是com.hospital.outpatient.mapper,但工程里实际的 Mapper 接口所在包名和它不一致,或者某个 Mapper XML 的namespace与接口全限定名对不上。解决:定位到Application.java或配置类上的@MapperScan注解,改成实际包路径,同时检查resources/mapper目录下 XML 文件的namespace是否等于接口全类名,改完清理一次target目录再重启。
5.2 页面能开但登录失败:数据库编码与字符集不一致
现象:前端页面正常加载,输入管理员账号密码后提示登录失败,但数据库里明明有这条用户记录。原因:初始化 SQL 脚本执行时用了默认字符集,而 MySQL 的系统变量character_set_server是latin1,导致中文用户名写入后乱码,查询时匹配不上。解决:查一下SHOW VARIABLES LIKE 'character%';,如果字符集不对,把建库语句改为CREATE DATABASE hospital DEFAULT CHARACTER SET utf8mb4;,重新导入init.sql。源码里的登录逻辑是先用用户名查用户,再比对密码哈希值,所以用户名乱码会直接导致“查不到用户”。
5.3 挂号重复插入:并发场景下号源校验与事务隔离级别
现象:用两个浏览器窗口同时给同一个医生挂号,看到剩余号源都显示还剩 1 个,结果两个窗口都挂号成功,号源变成负数。原因:挂号接口不是原子的,先查schedule.surplus再扣减,两个并发事务都读到了剩余 1,随后各自执行扣减,没有锁保护。解决:在我自己的复现中,把scheduleService.lockSchedule里的查询改成SELECT ... FOR UPDATE只锁排班记录行,同一个医生同一时段的并发挂号就会被串行化;同时把OutpatientAggregateService的createRegistration方法加上@Transactional,保证扣减号源和创建挂号记录在同一个事务里。另外注意 MySQL 的默认隔离级别是REPEATABLE_READ,加了行锁后不会有幻读问题,不需要改隔离级别。
5.4 前端页面接口 404:Shiro 过滤器把静态资源拦截了
现象:登录后能进入主页框架,但页面上的 CSS 样式全部丢失,点击菜单时部分接口返回 404。原因:Shiro 的过滤器链把/css/**、/js/**、/images/**之外的其他静态资源路径拦截了,比如 Bootstrap 字体文件走了/fonts/**,而这条规则没有放行,同时 JSP 页面里的资源引用路径写的是绝对路径/js/jquery.min.js,但工程部署的上下文路径带项目名,路径匹配不上。解决:在ShiroConfig的过滤器链里补上/fonts/**、/favicon.ico的放行规则;JSP 页面里统一用<c:url>或者${pageContext.request.contextPath}拼接资源路径,避免上下文路径不一致导致的 404。
6. 从课设到生产:门诊系统的二次开发与面试话术
6.1 给系统加一个排队叫号大屏的扩展思路
跑通主流程之后,最值得做的二次开发是加一个排队叫号展示大屏。这个功能在生产环境几乎必配,但源码里没有直接实现,需要自己扩展。扩展思路是:在聚合服务层加一个getCurrentQueue(doctorId)接口,返回当前医生正在接诊的排队序号和接下来三个等待序号的列表;前端大屏页面每五秒轮询一次这个接口,把queueNumber实时刷新到页面上。
// 大屏轮询逻辑:每5秒拉取一次当前队列 function refreshQueue(doctorId) { $.get("/queue/current?doctorId=" + doctorId, function (res) { if (res.code === 200) { $("#currentNumber").text(res.data.currentQueueNumber); $("#waitList").empty(); res.data.waitList.forEach(function (item) { $("#waitList").append( "<li>请 " + item.queueNumber + " 号 " + item.patientName + " 到 " + item.roomName + " 就诊</li>" ); }); } }); } setInterval(function () { refreshQueue(1); }, 5000);这一步改动能让你在答辩或项目汇报时讲出“实时数据刷新”“轮询与并发读”两个亮点。如果时间充裕,还可以把轮询改成 WebSocket 推送,作为加分项写进文档。
6.2 简历与面试:这门诊系统源码该怎么讲才算自己写的
医疗门诊系统在 Java 面试里属于高频的业务场景题,很多公司招聘时不看你会不会 Spring Boot CRUD,而是看你能否讲清楚业务状态流转和并发场景下的数据一致性。这份源码如果只是下载跑通,面试时三句话就被问穿。建议按这个顺序组织话术:先讲业务,说明门诊主流程的五个状态和每个状态流转的触发条件;再讲架构,说明为什么不直接改数据库表而是通过聚合服务层;最后讲技术难点,抛出并发挂号、事务回滚和 Shiro 过滤器链这三个点,每个点都用自己的话解释清楚场景和解决方案。
比如面试官问“你这个系统怎么防止号源超卖”,标准回答不是背代码,而是说:我先用SELECT ... FOR UPDATE锁住排班记录,再判断剩余号源,然后在一个事务里完成扣减和挂号记录插入。这句话能同时体现你对并发控制和事务边界的理解,比“用的 MyBatis 写的”好太多。还有一点,面试时不要说“这个项目是下载的源码”,要说“我在 xx 源码基础上做了二次开发,主要重构了聚合服务层和并发控制逻辑”,这是技术圈默认的诚实表达。
6.3 收尾:我自己跑这套源码时的两个习惯
我每次拿到一套新的课设级源码,第一件事一定不是急着启动,而是先打开init.sql看表结构,把外键关系在纸上画一遍。这套门诊系统的表结构不算复杂,但挂号表和排班表之间的关联、处方表和挂号表之间的关联,如果不先看清楚,后面排查并发问题时会很被动。第二个习惯是启动后先不开浏览器,用 Postman 直接调接口测业务流,从建卡到挂号到分诊到缴费一路调通,然后再去点页面。这样能把后端逻辑问题和前端页面问题分开排查,不至于混在一起翻车。希望这套门诊服务聚合系统源码的拆解能帮到你,至少让你拿到代码后少走几段弯路。
本文还有配套的精品资源,点击获取