☰
拆解Spring Boot+Vue+MySQL医院管理系统:从架构到部署避坑
2026/10/29 6:12:50 网站建设 项目流程

简介:面向高校学生与Java初学者的Spring Boot + Vue前后端分离医院管理系统源码包,内置管理员、医生、患者三种角色,涵盖登录注册、挂号、药物、检查项目、病床、排班、数据统计及医生端住院申请等功能,能帮助理解RESTful接口设计、权限划分与Vue+ElementUI后台搭建。资源共77个文件,以51个Java业务代码和11个XML映射/配置为主,同时包含PNG界面截图、yml/properties配置及说明文档,压缩包仅854KB,目录简洁便于定位源码和配置文件。目前已有454人学习下载。项目提供完整可运行的前后端联调骨架、模块化分层示例与典型管理场景实现,数据库采用MySQL,适合毕业设计、课程设计或快速上手Spring Boot与Vue整合开发。

1. 医院管理系统拆解:这套 Spring Boot + Vue + MySQL 的 HIS,值不值得一行行读

我陪朋友去一家二甲医院信息科对接数据,挂号窗口还在用厂商绑定的老 HIS,加一个退号逻辑要排期两周。后来我拆了这套基于 Spring Boot + Vue + MySQL 实现的医院管理系统,挂号、门诊、收费、药房四件事被拆成清晰的前后端模块。做毕设拿完整业务链路的、接私活的、想搞清 HIS 模块边界的后端开发,都适合把源码过一遍。

它不是单页面 demo,而是带角色权限的完整闭环:管理员管排班和药品,医生开处方,收费员收费退费,药房发药扣库存。新手可从中理解「前后端分离 + JWT 鉴权 + 多表事务」怎么落地;熟手重点看排班冲突、库存并发、金额精度三个翻车点。

2. 技术选型与项目结构:Spring Boot 分层、Vue 目录和 MySQL 表设计怎么定

2.1 后端:Spring Boot 分层与依赖选择

医院管理系统这种业务,表多、联查多、字段变动频繁。我拆这类项目时,后端默认走 Spring Boot 2.7.x + MyBatis-Plus 的组合,而不是 Spring Boot 3.x + JPA。理由有两个:2.7.x 对 JDK 8 友好,很多机器环境还在 JDK 8;MyBatis-Plus 的单表 CRUD 不用写 SQL,而挂号、处方这种聚合查询又能手写 SQL 控制,比 JPA 的抽象查询更适合查「今天某医生剩余多少号源」这类需求。关键依赖大致是这样:

<properties> <java.version>1.8</java.version> <spring-boot.version>2.7.18</spring-boot.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

这份依赖里三个点要留意:jjwt 0.9.1 是这类项目里最常见的 JWT 库,解析 token 的 API 稳定,网上资料最多;MyBatis-Plus 的 starter 版本要和 Spring Boot 2.x 匹配,3.5.3 配 2.7 的分页插件兼容性最好;mysql-connector-java 在 2.7 下用默认的 com.mysql.cj.jdbc.Driver 即可,不用改类名。如果手欠把 Spring Boot 升到 3.x,这套依赖里的 javax 包名和 MyBatis-Plus 坐标全要换,这就是避坑章节里会展开的版本坑。

目录结构我一般保持 controller / service / mapper / entity 四层。controller 只做参数接收和结果包装,service 写业务,mapper 写 SQL 映射,entity 对应表结构。医院管理里最容易写乱的是 service:退号要同时改挂号单状态、回补号源、判断有没有发药,这些必须在一个服务方法里串起来,而不是散在 controller 里。项目结构本身就在演示这一点,拿到源码第一件事就是看 service 层有没有把「改状态 + 回补」放到同一个事务方法里。

2.2 前端:Vue 版本选择与目录规划

前端这层,拿到项目先确认是 Vue 2 + Element UI 还是 Vue 3 + Element Plus。后端是 Spring Boot 2.7 + JDK 8 的话,我建议用 Vue 2.7 + Element UI 2.15,npm 依赖安装失败率最低;如果机器是 Node 18+,Vue 3 + Vite 也完全能跑。我自己拆这类系统时偏好 Vue 3 + Element Plus,但代码风格必须统一——混用两个版本的组件写法,是后期最痛苦的返工。

npm install -g cnpm --registry=https://registry.npmmirror.com vue create hospital-web npm install element-plus axios vue-router pinia

第一句用国内镜像装 cnpm,是解决 vue 安装依赖时网络超时的常见做法;vue create 生成脚手架后,再装 element-plus、axios、vue-router、pinia。路由和状态管理要提前规划:登录页、挂号页、医生工作台、药房库存页各占一个路由文件,动态路由按角色注册,管理员、医生、收费员看到的菜单天然不一样。源码里的 src/router 和 src/store 建议保留原始划分,不要因为页面少就压成单文件,后面加需求时你会感谢这个习惯。

2.3 数据库:MySQL 核心表设计与关系

数据表是系统地基,最少要有 sys_user(用户/角色)、department(科室)、doctor(医生排班)、registration(挂号单)、prescription(处方)、drug(药品)、drug_stock(库存流水)。挑两张核心表展开:

CREATE TABLE `doctor` ( `id` bigint NOT NULL AUTO_INCREMENT, `department_id` bigint NOT NULL COMMENT '科室ID', `name` varchar(50) NOT NULL COMMENT '医生姓名', `title` varchar(30) DEFAULT NULL COMMENT '职称:主任医师/主治医师', `weekday` varchar(20) NOT NULL COMMENT '出诊星期,如 1,3,5', `start_time` time NOT NULL COMMENT '出诊开始时段', `end_time` time NOT NULL COMMENT '出诊结束时段', `total_slots` int NOT NULL DEFAULT 20 COMMENT '每时段号源总数', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `registration` ( `id` bigint NOT NULL AUTO_INCREMENT, `patient_name` varchar(50) NOT NULL, `doctor_id` bigint NOT NULL, `reg_date` date NOT NULL COMMENT '挂号日期', `slot_no` int NOT NULL COMMENT '号源序号', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待就诊 1已就诊 2已退号', `amount` decimal(10,2) NOT NULL COMMENT '挂号费', PRIMARY KEY (`id`), KEY `idx_doctor_date` (`doctor_id`, `reg_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

两个设计点值得抄:doctor 表把出诊星期存成1,3,5逗号分隔,查询用 FIND_IN_SET,比拆多行简单,统计时略麻烦但可接受;registration 的 status 用 tinyint 枚举而不是字符串,判断快、占用小。amount 必须 decimal(10,2),金额字段用 double 纯属给自己埋雷。联合索引 idx_doctor_date 直接顶住「查某医生某天已挂号数」这个高频查询,没有它数据量过万后页面会明显变慢。

3. 核心模块拆解:JWT 鉴权、挂号排班与药品库存事务

3.1 JWT 登录鉴权:后端拦截器与前端路由守卫

登录链路不复杂:前端把用户名密码 POST 到 /api/auth/login,后端查 sys_user 验证密码,成功后用 jjwt 生成 token,token 里带 userId 和 role;前端把 token 存 localStorage,之后每个请求在 header 带 Authorization。后端用拦截器统一校验,白名单之外的接口必须有合法 token:

@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; // 跨域预检请求直接放行 } String auth = request.getHeader("Authorization"); if (auth == null || !auth.startsWith("Bearer ")) { throw new RuntimeException("未登录"); } String token = auth.substring(7); Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } }

这里两个容易被忽略的细节:OPTIONS 预检必须放行,否则前端 axios 跨域调用时控制台会报 CORS 错误;token 解析完要把 userId 和 role 塞进 request attribute,service 层直接拿当前操作人,不用再查一次库。JwtUtil 的过期时间我一般设 2 小时,密钥放 application.yml 的 jjwt.secret,而不是硬编码——源码里如果看到写死的密钥,上线前一定改掉。

前端配合的 axios 拦截器和路由守卫长这样:

axios.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = 'Bearer ' + token; } return config; }); router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.path === '/login') return next(); if (!token) return next('/login'); next(); });

前端这两段是 vue 项目里最容易抄错的地方:拦截器只负责「带上 token」,路由守卫只负责「没 token 就踢回登录页」;真正的角色权限还要靠菜单渲染和后端接口双重控制,前端隐藏菜单不等于后端放行。动态路由按角色注册这套做法,在 vue 路由文档里叫 addRoute,医院管理系统里管理员和医生的路由表分开注册就是标准用法。

3.2 挂号模块:号源校验与并发控制

挂号是业务心脏。用户挂号时要做的三件事:校验医生当天是否出诊、校验号源是否已满、生成挂号单。核心代码如下:

@Service public class RegistrationService { @Autowired private DoctorMapper doctorMapper; @Autowired private RegistrationMapper registrationMapper; @Transactional public Registration register(RegisterReq req) { Doctor doctor = doctorMapper.selectById(req.getDoctorId()); if (!doctor.workOn(req.getRegDate())) { throw new BusinessException("该医生当天不出诊"); } Integer used = registrationMapper.countByDoctorAndDate(req.getDoctorId(), req.getRegDate()); if (used >= doctor.getTotalSlots()) { throw new BusinessException("号源已满"); } Registration reg = new Registration(); reg.setPatientName(req.getPatientName()); reg.setDoctorId(req.getDoctorId()); reg.setRegDate(req.getRegDate()); reg.setSlotNo(used + 1); reg.setStatus(0); reg.setAmount(doctor.getRegFee()); registrationMapper.insert(reg); return reg; } }

@Transactional 保证挂号单插入失败不留下半截数据,但这段代码在并发下有超卖风险:两个请求同时进来都查到 used=19,都通过校验。常见做法是把「统计 + 插入」改成对记录做原子更新,执行update doctor set current_slots = current_slots + 1 where id = ? and current_slots < total_slots,影响行数为 0 就说明号源已满。我拆过好几个版本的这类系统,不少只做了校验没做原子扣减,演示没问题,上生产必须换这个方案。

对应的统计 SQL 放在 mapper XML 里,注意过滤掉已退号的单子:

<select id="countByDoctorAndDate" resultType="int"> SELECT COUNT(*) FROM registration WHERE doctor_id = #{doctorId} AND reg_date = #{regDate} AND status != 2 </select>

3.3 收费与药品库存联动:事务边界要画清楚

医生开完处方,收费员收费,药房发药扣库存,这是三个动作。最容易犯的错是把三个动作塞进一个大事务:用户交完钱,库存扣减失败,整单回滚,但支付已经完成,不能回滚。正确做法是收费单和处方状态先落库,扣库存作为独立事务,失败走人工补偿或定时任务重试:

@Transactional public void pay(PayReq req) { Prescription p = prescriptionMapper.selectById(req.getPrescriptionId()); if (p.getStatus() != 0) { throw new BusinessException("处方已收费或已作废"); } Payment payment = new Payment(); payment.setPrescriptionId(p.getId()); payment.setAmount(p.getTotalAmount()); paymentMapper.insert(payment); prescriptionMapper.updateStatus(p.getId(), 1); stockService.deductStockAsync(p); // 事务提交后再触发 }

第 4 步用异步扣库存,避免拖长支付事务。库存扣减本身走乐观锁,SQL 是update drug_stock set stock = stock - ? where drug_id = ? and stock >= ?,影响行数为 0 说明库存不足,转缺药登记。这三步的事务边界用一张表拆开看更清楚:

操作事务范围失败处理
生成收费单收费 + 处方状态整体回滚,可重试
扣减库存独立事务人工补偿 / 定时任务重试
退费收费单 + 处方 + 库存三步各管各,按状态机推进

金额计算全程用 BigDecimal,处方明细里的单价、数量、总价都是 decimal,这个系统里任何人都不该见到 double。

4. 前后端联调与部署:从 devServer 代理到打包进 Spring Boot

4.1 开发环境跨域:devServer proxy 而不是 @CrossOrigin

前后端分离开发时,前端 8080 调后端 8081,跨域第一个跳出来。很多新手直接在 Controller 上加 @CrossOrigin,开发能用,部署后一旦前后端同源,这个注解反而成隐患。常见做法是配 vue.config.js 的 devServer 代理,让前端 /api 开头的请求全部转发到后端:

module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true, pathRewrite: { '^/api': '' } } } } };

changeOrigin 一定要 true,否则后端拿到的 Host 还是前端地址,某些权限框架做域名校验会拒绝。pathRewrite 写不写取决于后端接口带不带 /api 前缀——Controller 的 RequestMapping 里有 /api 就去掉这行。这个方案解决开发期跨域又不污染生产代码,是我在 vue 项目上一直沿用的做法。

4.2 生产部署:Vue 打包放进 Spring Boot 的两种姿势

vue 打包放进 springboot 是最常见的小项目部署法。前端 build 出 dist 后,把 static 资源复制到 Spring Boot 的 resources/static 下,java -jar 启动,一个端口访问完整系统。中小诊所不需要单独 Web 服务器,这套流程足够:

cd hospital-web npm run build cp -r dist/* ../hospital-admin/src/main/resources/static/ cd ../hospital-admin mvn clean package -DskipTests java -jar target/hospital-admin.jar --server.port=8081

注意:前端路由用了 history 模式的话,刷新子页面会 404,处理方式见第 5 章。

有 nginx 的话,另一种姿势是 nginx 托管前端 dist,接口反向代理到后端,location /api { proxy_pass http://127.0.0.1:8081; }一行搞定。两种都行,小项目选第一种最简单,以后要分离部署再切 nginx。下载到的项目包一般会分前端、后端两个目录,动手前先确认这个划分,别把目录结构搞混。

4.3 参数配置:application.yml 与连接池设置

部署参数集中在 application.yml,这类项目基本都是「改三处就能跑」:数据库连接、端口、MyBatis-Plus 设置。一份常用配置如下:

server: port: 8081 spring: datasource: url: jdbc:mysql://localhost:3306/hospital?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 username: root password: your_password hikari: maximum-pool-size: 20 minimum-idle: 5 mybatis-plus: mapper-locations: classpath:/mapper/*.xml configuration: map-underscore-to-camel-case: true jjwt: secret: change-me-before-production expire-minutes: 120

关键参数对照:

配置项推荐值作用
serverTimezoneAsia/Shanghai避免时间差 8 小时
maximum-pool-size20连接池上限,够用
map-underscore-to-camel-casetruereg_date 自动映射 regDate
jjwt.secret上线必改token 签名密钥

5. 避坑:医院管理系统最容易翻车的五个地方

5.1 时间字段差八小时

现象:挂号单上的日期偶尔是前一天,日志时间比本地少 8 小时。原因:MySQL 连接串没写 serverTimezone,数据库默认 UTC,Java 侧 localtime 和它差出一个时区。解决:url 里固定加serverTimezone=Asia/Shanghai,实体字段用 LocalDateTime 而不是 Date。改完重启就一致,这个坑属于老生常谈但每批都有人踩,我至少见过十个项目栽在这上面,算是入门第一课。

5.2 药品库存并发扣减成负数

现象:药房同时发两单,库存变 -1,报表里出现负数库存。原因:扣库存用了先 select 再判断大于 0 后 update 的两步写法,两条语句之间并发进来,判断拦不住。解决:合并成一条原子 SQL,update drug_stock set stock = stock - ? where drug_id = ? and stock >= ?,返回行数小于 1 就报库存不足。号源扣减同理,凡是「读出来判断再写回去」的模式并发下都有洞,这是血泪经验换来的教训。

5.3 排班冲突只比对日期没比对时段

现象:医生一天被排两组时段,页面没提示,患者挂错号才发现。原因:排班校验只查星期几有没有排,没比对 start_time 到 end_time 的区间重叠。解决:SQL 加区间交叠条件,where doctor_id = ? and weekday like ? and start_time < #{endTime} and end_time > #{startTime},新时段开始小于旧时段结束、新时段结束大于旧时段开始,即为重叠。改完顺手把校验也用到医生换班场景。

5.4 Vue history 路由打包后刷新 404

现象:本地开发正常,打包扔进 Spring Boot 后首页能开,刷新某个子路由直接 404。原因:前端用了 history 模式,刷新时浏览器直接向后端请求 /registration 路径,后端没有对应 controller。解决:最简单是前端改回 hash 模式,url 带 #,丑但省事;后端加 WebMvcConfigurer 把非 /api 路径转发到 index.html 也行。医院内部系统不需要 SEO,我推荐直接 hash,少一层转发逻辑。

5.5 Spring Boot 版本太高带来的连锁翻车

现象:按 3.x 新建工程,复制 2.x 源码,启动报 NoClassDefFoundError,或 mapper 注入空指针。原因:Spring Boot 3.x 把 javax 迁到 jakarta 命名空间,MyBatis-Plus starter 要换新坐标,JDK 至少 17,很多机器还是 JDK 8。解决:跟着源码的 Spring Boot 版本走,不要贪新。源码是 2.7.x 就用 2.7.x;真要升 3.x,先把 javax.servlet 全换 jakarta.servlet,再升 MyBatis-Plus 到 3.5.4+,最后确认 JDK 17。顺序反着来必翻车,我已经看麻木了。

6. 上线验证:用一条完整业务链把系统跑通再交付

6.1 冒烟测试清单

改完配置别急着交差。我用一张表跑冒烟,跑通一项勾一项:

步骤操作预期结果
1管理员登录进入排班/药品管理菜单
2新建医生排班(周一上午)排班列表出现记录
3前台挂号(该医生周一)生成挂号单,号源 +1
4重复挂号同一时段号源满后提示已满
5医生开处方并收费处方状态变已收费
6药房发药库存相应扣减
7退号/退费状态回退,库存/号源回补

6.2 一条 SQL 验证核心数据一致性

收尾时我习惯跑一段 SQL,把数据一致性摊开看:

SELECT d.id, d.name, (SELECT COUNT(*) FROM registration r WHERE r.doctor_id = d.id AND r.status = 0) AS waiting FROM doctor d;

查出每个医生的待就诊人数是否合理,再配合SELECT SUM(stock) FROM drug_stock看库存有没有负数。负数一条都不允许出现,这是整个系统最底线的一致性要求。项目源码建议按这个顺序跑一遍再改功能,顺序反了你会分不清是原系统的问题还是自己改动的问题。

从我把这套医院管理系统拆完、按清单跑完第三遍之后,我每次拿到别人的前后端分离源码,都会强制先看三件事:事务注解有没有乱用、连接串有没有写时区、扣库存是不是原子 SQL。三个点过完再谈其他,这个习惯帮我省掉了大量后期排查时间。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询