Spring Boot医院就诊管理系统:架构设计、数据库与权限实战
发布时间:2026/9/10 20:13:36 锦皓数字建站

这两年Spring Boot几乎成了后端项目的默认起点尤其在学校毕设、中小型系统开发里十个项目有八个都是基于Spring Boot的某某管理系统。医院就诊管理系统就是其中非常典型的一类——表面上看起来是普通的CRUD系统实际上牵扯到预约挂号、医生排班、病历管理、支付状态、权限控制这些复杂业务做起来比想象中要费心思得多。这篇文章我打算把整套系统的设计思路、核心模块实现、数据库规划、踩坑记录全部分享出来。不管你是正在做类似课题的学生还是刚接触Spring Boot想拿真实业务练手的开发者这篇内容都能给你一套可以直接落地的方案。我会尽量说人话把每一步为什么这么做讲清楚而不是丢一堆配置让你自己去猜。1. 整体思路与需求拆解1.1 医院就诊管理系统到底要解决什么问题先别急着写代码做这类管理系统第一步永远是把业务搞清楚。医院就诊管理系统表面上要管的是患者——医生——科室——挂号记录这些实体但往深处想它本质上是一个状态流系统。什么意思一次就诊从患者走进医院到最后看完病中间要经历一串状态变化预约挂号待就诊→ 签到已报道→ 医生接诊就诊中→ 开出检查或药方待缴费→ 缴费完成已完成。每一步都有严格的先后顺序每种状态都限制着用户能执行什么操作。如果设计的时候脑子里没有这条状态线代码写到后面必然出现各种改来改去的逻辑漏洞。所以我的建议是开工之前先画清楚两个东西一个是角色权限矩阵一个是就诊状态流转图。角色权限不用复杂就三类患者、医生、管理员。但每类角色能看到的菜单、能触发的操作必须提前定死。状态流转更要明确比如患者取消挂号只能在待就诊状态下执行医生写病历只能在就诊中状态下执行。这些约束在后端接口里必须做校验不能只靠前端按钮隐藏来兜底。1.2 技术选型为什么是Spring Boot而不是其他方案这个项目的技术栈我最终选了Spring Boot 2.7.x MyBatis-Plus或Spring Data JPA MySQL Redis Vue前端。Spring Boot 3.x和4.x现在也出来了但我个人建议如果你不是非要用Jakarta EE新特性2.7.x在稳定性和资料丰富程度上依然很适合这类管理系统。当然新项目直接上3.x也没毛病只是要注意javax到jakarta的包名迁移。选Spring Boot的核心逻辑很实在生态成熟、上手门槛低、社区答案多。医院管理系统这类业务难点根本不在框架本身而在业务规则和权限模型上。Spring Boot的自动配置帮我们把项目骨架、内嵌容器、数据源绑定这些脏活累活全干了让我们能把精力集中在Service层的业务逻辑上。这一点在开发周期短的场景下价值非常大。提示如果团队里有人用过SSM切到Spring Boot之后最需要适应的就是约定优于配置——看到application.yml里自动装配好的数据源和JdbcTemplate别慌那是Spring Boot帮你了不是配置丢了。1.3 系统功能模块划分整个系统我拆成了下面几个模块每一个对应一个独立的业务域用户认证模块登录、注册、JWT签发与刷新、退出登录患者管理模块基本信息管理、就诊历史查询科室与医生管理模块科室列表、医生排班表、医生信息维护挂号模块在线预约、取消挂号、号源查询、签到就诊与病历模块医生接诊、病历书写、检查/检验开单收费与结算模块缴费单生成、支付状态回调、退费统计报表模块日就诊量、科室收入、医生工作量排行模块化设计不只是为了代码好看更重要的是明确边界。比如挂号模块绝对不允许直接改病历表统计模块只能通过Service层汇总数据谁越界谁负责。模块之间的通信统一走Service接口不搞跨Controller调用的骚操作。2. 四层架构与工程落地方案2.1 经典四层架构怎么划分Spring Boot项目最常见的组织方式是四层架构Controller层接口层、Service层业务层、DAO层数据访问层、Entity/DTO层数据模型层。这套分层的价值在于每一层只做好一件事变化的影响范围被控制在最小。具体到我们这个系统Controller层只做参数接收、基础校验、调用Service、封装返回结果。不要在Controller里写任何业务判断尤其别在Controller里直接操作Repository或者mapper。Service层全部业务规则都在这里。事务边界、状态判断、权限校验、业务异常抛出全部由Service负责。DAO层就是Spring Data JPA的Repository接口或者MyBatis的Mapper接口职责单一就是跟数据库打交道。Entity/DTO层Entity对应数据库表结构DTO用于接口出入参两者不要混用。很多人写管理系统的时候图省事直接把Entity返回给前端这种习惯在这个项目里一定要改。医院系统的数据结构往往涉及身份证号、病历详情等敏感字段如果直接把Entity序列化返回很容易把不该暴露的字段漏出去。所有接口出参一律用DTO/VO这个约束必须在项目一开始就定死。2.2 一个可以直接抄的目录结构下面是我实际使用的包结构可以直接照搬com.hospital.visit ├── config # 配置类WebMvc、Cors、Redis、JWT等 ├── controller # 接口层 │ ├── admin │ ├── doctor │ └── patient ├── service # 业务层接口 │ └── impl # 业务实现 ├── dao # Repository/Mapper ├── entity # 数据库实体 ├── dto # 请求/响应对象 │ ├── request │ └── response ├── common # 通用类Result、异常、常量、工具 │ ├── exception │ ├── result │ └── utils ├── security # JWT认证、权限注解 ├── handler # 全局异常处理、字段填充处理器 └── VisitApplication.java注意几个细节。common包里的Result统一返回体是必须的格式至少要包含code、message、data三个字段这样前后端联调时对接口结构有共同预期。handler包里的全局异常处理器很多人会漏掉但这东西能帮你省掉无数个try-catch用RestControllerAdvice把业务异常、参数校验异常、未知异常统一拦截并转成标准JSON返回Controller就能写得很干净。2.3 目录规划里最容易踩的坑第一坑是DTO和Entity乱放。有人把DTO塞进entity包里还有人爱建一个vo包又建一个model包最后自己都分不清。我的建议很简单就固定两个包dto.request和dto.response请求参数和返回结果分开命名上严格收口。第二坑是Service接口割裂。有人习惯为每个实体都建一个Service接口和impl结果就是一堆没有业务的空壳方法。我的建议是按业务域聚合Service比如RegistrationService里同时处理号源查询、创建挂号单、取消挂号三个方法而不是拆成三个Service。这样业务内聚更紧密代码量也少很多。第三坑是忽略配置文件的分环境管理。至少要有application-dev.yml、application-prod.yml和主配置application.yml三件套。数据库连接、Redis地址这类敏感信息在正式环境用环境变量注入不要把生产库密码写在配置文件里推到Git仓库这是血泪教训。3. 数据库设计与核心功能实现3.1 核心表结构设计思路数据库设计是这种管理系统最见功力的地方。我按业务域建了下面几张核心表用户表userid、username、password、real_name、phone、id_card、rolePATIENT/DOCTOR/ADMIN、status、created_time、updated_time。这里注意角色字段不要用int类型存0/1/2直接存字符串枚举更好读查询条件也直观。科室表departmentid、dept_name、intro、status。这张表很简单但注意要给dept_name加唯一索引避免重复科室。医生表doctorid、user_id、dept_id、title职称、introduction、registration_fee挂号费。doctor表通过user_id和user表关联这样医生登录和普通患者登录走同一套认证逻辑。排班表scheduleid、doctor_id、work_date、time_slot上午/下午、total_number、remaining_number、status。排班表是挂号功能的核心挂号扣号就是通过更新这张表的remaining_number实现的。挂号订单表registration_orderid、order_no订单号、patient_user_id、doctor_id、schedule_id、visit_date、time_slot、statusPENDING/PAID/CANCELLED/COMPLETED、amount、created_time。这里有个关键点订单号order_no一定要单独生成不能依赖自增id因为订单号要暴露给前端和支付系统。病历表medical_recordid、registration_id、patient_id、doctor_id、chief_complaint主诉、present_illness现病史、diagnosis诊断结果、treatment_plan治疗方案、created_time。病历是医生写的所以建议用大字段类型存长文本同时加上创建时间索引方便按患者查历史。缴费明细表payment_itemid、registration_id、item_typeMEDICINE/EXAMINATION/OPERATION、item_name、amount、status、paid_time。这张表把检查和药品都统一抽象成收费项后续扩展很方便。注意所有表的公共字段created_time、updated_time建议统一加上用MyBatis-Plus的自动填充或者JPA的PrePersist/PreUpdate来做别每张表都手写一遍。3.2 数据访问层JpaRepository怎么用我们用的数据访问方式是Spring Data JPA核心就是JpaRepository这个接口。很多人一开始看到这个接口会懵其实就是Spring帮你把基础CRUD做完了你只要定义好实体和主键类型就行。public interface RegistrationOrderRepository extends JpaRepositoryRegistrationOrder, Long { // 根据患者ID查询挂号记录按创建时间倒序 ListRegistrationOrder findByPatientUserIdOrderByCreatedTimeDesc(Long patientId); // 查询某个排班时段内已预约的人数 long countByScheduleIdAndStatus(Long scheduleId, String status); // 根据订单号查询 OptionalRegistrationOrder findByOrderNo(String orderNo); }这里重点讲一下JpaRepository的命名规则查询Spring会自动根据方法名解析SQL。findByPatientUserIdOrderByCreatedTimeDesc的意思就是按patientUserId字段查询并按照createdTime降序。这种写法对简单查询非常舒服但要注意方法名一长就容易拼错而且复杂查询后期维护起来很头疼。所以我的建议是简单查询用命名规则复杂查询用Query注解写JPQL再复杂就用原生SQL或者换MyBatis-Plus。比如查医生某天剩余号源JPQL比命名方法清晰得多Query(select s from Schedule s where s.doctorId :doctorId and s.workDate :workDate) ListSchedule findByDoctorAndDate(Param(doctorId) Long doctorId, Param(workDate) LocalDate workDate);另外还有个坑必须提醒JPA默认的findById返回的是OptionalT很多人不习惯直接调.get()一旦查不到就抛NoSuchElementException。正确做法是.orElseThrow(() - new BusinessException(记录不存在))把异常转成我们自己的业务异常。3.3 挂号扣号与订单创建的并发控制挂号是这个系统里并发压力最大的地方。一个热门医生上午的号源假设就30个如果100个人同时抢代码写不好就会出现超卖——明明没号了还下单成功。这里我推荐用Redis分布式锁 数据库乐观锁双层保护。具体做法是先查排班表remaining_number判断大于0后用UPDATE schedule SET remaining_number remaining_number - 1 WHERE remaining_number 0这种原子操作来扣减。注意这里的where条件把剩余号源大于0作为约束带进去那么即使同时来了多个请求数据库也会保证只有真正扣到号的请求才能成功影响行数影响行数为0就说明号源没了直接抛出号源不足异常。如果用了MyBatis-Plus可以用UpdateWrapper配合setSql(remaining_number remaining_number - 1)来实现相同效果。这一步是系统正确性的底线绝对不能省。完整流程串起来大概是校验当前患者是否有未完成的同时间段挂号单加Redis锁key设计为schedule:lock:{scheduleId}防止同一个排班被并发操作查询排班信息校验号源和状态执行原子扣减UPDATE schedule SET remaining_number remaining_number - 1 WHERE id ? AND remaining_number 0如果影响行数为1创建挂号订单状态置为PENDING释放锁如果影响行数为0抛出业务异常该时段号源已约满释放锁3.4 医生接诊与病历填写的状态约束医生端核心操作有两个开始接诊和提交病历。很多人把这两个操作合并成一个提交病历接口这是不对的。实际业务场景是医生先点击开始接诊系统把挂号订单状态从PENDING改成VISITING然后医生可以慢慢写病历写完再提交。这个拆分的价值在于可追踪。管理员可以随时看到某个患者目前在哪个医生手上就诊对现场调度有实际意义。代码层面要做的是在状态流转时校验前置状态是否符合预期Transactional public void startVisit(Long registrationId, Long doctorId) { RegistrationOrder order registrationOrderRepository.findById(registrationId) .orElseThrow(() - new BusinessException(挂号单不存在)); if (!order.getDoctorId().equals(doctorId)) { throw new BusinessException(无权接诊该患者); } if (!PENDING.equals(order.getStatus())) { throw new BusinessException(当前状态不可接诊); } order.setStatus(VISITING); registrationOrderRepository.save(order); }这种先查状态再改状态的写法在单机低并发下没问题但为了保险也可以加条件更新。不过对毕设或中小型系统来说Service层加同步锁或依赖Redis锁已经足够不必过度设计。4. 核心场景实操认证、文件上传与监控配置4.1 JWT认证与权限控制的完整落地医院系统的接口必须区分角色权限。我用的方案是Spring Security JWT登录成功签发token后续请求在Header里带Authorization: Bearer xxx通过自定义过滤器解析token、把用户信息放进SecurityContext。核心配置分三块第一块是登录接口放行。Spring Security默认会把所有请求拦下来必须手动配置哪些路径匿名可访问——比如/api/auth/login、/api/auth/register、以及前端的静态资源路径。第二块是接口权限规则。比如/api/admin/**需要ADMIN角色/api/doctor/**需要DOCTOR角色/api/patient/**则三种角色都可能有。用注解控制更灵活在Controller方法上加PreAuthorize(hasRole(DOCTOR))就行。开启方式是在启动类或配置类上加EnableGlobalMethodSecurity(prePostEnabled true)。第三块是token解析过滤器。写一个OncePerRequestFilter从请求头提取token用SecretKey验证签名如果合法就把userId和role封装成LoginUser对象放进SecurityContext后续在Service里通过SecurityContextHolder.getContext().getAuthentication()直接取当前登录人信息。提示JWT的secret一定要配置在环境变量或配置中心里不要硬编码写在类里更不要随代码提交到GitHub。泄露secret意味着任何人都可以伪造token整个认证体系就崩塌了。4.2 病历附件上传文件加参数一起提交医院系统经常会遇到上传检查报告、病历附件的需求。Spring Boot里上传文件本身不难难的是上传文件的同时还要传递其他业务参数。比如我要给某个挂号单上传一张患者的检查单照片需要同时带上registrationId和备注信息。前端用FormData提交时后端接收写法如下PostMapping(/api/patient/upload/report) public ResultString uploadReport( RequestParam(file) MultipartFile file, RequestParam(registrationId) Long registrationId, RequestParam(value remark, required false) String remark, RequestHeader(Authorization) String token) { // 1. 校验文件类型和大小 // 2. 生成存储路径并保存 // 3. 将文件路径和registrationId关联存入附件表 // 4. 返回文件访问URL }注意几个实操细节文件大小限制在application.yml里设置spring.servlet.multipart.max-file-size10MB、max-request-size10MB。不设置的话Spring Boot默认是1MB传个照片就报错。文件名必须重写不要用用户上传的原始文件名要生成UUID或时间戳拼接作为新文件名避免重名覆盖和历史文件名里的乱码问题。存储路径用日期分级目录比如/data/hospital/upload/2025/06/09/uuid.jpg方便后续归档和清理。推荐把文件存本地磁盘或者OSS不要直接存数据库BLOB字段。数据库存文件路径字符串就行查询性能好很多。4.3 Spring Boot Actuator的安全红线Actuator是Spring Boot自带的运维监控组件能暴露健康检查、指标信息、环境属性、Bean列表等一大堆敏感信息。如果直接开启未授权访问等于把系统的内部结构、数据库连接信息、配置项全亮给攻击者看这是非常危险的事情。我们系统里要访问Actuator但又不能裸奔我的做法是第一条只暴露必要的端点默认的health和info保留多余的env、beans、heapdump、shutdown全部关闭。第二条给管理端口单独加权限要么设独立的管理端口management.server.port8081要么在Spring Security里把/actuator/**配置成需要ADMIN角色才能访问。management: endpoints: web: exposure: include: health,info endpoint: health: show-details: never如果你是跟着教程做到这一步看到/actuator/heapdump能下载堆转储文件别觉得新鲜就保留那玩意能直接泄露系统内存里的用户密码等敏感信息必须关掉。5. 常见问题与排查技巧实录5.1 启动慢与端口被占用Spring Boot项目启动时报Port 8080 was already in use是家常便饭。可以先netstat -ano | findstr 8080看看占用进程是谁或者直接在配置里换端口。如果项目启动特别慢先检查数据库连接配置——如果数据库连不上Spring Boot会反复重试导致启动很久才报错。这时候用spring.datasource.hikari.initialization-fail-timeout调短连接失败时间能快速暴露问题。5.2 JPA查询返回的JSON序列化循环引用用JPA做实体关联关系时OneToMany加上之后查询出来的实体里经常出现你包含我、我包含你的循环引用Jackson序列化直接栈溢出。这个问题最常见的解法是在关联字段上加上JsonIgnoreProperties或者在DTO里手动组装返回对象。我的习惯是一律不直接把Entity返回给前端在Service里把Entity转成VO关联关系手动控制不依赖Jackson去解析双向关联。虽然多写几行转换代码但序列化问题、懒加载问题、字段泄露问题全部绕开了省心很多。5.3 懒加载引发的LazyInitializationExceptionJPA默认关联关系是懒加载的也就是查到主实体的时候关联实体并没有从数据库查出来。如果这时候事务已经结束再访问关联对象就会抛LazyInitializationException。解决这个问题的思路有几个在Service层事务方法内提前把需要的数据查出来并转成DTO事务提交后不再碰懒加载属性需要单条列表直接用JPA的JOIN FETCH或者EntityGraph把需要的关联关系一次性查出来直接在DTO组装阶段访问关联属性只要还在事务内就没问题展开讲一下EntityGraph这个注解很好用它可以指定查询时要一并抓取的关联对象避免N1问题EntityGraph(attributePaths {doctor, department}) Query(select r from RegistrationOrder r where r.patientUserId :patientId) ListRegistrationOrder findOrdersWithDetail(Param(patientId) Long patientId);5.4 事务不生效的隐蔽坑Transactional在同一个类内部方法调用时不生效这是Spring AOP的经典问题。比如OrderService.createOrder()方法内部调用了本类的deductStock()方法就算deductStock()上标了Transactional事务也不会开启因为调用没有经过代理对象。解决方式有两种一是把需要事务的方法拆分到不同的Service类里互相通过Spring容器注入的代理对象调用二是在同一个类里把调用改成AopContext.currentProxy()方式需要设置EnableAspectJAutoProxy(exposeProxytrue)。我做管理系统时踩过这个坑后来直接规定事务边界只在聚合Service的公开方法上标注内部private方法一律不标Transactional从根源上规避问题。5.5 常见问题速查表问题现象可能原因解决思路前端跨域请求失败未配置Cors跨域WebMvcConfigurer里自定义addCorsMappings登录后访问接口返回403Security配置放行规则写错检查permitAll和anyRequest().authenticated()顺序JWT过期但前端不跳登录前端未捕获401状态码在axios拦截器里全局捕获401跳转上传文件报MaxUploadSizeExceededException未配置或配置了但没重启检查application.yml配置并确认生效数据库连接报Too Many Connections连接池参数过大或存在连接泄漏调小HikariCP的maximumPoolSize并检查事务是否关闭同一方法事务回滚不干净捕获了异常未继续抛出Transactional默认只回滚RuntimeException手动捕获异常后需要TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()或直接重新抛出6. 项目上线前必须做的几件事到这里系统功能都做完了但先别急着交作业。上线部署前有几件必须做的工作很多人因为赶进度就跳过了结果线上频繁出事。第一是统一的返回体和全局异常检查。把所有接口过一遍凡是出现200 null这种返回体或者直接返回字符串的接口全部收口成标准格式。我们统一用ResultT包装code为200是成功400是参数错误401是未认证403是未授权500是服务器内部错误前后端按这个约定联调问题能少一半。第二是日志规范。系统接入Slf4j Logback至少分info、error两个日志文件。关键操作比如挂号成功、退号、缴费回调都要记录操作日志日志内容至少要包含操作人ID、操作类型、涉及订单号、操作时间。出了问题能通过日志快速还原现场。第三是定时任务与缓存策略。排班的号源数据是热点数据可以用Redis缓存科室列表和热门医生的号源信息缓存5分钟或10分钟刷新一次。定时任务还可以用来处理已经下单但长时间未支付的订单比如超过30分钟自动取消并回补号源。用Scheduled(fixedDelay 60000)每分钟扫一次PENDING状态的订单超时的直接改成CANCELLED并把号源加回去。第四是部署环境的区分。生产环境建议用Maven profile做多环境打包mvn package -P prod打出生产包配置文件里的数据库密码、Redis地址全部从环境变量读取数据库连接串不要写死在yml里。第五是基础安全加固。管理员的密码必须用BCrypt加密不能存明文。患者查询自己的就诊记录时接口一定要校验当前登录用户和患者ID一致不然任何登录的人都知道别人的病历这是隐私红线。我实际做完这套系统之后最大的感受是Spring Boot本身确实没什么难度真正的复杂度全在业务规则、状态管理、并发控制和权限设计这些看不见的地方。如果你正在做同类的毕设或者练手项目我建议不要把时间浪费在琢磨什么框架更高级而是把核心业务场景的流程图理清楚把状态约束写严谨把异常处理做完整这套系统就已经能吊打很多网上找的开源项目了。最后再分享一个小技巧项目骨架搭好之后别急着写业务代码先花半天时间把统一返回体 全局异常 登录认证 基础CRUD这条链路跑通后面所有模块都在这个地基上垒砖。地基稳了这栋楼住着才安心。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。