基于SpringBoot+Vue的医院后台管理系统实战开发全解析
这套前后端分离的医院后台管理系统用SpringBoot、Vue、MyBatis、MySQL四个技术栈搭了一套完整的业务闭环覆盖了挂号、门诊、收费、药房、住院这些医院日常运营的核心环节。市面上的医院管理系统教程很多但多数只给一段登录功能加两张表真正能跑起来、能部署、能当项目经验讲清楚前后端如何协作的反而少。这篇文章我就从项目设计、数据库建模、后端接口实现、前端页面搭建到最后的打包部署把整条链路完整拆开讲一遍尤其是我实际开发中遇到的那些坑和取舍希望对正在做毕设或者想拿完整项目练手的朋友有帮助。1. 项目整体设计与技术选型思路1.1 为什么选前后端分离这套组合医院后台管理系统有一个很典型的特点业务角色多、操作界面差异大、数据流转链路长。管理员、医生、护士、药房药师、收费员每个角色看到的页面和操作权限完全不同。如果还用传统的服务端渲染模式所有页面逻辑都堆在后端模板里前端每改一个按钮样式后端就要重新编译打包开发和维护效率会非常低。前后端分离的核心逻辑就是把数据接口和页面展示彻底拆开。后端团队只负责提供稳定的RESTful API前端团队只关注Vue组件和页面交互两边通过JSON数据通信。医院项目还有一个好处后端服务可以独立部署在服务器上前端打包成静态文件用Nginx托管平时医院内网的接口升级完全不影响正在使用的页面出了问题也能快速回滚这对业务连续性要求高的场景非常友好。技术栈选择上SpringBoot负责接口层和业务层它内置Tomcat、自动配置能力强一个jar包就能启动整个后端服务省去了大量XML配置。Vue负责前端页面它的响应式数据绑定和组件化开发非常适合后台管理这种表格表单弹窗密集型的交互场景。MyBatis作为持久层框架保留了SQL的灵活性医院业务里经常会出现多表关联、复杂统计查询用原生SQL写反而比ORM框架拼条件更直观。MySQL则是最成熟稳定的开源关系型数据库医院系统对数据一致性和事务要求高MySQL的InnoDB引擎在事务和行级锁上完全够用。1.2 从业务场景反推模块划分我不喜欢一上来就列技术清单做这类系统最好先走一遍业务。想象一下一个患者从进医院到看完病的完整流程先挂号然后去诊室看医生医生开处方患者去收费处缴费再去药房取药如果情况严重还要办理住院。每个环节都对应一个后台管理功能。所以这套系统的模块划分是跟着业务走的系统管理用户管理、角色管理、菜单权限管理门诊管理挂号管理、医生排班、门诊候诊医生工作站患者接诊、电子处方单录入、检查检验申请收费管理费用结算、退费管理、收费日结报表药房管理药品信息维护、库存管理、出入库记录住院管理入院登记、病房床位管理、医嘱执行这些模块之间不是孤立的。挂号会产生一条挂号记录医生接诊需要关联这条记录开处方又关联挂号ID收费时根据处方明细计算费用药房发药时扣减库存。如果一上来就埋头写代码很容易漏掉这些关联关系。先画清楚业务流程图再设计表结构后端的Controller和前端页面都是水到渠成的事。1.3 技术栈落地时的几个取舍我没有引入Spring Cloud微服务那套原因很简单医院后台管理系统属于典型的单体应用用户量撑死几百人并发拆成微服务只会增加服务注册、配置中心、网关这些复杂度对业务本身没有任何收益。单应用加前后端分离已经是这个规模下性价比最高的架构。持久层我用的是原生MyBatis而不是MyBatis-Plus这算是一个有意的选择。MyBatis-Plus确实写CRUD很方便内置方法连SQL都不用写但问题在于项目经验面试的时候面试官问你MyBatis的Mapper XML怎么传参、动态SQL标签有哪些你如果只会用Plus的BaseMapper很容易露馅。原生MyBatis能让你真正理解SQL映射、参数绑定和缓存机制学会之后切到Plus也就是加个依赖的事。另外医院系统的很多查询需要自定义动态SQLMyBatis的if、foreach标签在这里比Plus的Wrapper更加直观可控。MySQL版本我建议用5.7或者8.0两个版本我都跑过。5.7稳定、资料多网上搜报错基本都有答案8.0性能更好窗口函数、JSON类型都支持但连接驱动要换com.mysql.cj.jdbc.Driver。考虑到教程的普适性下面所有示例我都基于MySQL 5.7写如果用的8.0注意驱动类名和时区参数就好。SpringBoot版本这里要特别注意。现在很多新项目直接上SpringBoot 3.x但3.x要求JDK 17而且包名从javax改成了jakarta网上大量老教程的代码在3.x下面根本跑不起来。我做这套系统用的SpringBoot 2.7.x JDK 8 MyBatis 2.x这是目前兼容性最好、资料最多的组合。你如果非要尝鲜用3.x记得所有import javax.servlet.*改成import jakarta.servlet.*MyBatis也需要用对应的starter版本。别让版本问题成为拦路虎稳定跑起来比追求新版更重要。2. 数据库设计与核心表结构2.1 表结构怎么规划才不乱数据库是这套系统的地基。我见过很多新手项目表建得随意字段类型混乱比如金额用double存时间用varchar存后面做统计回调数据全乱了。我建表前花了半天时间统一规范主键全部用bigint自增金额用decimal(10,2)时间用datetime状态字段用tinyint配合字典表解释逻辑删除用deleted字段所有表都带create_time和update_time。整个系统一共设计了20张表核心表如下表名用途关键字段sys_user系统用户医生/药师/收费员/管理员username、password、real_name、role_idsys_role角色表role_name、role_keysys_menu菜单权限表menu_name、parent_id、path、permspatient患者档案表patient_name、id_card、phone、genderregistration挂号表patient_id、doctor_id、dept_id、visit_date、statusdept科室表dept_name、leader、phonedoctor_info医生信息表user_id、dept_id、title、introductionprescription处方表registration_id、doctor_id、total_amount、statusprescription_item处方明细表prescription_id、drug_id、quantity、pricedrug_info药品信息表drug_name、specification、unit、price、stockdrug_stock_record药品出入库记录drug_id、type、quantity、operator_idsettlement收费结算表registration_id、amount、pay_type、statusbed病床表bed_no、ward_id、statushospitalization住院记录表patient_id、bed_id、admission_date、discharge_date这些表之间的外键关联我没有直接在数据库层面强制加而是通过业务代码维护。原因很简单医院系统后续很可能要做分库分表或者数据清洗物理外键会变成约束。逻辑关联靠ID字段保证查询时用JOIN拼性能更好也更灵活。2.2 关键表设计背后的思考就拿registration挂号表和prescription处方表来说它们的字段设计直接影响后面功能好不好写。挂号表我记录了visit_date和visit_time两个字段上午下午分开方便医生排班查询当天号源。同时加了一个status字段0表示待就诊1表示已就诊2表示已取消这样收费和药房模块通过状态就能判断是否允许发药不用去关联一堆业务表。处方表的total_amount字段是冗余存储的。正常做法是通过处方明细表里每条药品的quantity * price累加算出总额但每次结算都去累加一遍代码啰嗦而且效率低。我在生成处方的时候就直接算出总额存进去收费模块直接读这个字段明细表和汇总表的数据一致性通过事务来保证。这就是典型的冗余换性能思路但要记住冗余字段必须由程序保证同步更新否则会出现对不上的情况。密码存储方面sys_user表的password字段我用的BCrypt加密不是MD5。MD5加盐虽然也能用但BCrypt每次加密结果不同防彩虹表攻击更稳而且Spring Security自带BCryptPasswordEncoder可以直接用。这个是安全红线医院数据涉及患者隐私密码绝不能明文存。2.3 建表SQL里的三个细节坑第一字符集必须用utf8mb4而不是utf8。utf8在MySQL里最多存3个字节患者姓名如果出现生僻字或者表情符号直接报Incorrect string value错误utf8mb4才是完整的4字节UTF-8编码。库、表、字段三级都要指定。第二金额字段一律decimal(10,2)禁止用float和double。浮点数在计算机里是二进制存储的0.1加0.2可能等于0.30000000000000004做药品价格、结算金额这种敏感数据精确计算是底线。Java对应用BigDecimal接收前端也要注意精度。第三每个表都加create_time、update_time两个时间字段并且设置DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP插入和更新的时候会自动维护省去在业务代码里手动塞时间的麻烦。逻辑删除字段deleted默认值设为0所有查询条件里都要带上deleted 0。3. 后端实现SpringBoot MyBatis 的核心细节3.1 工程目录分层与职责边界后端工程我用了标准的四层结构Controller层负责接收请求和参数校验Service层处理业务逻辑和事务边界Mapper层只做数据库读写Entity层对应表结构。这套分层虽然老但职责清晰后续加人维护不会乱。很多新手喜欢在Controller里直接写业务代码当时觉得快等要复用逻辑的时候就傻眼了。一个患者挂号的请求流程大概长这样前端POST一个JSON到/api/registrationController接收后调用RegistrationService.register()Service里先查医生当天号源是否已满然后插入挂号记录再更新号源余量这两个操作在同一个事务里。事务的注解Transactional加在Service方法上默认遇到RuntimeException就回滚这是保证数据一致性的关键。我在代码里还统一封装了一个返回体ResultT结构是{ code, message, data }。code为200表示成功401表示未登录403表示无权限500表示服务器异常。前端Axios拦截器统一根据code做处理不需要每个接口单独写错误提示。这个设计强烈建议照抄比返回裸数据好用太多。3.2 JWT登录认证与拦截器的实现这个系统的登录认证用的是JWT方案。用户输入用户名密码后端校验通过后生成一个带过期时间的token返回给前端前端每次请求都在Header里带上Authorization: Bearer token。后端写一个拦截器统一解析token解析失败直接返回401成功则把用户ID放进ThreadLocal后续业务代码随时可以拿到当前操作人。JWT生成的核心代码如下使用io.jsonwebtoken库public String generateToken(Long userId, String username) { Date now new Date(); Date expireDate new Date(now.getTime() 3600L * 1000 * 24); // 24小时过期 return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }这里有两个优化点。第一token过期时间设置24小时但医院系统里医生可能连续值班用着用着突然被踢下线很影响工作。我在拦截器里加了剩余有效期小于4小时则自动续期的逻辑响应头里返回新的token前端统一替换。第二token里不要放敏感信息JWT的payload只是Base64编码不是加密任何人解码都能看到内容。拦截器配置要注意放行白名单登录接口、验证码接口、静态资源这些不需要拦截其他接口全部走拦截器。另外CORS跨域配置必须和拦截器一起定义否则前端请求会先被CORS拦截干掉。我用的WebMvcConfigurer实现addInterceptors和addCorsMappings两个方法同一个配置类里搞定。3.3 MyBatis的Mapper写法与缓存配置MyBatis的Mapper我习惯用XML方式写因为复杂SQL在XML里格式清晰、方便调试。一个典型的分页条件查询差不多长这样select idselectPatientPage resultTypecom.example.entity.Patient SELECT id, patient_name, id_card, phone, gender, birthday, create_time FROM patient where if testkeyword ! null and keyword ! AND (patient_name LIKE CONCAT(%, #{keyword}, %) OR phone LIKE CONCAT(%, #{keyword}, %) OR id_card LIKE CONCAT(%, #{keyword}, %)) /if if testgender ! null AND gender #{gender} /if AND deleted 0 /where ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /selectwhere标签自动处理前导的ANDif标签动态拼接条件这是MyBatis最核心的用法。Mapper接口方法参数多的时候我统一用Param注解命名参数名和XML里的#{keyword}保持一致。特别注意XML里的小于号要转义成lt;否则XML解析直接报错。缓存方面MyBatis有一级缓存和二级缓存。一级缓存默认开启作用范围是一次SqlSession也就是同一个事务内相同查询不会重复查库。二级缓存需要手动开启作用域是Mapper namespace多个SqlSession共享。但这里有个大坑二级缓存对多表JOIN查询要非常小心因为缓存刷新是按namespace的update来触发的A表的缓存可能在B表更新后仍然存在查询结果就不一致了。我直接没开二级缓存医院系统的数据太多是高并发修改场景缓存带来的风险远大于性能收益真要提速就让前端做页面缓存或者用Redis。3.4 SpringBoot环境配置与版本选择建议后端项目跑起来之前最大的拦路虎就是环境。我给这套系统锁定的版本组合是JDK 1.8、SpringBoot 2.7.18、MyBatis starter 2.3.1、MySQL 5.7。这个组合经过了大量项目验证网上任何报错都能搜到解决方案。pom.xml里核心依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid-spring-boot-starter/artifactId version1.2.20/version /dependencyapplication.yml里的关键配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/hospital?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver type: com.alibaba.druid.pool.DruidDataSource mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.hospital.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case一定要开这样数据库字段patient_name自动映射成Java属性patientName不用手写一堆resultMap。log-impl配置成StdOutImpl后控制台直接打印SQL日志调试期间别关掉上线再关。Druid连接池我专门选了它的监控页面能看活跃连接数和SQL执行耗时对排查慢查询很有用。4. Vue前端工程搭建与核心页面实现4.1 开发环境配置与工程初始化前端环境配置是新手重灾区。Node.js我用的16.x版本对应npm 8.x这套组合跑Vue 3 Vite非常稳定。Node版本过高过低都可能出问题太高比如18在某些老依赖下会有兼容警告。工程创建用的Vite命令是npm create vitelatest hospital-web -- --template vue项目装依赖的时候如果遇到node_modules装一半报错先删掉整个目录再重新npm install大部分情况是网络不稳定导致包下载不完整。装完依赖记得确认一下package.json里vue版本是3.x不要装成2.x两个版本的语法差别很大。Element Plus是这个项目UI组件库的基础。后台管理的核心交互无非就是表格、表单、弹窗、菜单、分页Element Plus全都有现成封装。安装命令npm install element-plus然后在main.js里全局注册import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue import router from ./router const app createApp(App) app.use(ElementPlus) app.use(router) app.mount(#app)4.2 路由配置与权限控制前端路由这部分我用了动态路由方案。后端登录成功后会返回该用户的角色和菜单权限列表前端根据权限动态添加路由。这样医生登录看不到收费管理菜单收费员操作不了医生工作站权限控制在前后端双重生效。路由表设计成两层静态路由和动态路由。静态路由包括登录页、404页、首页所有人可见动态路由在登录后从后端拉取接口/api/menu/list前端把返回的菜单数据格式化成Vue Router能识别的route配置用router.addRoute()动态注入。菜单和路由共用同一份数据左侧菜单栏根据当前路由表自动生成。这里用到了Vue的插槽机制。我在封装菜单组件的时候父组件需要定制菜单项的图标和名称但又不想写死就用插槽暴露接口template el-sub-menu :indexmenu.path template #title el-iconcomponent :ismenu.icon //el-icon span{{ menu.name }}/span /template template v-forchild in menu.children :keychild.path sidebar-item v-ifchild.children :menuchild / el-menu-item v-else :indexchild.path{{ child.name }}/el-menu-item /template /el-sub-menu /template菜单组件递归渲染子菜单里还有子菜单就继续调用自己这个写法在后台管理项目里非常实用。4.3 Axios封装与跨域配置axios请求统一封装在utils/request.js里加上请求拦截器和响应拦截器。请求拦截器从localStorage拿token有则加到Header响应拦截器统一处理code401跳转登录页500显示错误提示。这段代码每个页面都要用封装一次后面所有接口都省事import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } if (res.code 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(res.message) return Promise.reject(new Error(res.message)) }, error { ElMessage.error(error.message) return Promise.reject(error) } ) export default request跨域问题在开发阶段通过Vite的proxy配置解决。前端跑在5173端口后端跑在8080端口浏览器会拦截跨域请求。在vite.config.js里配置export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })所有以/api开头的请求都会代理到后端的8080端口前端代码里不需要写完整域名的接口地址。这里有个细节后端的Controller路由如果是/api/registration/list那前端baseURL用/api请求路径直接写/registration/list不要重复拼接/api。4.4 核心页面拆解挂号与处方挂号页面是前端交互最典型的例子。左边是患者信息表单右边是当天医生排班列表。患者档案已经存在的搜到后直接选中新患者先填档案再挂号。医生排班列表通过科室下拉联动加载选中医生后展示剩余号源。这个页面用Element Plus的el-form做表单校验手机号正则、身份证号长度都做在前端减少无效请求。医生开处方页面用了一个动态表格来维护药品明细左上角选药品选中的药品自动加到下面的处方明细表格可以修改数量和单价表格底部自动汇总总额。这里有个有意思的实现Vue的响应式数据渲染表格时修改输入框的值不会立刻更新金额需要在change事件里手动重新计算。计算公式function calTotal() { const total drugItems.value.reduce((sum, item) { return sum item.quantity * item.price }, 0) totalAmount.value total.toFixed(2) }用reduce遍历所有明细累加数量乘单价最后toFixed(2)保留两位小数。注意前端只做展示用的金额计算最终结算以后端计算的总额为准防止有人通过修改前端请求绕过校验。页面样式方面后台管理系统不需要花哨的前端特效干净、整齐、信息层次分明才是核心。Element Plus默认主题色是蓝色我通过CSS变量覆盖的方式换成了一套偏医疗风格的绿色同时把表格的stripe属性和border属性打开数据多的表格加上height固定表头操作体验会比默认样式好很多。5. 从开发到部署的全流程跑通5.1 本地启动MySQL、后端、前端的启动顺序整套系统的启动跑通我总结出一个标准顺序照着做基本不会出问题。第一步安装并启动MySQL。Windows环境下我推荐直接下载MySQL Installer图形化安装选Server only一路Next即可。安装完成后在服务管理器里启动MySQL服务然后用命令行或者Navicat创建一个数据库CREATE DATABASE hospital DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;再导入项目里的hospital.sql脚本。导入完成后执行SELECT COUNT(*) FROM sys_user;能查到数据说明数据库正常。第二步启动后端。用IDEA打开后端工程等待Maven把依赖下载完检查application.yml里的数据库用户名密码是否和本机一致。直接运行HospitalApplication.java的main方法看到控制台输出Tomcat started on port(s): 8080就成功了。这一步报错最密集的就是数据库连不上检查顺序是MySQL服务是否启动、密码是否正确、连接URL里的数据库名是否存在、驱动版本是否匹配。第三步启动前端。进入hospital-web目录先npm install再npm run dev控制台输出Local: http://localhost:5173/后在浏览器打开。输入后端提前建的测试账号admin/123456能登录进入系统说明前后端联调成功。5.2 打包部署jar包加静态资源的组合拳开发环境跑通只是第一步真正要交付给客户或者做项目展示还得打包部署到服务器上。后端打包很简单在项目根目录执行mvn clean package -DskipTeststarget目录下会生成一个hospital-0.0.1-SNAPSHOT.jar。把这个jar上传到服务器执行java -jar hospital-0.0.1-SNAPSHOT.jar就能启动。生产环境建议用nohup后台运行nohup java -jar hospital-0.0.1-SNAPSHOT.jar server.log 21 前端打包执行npm run build构建产物在dist目录。把dist目录里的所有文件复制到Nginx的html目录下配置一个location把请求转发给后端server { listen 80; server_name localhost; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html;这行是前端路由History模式的关键。它保证用户刷新/patient/list这个页面时Nginx不会返回404而是把请求重写到index.html由Vue Router重新识别路由。5.3 常见问题速查表我把整个开发部署过程中最容易踩的坑整理成了表格每个问题都加上排查思路问题现象根本原因解决办法后端启动报Access denied for user数据库密码错误或权限不足检查application.yml密码确认root账号允许localhost访问连接数据库报Public Key Retrieval is not allowedMySQL 8.0驱动认证问题JDBC URL加allowPublicKeyRetrievaltrue前端npm install卡住不动网络问题删除node_modules重新装或配置npm源前端请求接口报CORS错误跨域未配置确认后端addCorsMappings配置或Vite proxy是否生效登录接口能通但业务接口401token未传递检查axios请求拦截器Header配置是否正确页面刷新后404前端路由History模式nginx未配置添加try_files $uri $uri/ /index.htmlMyBatis报Invalid bound statementXML文件路径不对检查mapper-locations和MapperScan是否匹配页面上中文全部乱码字符集不统一数据库连接URL加characterEncodingutf8mb4前端meta标签加charset5.4 这套项目后续还能怎么扩展医院管理系统做完主干功能后自然会有一些扩展方向。第一个是加Redis做缓存和会话管理JWT虽然无状态但用户权限变更、强制下线这种需求还是需要一个Redis来做黑名单控制。第二个是加文件上传功能患者检查报告、病历图片这些都可以用MinIO或者本地存储对接。第三个是加报表统计收费日报、药品消耗分析用ECharts做成可视化图表再配上定时跑批任务生成Excel导出。这些都是我实际接到过类似需求后总结的方向。框架搭得清晰扩展就不会伤筋动骨这也是前后端分离架构加分层设计的最大价值所在。我个人在实际操作中的体会是这类完整项目最大的学习价值不在某个单一技术点而在于让你体会一套系统从需求到上线的完整链路。你会看到一张表结构设计是如何影响后续三个月开发的会理解一个Transactional注解背后是数据一致性的底线也会明白为什么别人都说部署一小时踩坑半小时。如果你拿到手只是把代码跑起来就放仓库落灰那这套系统的价值你连十分之一都没拿到。我建议你把数据库脚本从头到尾看一遍把SpringBoot的启动流程断点跟一遍再自己尝试加一个退费管理页面走完这轮你才算真正掌握了这套前后端分离项目的精髓。