ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

SpringBoot+Vue企业级医院后台管理系统源码深度解析

SpringBoot+Vue企业级医院后台管理系统源码深度解析 医院项目在软件开发行业里属于典型的“业务规则重、数据敏感、并发不算特别高但绝对不能错”的类型。当时我拿到这套企业级医院后台管理系统源码时第一反应是这不是个练手demo而是一套能直接改造成生产级系统的骨架。技术栈是最主流的SpringBoot Vue MyBatis MySQL前后端分离权限、挂号、门诊、药房、住院、统计这些模块全都覆盖了。对于想深入做企业级应用、想搞懂真实业务系统怎么落地的Java工程师来说这套源码的参考价值相当大能省下不少自己从零摸索的时间。这套系统解决的问题很实在医院门诊流程怎么串起来医生排班怎么和挂号库存联动药房库存怎么做到扣减和回滚还有最头疼的权限控制怎么在不同角色之间不泄露数据。对刚入门SpringBoot的人来说它是一个完整的多模块工程能学到的不只是CRUD对想快速交付项目的团队来说它又是一份可以直接二次开发的蓝本。我后面所有分析都会围绕这套源码的实际结构和踩坑经验展开尽量讲清楚每个设计背后的原因。1. 项目整体架构与模块拆解1.1 系统定位与技术选型考虑先说这套系统的定位。它不是“单体巨石”那种老式JSF项目而是前后端完全分离的Web应用。后端只提供RESTful API前端用Vue负责渲染和交互两者之间靠JSON通信。这样做的好处很明显后端服务可以被多个端复用——以后要加个患者自助机、微信小程序直接调同一套API就行不用再写一套逻辑。技术选型放在今天依然是性价比最高的组合SpringBoot负责快速搭建后端服务内嵌Tomcat打一个jar包就能跑省去了繁琐的XML配置。相比传统SSH框架开发效率高了一个数量级。Vue负责前端页面组件化开发让医生工作台、药房管理、挂号收费这些界面可以独立维护互不干扰。MyBatis作为持久层框架SQL由自己掌控复杂联表查询、统计报表写起来很直接不会像JPA那样遇到复杂查询就头疼。MySQL存储业务数据稳定、免费、运维生态成熟对医院这种读多写少、事务要求高的场景完全够用。这套组合的最大优势是“可控”。每一层都有明确的边界出了问题能快速定位。对于医院后台这种业务逻辑复杂的系统可控比花哨更重要。1.2 功能模块全景图该系统核心模块可以分成六大块配套源码里基本都实现了闭环。模块核心功能涉及主要表系统管理用户、角色、菜单、部门、操作日志sys_user、sys_role、sys_menu挂号管理号源排班、患者挂号、退号、队列管理registration、schedule门诊医生工作站接诊、病历书写、开检查单/处方medical_record、prescription药房管理药品入库、库存台账、发药/退药drug、drug_stock、dispensing收费管理挂号费、处方费用收取、退费payment、payment_detail统计报表门诊量、医生工作量、药品消耗汇总基于以上业务表聚合每个模块都有独立的Controller、Service、Mapper三层结构包名清晰能直接看出业务边界。这套源码对学习“如何按领域拆包结构”特别有帮助真实工作中团队协作时这种清晰的分层能省掉大量合并冲突。在这里说一个关键点医院系统的模块不能只按“功能菜单”切还要按“数据权限”切。比如同一个药品库存表药房主任能看全院数据普通药师只能看自己药房的数据。这套源码在查询时通过dept_id过滤来实现前端菜单权限只是入口后端才是真正的守门员。这是企业级系统与个人项目的本质区别。1.3 为什么选单体架构而不是微服务很多人看到“企业级”三个字就想到微服务但医院后台管理系统用单体架构反而正确。原因很实际第一业务体量没到需要微服务的程度。医院后台的并发量一般就是几百人同时操作单库单表加索引优化就能扛住。硬拆微服务只会增加分布式事务、服务注册发现、配置中心这些复杂度。第二医院系统的事务边界非常强。一次挂号流程要扣号源、生成订单、记录操作日志跨服务调用的分布式事务会让人崩溃而单体架构一个Transactional注解就搞定了。第三运维成本低。医院信息科不像互联网公司有专门的运维团队单体架构部署简单一台服务器一个jar包一个Nginx就完事出了问题也好排查。所以我的建议是不要为了技术而技术。这套源码用单体模块化分层的方案在实际落地中是最稳的选择。虽然标题里是“管理系统管理系统”但本质上强调的是“一个可以直接上线的完整系统”重点在完整两个字而不是复杂度。2. 后端核心SpringBoot与MyBatis的深度配合2.1 工程结构与启动流程先看工程目录这是典型的Maven多模块结构或者单模块分层包结构核心分包如下com.hospital.system ├── common // 通用工具、异常处理、常量 ├── config // SpringBoot配置类比如拦截器、跨域 ├── controller // 接口层只做参数接收和响应封装 ├── service // 业务层事务边界 ├── mapper // MyBatis接口 ├── entity // 数据库实体 ├── dto/vo // 前端交互对象 └── security // JWT认证与鉴权启动流程不复杂SpringBootApplication启动类开启组件扫描application.yml加载数据源和MyBatis配置启动时MyBatis扫描mapper接口并加载XML文件。有一点容易被忽略MyBatis的mapper接口和XML文件必须同名且在对应目录下否则启动直接报Invalid bound statement错误。这个我用源码实测XML放在resources/mapper下接口在mapper包下靠mapper-locations: classpath:mapper/*.xml关联。2.2 MySQL数据库设计要点这套系统的表结构有几个设计值得反复琢磨2.2.1 主键选择。用户表、挂号表都用BIGINT AUTO_INCREMENT自增主键。不用UUID是因为医院系统要经常做范围查询和排序自增主键在InnoDB里B树插入顺序性好页分裂少查询性能明显优于随机UUID。2.2.2 核心业务表必须带逻辑删除标志。比如药品表、医生排班表都有status字段用0和1表示禁用和启用。真正删除一条就诊记录的风险太大万一医疗纠纷需要溯源物理删除就找不回来了。这个设计在医疗行业尤其重要。2.2.3 时间字段统一为DATETIME并设置默认值。比如用户表的create_time DATETIME DEFAULT CURRENT_TIMESTAMP更新时ON UPDATE CURRENT_TIMESTAMP。好处是代码里不用手动维护这些字段MyBatis插入时也不容易漏。2.2.4 金额字段必须用DECIMAL(10,2)。药品单价、挂号费、支付金额全部是定点数严禁用FLOAT或DOUBLE。医疗计费对精度要求极高0.10.2的浮点误差在财务对账时就是大事故。还配套了一部分初始化SQL脚本包括基础数据比如管理员账号、系统菜单、角色权限关联。首次部署导入SQL就能把系统跑起来不需要自己一个个建表。要注意的一点是数据库连接串一定要带characterEncodingutf8serverTimezoneAsia/Shanghai。MySQL 8.x的驱动对时区敏感不带serverTimezone会直接报The server time zone value异常。这几乎是每个新手部署必然踩的坑。2.3 MyBatis映射技巧与缓存配置MyBatis在这套系统里承担所有SQL操作。XML写法上有两个核心点值得展开结果映射自动驼峰。application.yml里开启map-underscore-to-camel-case: true数据库字段create_time自动映射到Java属性createTime。这能少写大量resultMap代码干净很多。动态SQL复用。多条件组合查询在系统中很常见比如挂号记录按姓名、日期、科室筛选。XML里用where标签自动处理多余的AND/ORselect idselectRegistrationList resultTypeRegistrationVO SELECT * FROM registration where if testpatientName ! null and patientName ! AND patient_name LIKE CONCAT(%, #{patientName}, %) /if if testdeptId ! null AND dept_id #{deptId} /if if testregDate ! null AND DATE(reg_time) #{regDate} /if /where ORDER BY reg_time DESC /select这种写法比在Java里拼SQL字符串安全得多既避免了SQL注入风险又让SQL逻辑集中在XML里可维护。MyBatis缓存是重点我这里专门踩过坑。系统开启了二级缓存cache evictionLRU flushInterval60000 size512 readOnlytrue/。设置LRU淘汰策略、一分钟刷新、最多512个对象。看起来挺合理但实际运行中发现一个问题当多表联查时二级缓存可能会读到脏数据。因为缓存是基于namespace的。比如prescription表的查询缓存了药品名称这时候药品表的数据被另一个事务改了prescription的缓存压根不知道继续返回旧数据。解决方案是在涉及频繁更新的业务表上关闭二级缓存只对字典表、科室表这种低频变更的只读数据开启。这套源码的字典表都开了缓存业务单据表基本不用。这个思路值得直接借鉴。2.4 分页插件用法与防坑指南列表页查询是后台系统的刚需这套源码用了PageHelper分页插件用法很简单dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependencyPageHelper.startPage(pageNum, pageSize); ListUserVO userList userMapper.selectUserList(condition); PageInfoUserVO pageInfo new PageInfo(userList);原理是基于MyBatis拦截器在执行SQL前自动拼接LIMIT语句。这里我给大家三个实践心得第一PageHelper.startPage()后面必须紧跟第一条SQL查询。中间如果穿插了其他查询分页就会作用到错误的SQL上。我曾经在startPage和查询之间加了一条字典查询结果列表数据没分页倒是字典表被LIMIT了这个错非常隐蔽。第二不需要返回总数时用PageHelper.startPage(pageNum, pageSize, false)。比如导出Excel不用count查询能节约一次数据库往返。大表count查询其实挺慢的。第三排序字段不要直接拼在SQL里。如果不做白名单校验用ORDER BY ${sortField}等于把排序字段的位置给了用户存在注入风险。正确做法是用代码里的字段映射表把允许排序的属性名映射到数据库列。3. 前端实践Vue项目的搭建与联调3.1 Vue工程初始化与目录规划这套源码的前端基于Vue 2和Element UI也可以用Vue 3 Element Plus改造工程是标准的Vue CLI初始化后的目录src ├── api // 接口请求封装按模块拆分文件 ├── assets // 静态资源 ├── components // 通用组件比如分页、弹窗、表单 ├── layout // 框架布局侧边栏顶栏主内容区 ├── router // 路由配置 ├── store // Vuex状态管理 ├── utils // 请求工具、鉴权工具 └── views // 页面组件按模块分文件夹目录规划的核心思路是“按业务模块切文件不按技术类型堆文件”。比如api/registration.js、views/registration/index.vue、views/registration/detail.vue这样每个业务模块的前端代码都聚合在一起改需求时只需进一个目录不用东翻西找。安装依赖时有个值得注意的问题默认的npm install在某些网络环境下容易卡住建议用npm install --registryhttps://registry.npmmirror.com切换镜像源。装完依赖后执行npm run dev本地开发服务器默认跑在localhost:8080为了避免和后端端口冲突工程里一般会配置devServer.port8081并开启代理。3.2 路由设计与权限控制路由设计分两部分静态路由和动态路由。静态路由只有登录页和404页其他所有页面都在用户登录后根据角色权限动态生成。这套思路很关键因为真正的权限控制不能只靠前端隐藏菜单必须做到“没权限的路径直接不能访问”。动态路由的生成逻辑大概是// 登录成功后根据用户角色从后端获取菜单权限 const menuList await getMenuList() const asyncRoutes generateRoutes(menuList) router.addRoutes(asyncRoutes)generateRoutes会遍历后端返回的菜单树匹配到对应的Vue组件路径然后动态挂到路由表上。这里有个易错点打包后动态导入的组件路径必须用() import(/views/ componentPath)这种写法如果写成字符串拼接import(/views/ path)Webpack可能无法正确打包所有组件导致访问某个路由时白屏。路由守卫也是必写的router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() } else if (!token) { next(/login) } else { next() } })这个守卫只处理“有没有登录”不处理“有没有权限”因为动态路由本身就保证了权限。注意localStorage存token会有XSS风险所以这套源码在axios拦截器里做了基础的输入过滤提交到后端的数据会统一清理script标签防止存储型XSS。3.3 接口请求封装与拦截器前端所有接口请求都封装在一个request.js工具里基于axios二次封装。核心代码import axios from axios const service axios.create({ baseURL: /api, timeout: 15000 }) // 请求拦截器统一携带token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) // 响应拦截器统一处理返回码和错误 service.interceptors.response.use( response { const res response.data if (res.code ! 200) { // 业务错误统一提示 return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { // token失效跳转登录 localStorage.removeItem(token) window.location.href /login } return Promise.reject(error) } )这套封装带来的直接好处是业务代码里不用每次写token拼接和错误处理每个API函数只需要关心参数和返回值。比如挂号列表export function getRegistrationList(params) { return service({ url: /registration/list, method: get, params }) }页面里调用时就很干净const res await getRegistrationList({ pageNum: 1, pageSize: 10 }) this.tableData res.list这里有一个非常实用的联调心得前端开发时务必配置代理避免跨域。在vue.config.js里写module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } } } }这样前端代码里的/api/login会代理到http://localhost:8080/login绕开了浏览器跨域限制。生产环境则通过Nginx配置相同的反向代理规则。不配置代理的话前后端联调时Access-Control-Allow-Origin问题会浪费大量时间。3.4 前端开发中的常见坑这一节记录几个我在使用这类Vue后台项目时经常遇到的问题也是新手最容易被卡住的地方。第一个坑Element UI表单校验不生效。原因一般是prop属性和v-model绑定的字段名不一致。比如el-input v-modelform.nameel-form-item propuserName两者对不上校验规则就永远触发不了。这个问题极其普遍排查时直接对照prop和v-model字段。第二个坑路由跳转但页面不刷新。在复用同一个组件的情况下比如从挂号列表跳转到挂号详情两次跳转路由不同但组件是同一个created钩子不会重新执行导致页面数据不更新。解决方案是给router-view加:key$route.fullPath强制路由变化时重新创建组件。这个小技巧几乎每个后台项目都要用。第三个坑打包后上线资源路径404。默认Vue CLI打包出的静态资源是绝对路径/js/xx.js如果部署在子目录比如http://server/hospital就找不到资源。解决方法是vue.config.js里设publicPath: ./同时路由改用hash模式createWebHashHistory。这套源码建议直接用hash路由虽然URL里多个#号但部署省心很多不用配服务端回退。4. 核心业务模块实现细节4.1 登录认证与JWT鉴权认证模块是系统的安全入口也是很多人第一个想看的代码。这套源码采用JWT SpringBoot拦截器的方式实现无状态认证。用户在登录页输入用户名密码后端AuthController接收后调用UserService校验账号密码。密码存储用的是BCrypt加密不是MD5。BCrypt每次加密结果都不同彩虹表基本撞不出来。存数据库时是这样处理的String encodedPassword BCrypt.hashpw(rawPassword, BCrypt.gensalt());校验时用BCrypt.checkpw(inputPassword, encodedPassword)。登录成功后后端生成JWT字符串返回给前端String token Jwts.builder() .setSubject(userId.toString()) .claim(username, user.getUsername()) .claim(role, user.getRole()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();JWT的好处是后端不需要存Session用户身份信息藏在token里适合前后端分离和水平扩展。但要注意JWT无法主动失效如果用户被禁用只要token没过期他依然能访问。解决方式是在用户表加status字段每次请求时在拦截器里检查用户状态禁用的话直接踢出。拦截器核心逻辑Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(Authorization).replace(Bearer , ); try { Claims claims Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody(); request.setAttribute(userId, claims.getSubject()); return true; } catch (Exception e) { response.setStatus(401); return false; } } }注册拦截器时要注意addPathPatterns(/**).excludePathPatterns(/login, /captcha)登录接口本身不能拦截否则就是自己把自己锁在门外了。4.2 挂号业务流程与事务控制挂号是医院系统的核心业务涉及多张表的联动更新判断当前排班号源是否充足生成一条挂号记录状态为“已挂号”扣减排班表剩余号源如果是初诊还要创建患者档案这四个步骤必须在一个事务里完成任何一步失败都要回滚。源码里的核心逻辑大概是这样Transactional(rollbackFor Exception.class) public RegistrationVO createRegistration(RegistrationDTO dto) { Schedule schedule scheduleMapper.selectByIdForUpdate(dto.getScheduleId()); if (schedule.getRemaining() 0) { throw new BusinessException(号源已满); } if (dto.getIsFirstVisit()) { Patient patient patientMapper.selectByPhone(dto.getPhone()); if (patient null) { patient patientMapper.insert(...); } } Registration reg new Registration(); // 设置属性... registrationMapper.insert(reg); schedule.setRemaining(schedule.getRemaining() - 1); scheduleMapper.updateById(schedule); return buildVO(reg); }关键操作是selectByIdForUpdate这里用了悲观锁。为什么不用乐观锁因为号源是临界资源并发情况下用版本号字段容易产生大量重试而SELECT ... FOR UPDATE在InnoDB行锁下能实实在在地串行化同一排班的号源扣减。等事务提交后再释放锁其他请求才能继续。这对支撑“号源不能超卖”的需求是最稳妥的。一个值得注意的小细节号源不足时抛业务异常BusinessException会被全局异常处理器捕获返回统一的{code: 500, message: 号源已满}格式给前端。这套全局异常处理机制很完善代码里定义了一个RestControllerAdvice类把参数校验、业务异常、系统异常分别返回前端只需要处理统一的消息结构不用关心具体异常类型。4.3 药房库存与发药逻辑药房模块涉及药品入库、库存台账、发药出库最容易出现的问题是“账面库存和实物不一致”。源码的库存设计用了流水台账模式drug表存药品基础信息drug_stock表存当前库存余量stock_record表存每次入库、出库、盘点的流水每次发药时不是在drug_stock上直接减数量而是先写一条stock_record再更新drug_stock的剩余量。这样即使哪天数据对不上也可以通过流水回放找出哪一步出了问题。这是医疗库存系统的一个核心设计理念任何库存变化都要有据可查。Transactional public void dispenseDrugs(Long prescriptionId) { ListPrescriptionItem items prescriptionItemMapper.selectByPid(prescriptionId); for (PrescriptionItem item : items) { DrugStock stock drugStockMapper.selectByDrugIdForUpdate(item.getDrugId()); if (stock.getQuantity() item.getCount()) { throw new BusinessException(药品库存不足); } stock.setQuantity(stock.getQuantity() - item.getCount()); drugStockMapper.updateById(stock); StockRecord record new StockRecord(); record.setDrugId(item.getDrugId()); record.setChangeType(OUT); record.setChangeCount(item.getCount()); record.setOrderNo(prescriptionId); stockRecordMapper.insert(record); } }用selectByDrugIdForUpdate同样是为了在并发发药时不超卖。药品库存和号源一样都是临界资源行锁是必须的。另外药品有效期管理也是医院系统的特色需求drug_stock表里会有batch_no和expire_date字段发药时优先发放临期批次FEFO策略这套源码在这方面也有对应的实现思路。4.4 统计报表与数据聚合统计模块是医院管理层天天看的东西今日门诊量、各科室收入、药品消耗排名、医生工作量。实现方式很简单就是按时间段聚合查询。这里有个性能优化的技巧统计报表不要查业务大表而是查汇总表。如果每天几万条挂号记录直接GROUP BY高峰期查询会拖慢业务库。常见的优化手段是定时任务每天凌晨把前一天的挂号、收费数据汇总到report_daily表报表页面只查汇总表同时给reg_time、dept_id建组合索引。这样做报表查询秒出又不影响业务库性能。SpringBoot里用Scheduled加一个定时任务类就能实现别忘了在启动类标记EnableScheduling。第一次跑报表时还要注意时区问题定时任务用的是服务器时间如果服务器是UTC时间凌晨执行的其实是北京时间早上8点数据归集就错了。生产环境务必统一timezone配置。5. 部署实战与问题排查5.1 环境准备与配置说明要跑起这套系统需要准备的环境软件版本建议说明JDK1.8SpringBoot 2.x最稳Maven3.6依赖管理MySQL5.7或8.0建议8.0驱动用com.mysql.cj.jdbc.DriverNode.js14Vue前端构建环境Nginx1.20生产环境静态资源和代理安装MySQL时建议直接解压版或者用Docker部署Docker的方式更快docker run -p 3306:3306 --name mysql8 \ -e MYSQL_ROOT_PASSWORD123456 \ -e TZAsia/Shanghai \ -d mysql:8.0启动后记得新建数据库CREATE DATABASE hospital_db DEFAULT CHARACTER SET utf8mb4;然后导入项目SQL脚本。导入前一定要核对字符集如果建库时用了latin1中文会直接变乱码。用utf8mb4是最稳的不仅支持中文还能存emoji表情。5.2 打包部署流程后端打包mvn clean package -DskipTests打出的jar包在target/目录上传到服务器执行java -jar hospital-system.jar --spring.profiles.activeprod建议用nohup后台运行并输出日志nohup java -jar hospital-system.jar app.log 21 前端构建npm run build生成dist目录把里面的文件传到Nginx的web目录然后配置反向代理server { listen 80; server_name hospital.example.com; root /opt/hospital/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里的坑在proxy_pass路径上。如果location /api/且proxy_pass http://127.0.0.1:8080/会把/api前缀去掉再转发比如/api/login变成/login。这要和后端代码里的RequestMapping路径对应否则就是404。5.3 常见问题速查表我把自己实操中遇到的高频问题整理成下表每一行都是真实踩坑记录现象原因解决方案SpringBoot启动连不上MySQL数据库未启动或连接串错误检查spring.datasource.url确认3306端口可通MyBatis报Invalid bound statementMapper接口和XML路径不匹配检查mapper-locations配置和XML包路径前端请求接口跨域未配置代理或未开启CORSdev环境用vue.config.js代理生产用Nginx登录成功后刷新页面就掉线Vuex刷新即失没有持久化登录信息同时存localStorage并做store初始化读取分页数据被第二条SQL影响PageHelper.startPage后用了其他查询保持startPage后紧跟目标查询药品扣减出现负数未使用行锁或事务回滚不完整加selectByIdForUpdate确保并发安全打包后点击菜单白屏动态路由懒加载地址写错组件路径必须用() import()完整的相对地址报表数字对不上定时任务服务器时区不对统一设置JVM时区-Duser.timezoneAsia/Shanghai这套平台还有两个扩展方向可以聊聊。一个是对接医保接口医保报销是医院信息化绕不开的环节通常需要把基础数据通过WebService上传到医保中心再处理返回的报销结果。另一个是引入消息队列比如药房发药成功之后系统的通知推送、短信提醒都可以通过MQ异步处理降低接口响应时间。这套源码眼下是同步调用但对于学习如何扩展中间件场景很有参考价值。我在实际使用这套源码的过程中最大的体会是一个完整的医院后台管理系统代码量不是最难的难的是把每个业务流程的事务边界、数据一致性、权限控制做到位。这套源码在这些方面给了一个很好的范本。如果你打算拿它二次开发建议先从挂号流程读起它是一条串联起排班、患者档案、收费、药房发药的核心链路读懂它整个系统的数据流动就清晰了大半。最后一个小提醒项目跑起来之后第一件事是改掉默认的管理员密码然后调整CORS为白名单模式很多后台入侵案例都是从默认口令开始的。希望这份源码分析能帮你真正吃透一个企业级项目的完整脉络。
返回列表