ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Vue3+SpringBoot+MySQL的分布式医院管理系统:从单体到分布式演进实践

基于Vue3+SpringBoot+MySQL的分布式医院管理系统:从单体到分布式演进实践 做医疗信息化项目这些年我被问得最多的问题不是系统怎么开发而是这套系统为什么这么设计。很多人拿着基于Vue3 SpringBoot MySQL的分布式医院管理系统这种标题找到我第一句往往是这个技术栈是不是随便选的前后端分离到底解决了我什么问题分布式三个字我是真做了分布式吗说实话这类项目在医院信息科、创业公司、以及大部分毕业设计里已经是绝对的标配组合了。Vue3 负责用户交互界面SpringBoot 承担后端业务逻辑MySQL 做数据持久化三者通过 HTTP JSON 完成前后端解耦。我在自己的项目里也完整落地过一套包括挂号、门诊医生站、药房发药、收费结算、住院病历管理这些模块。这套组合能跑通、能上线、能扛住日常门诊量而且后续要往微服务方向演进底子也是对的。这篇文章我就以这套医院信息管理系统为例子把我实际从零搭建、到部署上线的完整过程包括技术选型理由、数据库设计、接口鉴权、并发挂号、前端工程化、跨域处理、生产部署以及从单体走向分布式的真实思路一次性讲清楚。不管是打算做毕设、还是公司要实训项目交付按这篇的思路走能少踩很多坑。1. 医院信息管理系统我为什么最终定了 Vue3 SpringBoot MySQL 这个组合1.1 先说说标题里那个分布式到底是怎么回事很多同学拿到基于Vue3 SpringBoot MySQL的分布式医院管理系统这个题目第一反应是我得拆好几个微服务用 SpringCloud 那套才行。其实不必。这类项目里出现的分布式绝大多数指的是前后端分布部署也就是前端是独立部署的 Web 站点后端是独立运行的 API 服务两边通过接口通信。这和单体应用里把页面和逻辑揉在一个工程里是相对的并不要求你把后端拆成挂号服务、收费服务、药房服务三个进程。医院管理系统这种业务数据强一致、事务边界清晰、报表和就诊链路复杂前期硬拆微服务反而把自己坑死。你在项目文档里写清楚前后端分离部署 业务模块化拆分 可水平扩容的接口服务这个定位是完全站得住脚的。真要往分布式演进那是我后面会讲的第6章内容是在这套地基上的增量改造不是推翻重来。1.2 后端锁定 SpringBoot图的不是热门两个字后端选 SpringBoot很多人给的理由是大家都在用。但真正的理由得从业务属性说。医院管理系统有一个典型特征业务规则密集且强依赖事务。一个门诊收费动作要同时扣减患者账户、生成收费记录、更新医生工作量统计、写入操作日志。任何一个环节失败整笔业务都不能落库。SpringBoot 背后的 Spring 生态对这类强事务场景的支持是最成熟的——Transactional注解声明式事务、DataSource 自动配置、MyBatis-Plus 或者 JPA 的集成全是现成的。而且 SpringBoot 的自动配置机制在处理不同运行环境差异这件事上特别省心。开发环境用本地 MySQL生产环境切到云数据库只需要改application.yml里的连接串不用改代码。如果你用application-dev.yml、application-prod.yml加spring.profiles.active来切连启动参数都能做成标准化的。我实际项目中用的是 SpringBoot 2.7.x配合 MyBatis-Plus 做数据访问。选 MyBatis-Plus 而不是 JPA是因为医院业务里有大量复杂的统计查询比如某科室过去30天各时段的挂号量分布这类 SQL 用 MyBatis-Plus 的条件构造器或者自定义 XML 写DBA 同学接手审查起来也直观。JPA 不是不行但团队里不是每个人都熟悉 Hibernate 的懒加载和一级缓存踩坑成本偏高。1.3 前端选 Vue3Composition API 对后台管理系统是真刚需如果你同时写过 Vue2 和 Vue3再回头做医院管理这种列表页 表单页 弹窗 状态流转密度极高的中后台系统感受会非常强烈Vue3 的 Composition API 太适合做业务逻辑复用了。举一个很具体的例子。挂号收费页面需要一个倒计时、一个费用明细计算、一个医保类型切换联动。在 Vue2 的 Options API 里这些逻辑散在data、methods、watch各个碎片里想抽出来复用得写 mixin而 mixin 的命名冲突问题很烦人。Vue3 里我直接用useCountDown、useFeeCalculator这种组合式函数每个 hook 把自己相关的状态和逻辑收拢在一起页面里setup中几行就串完了。代码量不一定减少但可读性和维护性提升了一个档次。另外 Vue3 目前的生态已经完全补上了。UI 组件库选 Element Plus状态管理用 Pinia路由用 Vue Router 4构建工具 Vite 5文档和质量都很成熟。2023 年之前可能还有人劝你Vue2 稳定别折腾现在再这么劝就是耽误你了。1.4 MySQL 在这个局里承担什么角色有人觉得 MySQL 不够高级想上 Oracle、SQL Server。但医院管理系统里 90% 的业务表数据量和并发量根本没有到 Oracle 不可替代的程度。一家区级医院的日门诊量大概 2000~4000 人次核心流水表一天新增几千条MySQL 单库单表毫无压力。配合 InnoDB 引擎的行级锁和 MVCC挂号这种高频操作也能撑住。MySQL 对开发者最大的友好之处是生态工具链齐全。开发调试我用 Navicat 看表结构、跑测试 SQL部署到服务器上直接用命令行或者脚本初始化数据。热词榜里出现频率很高的mysql安装教程mysql安装配置教程navicat for mysql说明这个工具的安装和配置仍然是很多人的拦路虎我后面在部署章节里会单独把容易出错的几个点拎出来讲。2. 业务模块和数据模型拆解挂号、门诊、收费这几张核心表不能拍脑袋建2.1 先把就医主链路画出来再谈建表很多新手拿到医院管理系统需求第一件事是打开 Navicat 建表。我踩过这个坑建了 30 多张表结果业务流程一对两张表根本用不上三张表字段明显不够。正确顺序应该是先梳理就医主链路。预约/挂号 → 分诊/候诊 → 医生接诊 → 开立医嘱/处方/检查 → 收费结算 → 药房发药/执行检查 → 报告回填 → 离院或住院流转。每个环节会产出什么单据单据之间怎么关联这才是表设计的依据。围绕这条链路核心的业务表有这几类基础档案表患者基本信息表、科室表、医生表、药品字典表、诊疗项目字典表、收费项目表。业务单据表挂号单表号源与挂号记录、门诊诊断表、医嘱表、处方表、检查申请单表、收费流水表。关联与状态表患者就诊历史表、退号/退费记录表、操作日志表。我这里只挑几个容易踩坑的关联关系说明。2.2 号源表与挂号记录表一定要分开最反直觉的设计点在这里。很多项目把号源和挂号记录合在一张表里结果一个医生上午放 30 个号被挂了 25 个还剩 5 个这时有人退号释放出来的号又被挂了——状态在所有操作里来回变并发一上来就出问题。我建议拆两张表号源表registration_source代表今天上午某科室某医生的第几号字段包括doctor_id、dept_id、clinic_date、time_slot上午/下午、serial_no序号、status0待就诊 1已占用 2已过号 3已作废。挂号记录表registration_record代表某患者挂了哪个号源记录了真实发生的挂号行为包含patient_id、source_id、create_time、operator_id、fee_amount等。号源表里状态字段 一个version乐观锁字段专门用来解决并发挂号的冲突这个第3章讲接口的时候细说。挂号记录表则是一行一事实永远只新增不修改退号时不是把记录删掉而是新增一条退号记录同时把号源状态改成已释放。这样设计的一个直接好处是统计今天挂号多少人维度时直接数挂号记录表统计还有没有号维度时只看号源表两个查询互不干扰效率很高。2.3 用户和权限标题里没写但每张表后面都跟着它医院系统的角色种类特别多超级管理员、门诊挂号员、收费员、医生、护士、药房药师、系统维护员甚至还要区分科室主任和数据统计员。这就必须上 RBAC基于角色的访问控制模型。我落地时用了五张表sys_user用户表 sys_role角色表 sys_menu菜单/权限表 sys_user_role用户角色关联表 sys_role_menu角色菜单关联表前端动态路由、后端接口鉴权、按钮级别控制全都可以围绕这套模型展开。具体到接口层面我在 SpringBoot 里定义了一个RequirePermission(system:user:add)这样的自定义注解配合拦截器解析用户角色拥有的权限标识集合没有权限直接返回 403。前端则根据同一个权限标识决定按钮显示不显示。前后端双重校验不是多此一举——后端校验是安全底线前端控制纯是用户体验。这里有个实操经验权限标识的命名规范在项目开始前就要定死我用的是模块:功能:操作例如registration:record:create、finance:refund:audit。如果等表都建完了再补权限数据要重新初始化麻烦但还能忍最怕的是 A 模块用xxx:yyy:zzzB 模块用yyy_zzz_xxx后面写拦截器匹配规则的时候你会想骂人。2.4 处方、收费、医嘱之间的闭环设计医院系统里最容易数据不一致的地方就是医生开了药但患者还没去收费或者收了费药房却不知道要发药。我的解法是让每一笔业务单据都有明确的状态机医嘱表medical_order状态0暂存→1已提交→2已退费。处方表prescription状态0未收费→1已收费→2已发药→3已退药。收费流水表payment_record每笔收费记录带biz_type挂号/药品/检查/治疗和biz_order_no业务单号。收费动作发生时开启一个事务先创建收费流水再把相关联的处方状态改成已收费。药房端只拉取状态为已收费的处方去发药发完把状态改成已发药。任何一个环节失败事务直接回滚不可能出现钱扣了但处方没改状态的情况。在 MySQL 里边这个操作的实现就是一个带Transactional的 Service 方法里面依次执行update prescription set status 1 where id ?和insert payment_record ...。注意 update 语句的 where 条件里带上status 0这样即使用户手速快重复点击两次收费第二次 update 影响行数为 0业务代码里判断result 0就返回处方状态已变化请刷新。3. SpringBoot 后端落地JWT 鉴权、统一返回结构、并发挂号怎么保证数据一致性3.1 项目工程结构与三层分包后端工程我用的是一个标准的 Maven 单模块 Structure分controller、service、mapper、entity、dto、common、config这几层。很多教程喜欢把 service 接口和实现类拆开我这边考虑到团队规模不大直接用一个XxxService类没有接口省去大量空洞的中间层代码。别迷信必须有接口设计模式是为了解决问题不是为了凑代码结构。关键 pom 依赖就这么几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.4.0/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid-spring-boot-starter/artifactId version1.2.18/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency这里说明一下为什么加 Druid 连接池。SpringBoot 默认的 HikariCP 性能其实很好但 Druid 自带监控页面可以看到每个接口执行的 SQL、连接池的活跃连接数。医院管理系统上线后特别是门诊高峰期DBA 要排查连接数怎么突然飙到 200Druid 的监控台几分钟就能定位到是哪个接口的 SQL 慢导致连接释放不及时。这种排查能力在医疗系统这种不能随便重启的场景里太重要了。3.2 统一返回结构 R 类和全局异常客户端的错误提示全从这里来前后端分离后前端拿到的不再是服务端渲染的页面而是一堆 JSON。如果每个接口返回结构都不一样——有的返回{code:0, data:...}有的直接返回一个 List——前端 axios 拦截器怎么做统一处理异常怎么区分参数错误未登录服务器异常所以后端第一件事是定一个统一的返回类。我写的R类就是这样Data public class RT { private Integer code; private String message; private T data; public static T RT success(T data) { RT r new R(); r.setCode(200); r.setMessage(success); r.setData(data); return r; } public static T RT error(Integer code, String message) { RT r new R(); r.setCode(code); r.setMessage(message); return r; } }配合一个全局异常处理器RestControllerAdvice把业务异常BusinessException变成R.error(500, e.getMessage())把参数校验MethodArgumentNotValidException变成R.error(400, 第一条错误信息)。有了这套机制Service 里发现该号源已被预约直接throw new BusinessException(该号源已被预约)前端弹窗就能显示出这句话完全不需要每个接口都写 try-catch。状态码我自定义了一套语义200成功、400参数错误、401未登录、403无权限、500业务异常。注意不要把 HTTP 状态码和业务 code 混为一谈。比如未登录我在 HTTP 层也返回 401这没问题但药品库存不足这种业务失败HTTP 层是 200业务 code 是 500因为请求本身是成功到达并处理的。前端 axios 拦截器只看业务 code 来做全局提示。3.3 JWT 认证无状态会话是怎么在前后端分离架构里运转的传统单体 Web 应用用 Session 登录前端拿到一个JSESSIONID存在 Cookie 里后端内存里维护一份会话数据。前后端分离后前端和后端可能不在同一个域名甚至不在同一个服务器Cookie 的跨域问题、会话共享问题都是麻烦。JWTJSON Web Token直接把这些会话数据编码进一个 token 字符串里后端不存 session天然适合分布式部署。我的登录接口逻辑// 根据用户名查出用户BCrypt 比对密码 // 校验通过后生成 JWT String token JWT.create() .withSubject(user.getId().toString()) .withClaim(roleCodes, roleCodeList) .withClaim(permCodes, permCodeList) .withExpiresAt(new Date(System.currentTimeMillis() 8 * 60 * 60 * 1000)) .sign(Algorithm.HMAC256(jwtSecret));生成后把 token 返回给前端前端的 axios 请求拦截器在 header 里加Authorization: Bearer token。后端有个JwtInterceptor每个需要登录的请求都先解析 token解析失败直接返回 401。这里有个实际的坑JWT 的 header 信息量是有限的如果把角色、权限全塞进去token 会非常大而且一旦用户权限被修改要等到 token 过期才会生效。我上面的做法把角色权限塞进 token 是当时图省事后面做用户管理功能时要更新用户权限只能把 token 有效期设短一点8小时才不至于权限变更后老 token 还能继续访问。如果你做的是对权限实时性要求高的系统建议 token 里只放 userId每次请求到后端再从缓存/库里查权限集合代价是多一次查询但换来的是权限即时生效。3.4 并发挂号的号源扣减乐观锁 数据库行锁的组合拳这是整个项目里技术含量最高的一个点也是面试官最喜欢问的。试想一个热门专家号上午 8 点放号50 个号源几百个患者在 App、小程序、窗口同时抢数据库层面会发生什么如果不加任何控制两个请求同时读到status 0的号源同时执行update registration_source set status 1结果两个患者都显示挂号成功但号源只有一个这就是超卖。我的方案分两层第一层是乐观锁。在号源表加一个version字段更新时带上版本号UPDATE registration_source SET status 1, version version 1 WHERE id ? AND status 0 AND version ?MyBatis-Plus 里用Version注解标注即可更新时框架会自动给实体带上版本条件。如果有两个并发请求只有一个能更新成功另一个影响行数为 0业务层判断后返回手慢了号源已被预约。第二层是防止重复挂号。同一个患者、同一天、同一个医生只能挂一次号。这个校验用唯一索引兜底最好我建了一个唯一索引uk_patient_doctor_date在挂号记录表上字段是(patient_id, doctor_id, clinic_date, time_slot)。即便业务代码里忘记判断数据库层面也会挡住重复插入抛出的DuplicateKeyException被全局异常处理器接住转成友好提示。这两层配合下来我压测过 200 个并发请求同时抢 20 个号源没有出现超卖也没有出现一个号源被两个患者同时占用的脏数据。核心死磕的是update 语句的先到先得 唯一索引的最后防线。这里提一句网上常见的先 select 再判断再 insert的做法在并发下靠不靠得住答案是分情况。若你的数据库隔离级别是默认的 REPEATABLE READ纯粹的先查后插防不了幻读必须有锁或者唯一索引兜底别拿业务代码当数据库用。4. Vue3 前端工程化权限路由、Pinia、axios 请求封装和 Vite 配置4.1 前端工程创建与环境配置里面的隐形门槛初始化一个 Vue3 项目最快的是这条命令npm create vitelatest hospital-web -- --template vue注意这里有个版本相关的坑。Node.js 的版本直接决定了你能不能顺利装依赖。Vite 5 要求 Node 18Vite 7 甚至要求 Node 20。如果你的开发机 Node 还是 14 或 16npm run dev会直接报requires Node.js version 18。这类问题在vue安装及环境配置热搜里的高频出现原因就在这里。我自己的建议是本地开发装 Node 20 LTS服务器上构建前端时也用一致的版本。项目跑起来后第一步不是写页面而是把目录结构理清楚。我惯用的结构src/ api/ # 每个模块一个请求文件 assets/ # 静态资源 components/ # 通用组件 layout/ # 主布局侧边栏 顶栏 内容区 router/ # 路由配置文件 store/ # Pinia 状态 utils/ # request封装、工具函数 views/ # 页面4.2 axios 封装token 注入、401 统一跳转、业务错误全局提示前端所有请求都通过一个统一的request.js发出。封装的核心逻辑就三件事请求拦截器里从userStore拿 token有则加到 header响应拦截器里判断 HTTP 状态码如果 401 就清空登录态跳回登录页如果 HTTP 状态码是 200但业务 code 不是 200就用ElMessage.error(res.message)弹出后端返回的错误信息并reject掉这个 Promise。代码长这样几乎是模板级的写法import axios from axios import { ElMessage } from element-plus import router from /router import { useUserStore } from /store/user const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 10000 }) service.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response?.status 401) { const userStore useUserStore() userStore.resetState() router.push(/login) } ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } ) export default service这个封装里有一个细节我从项目开始到最后都没改过——return res.data而不是return response。这意味着页面里调用api.getXxx()拿到的直接就是后端响应的data字段少了一层嵌套。比如后端返回{code:200, message:success, data:[...]}前端const list await getPatientList()拿到的直接是那个数组代码干净许多。4.3 路由权限动态路由不能只在导航守卫里做判断医院系统按角色显示不同菜单这个需求用 Router 的静态路由加meta判断也能做但体验很差角色一多路由表的meta.roles数组会写得又臭又长而且每加一个角色就得回代码里改。我的做法是登录成功后后端返回当前用户的权限标识列表和菜单树前端拿到后用router.addRoute动态生成路由。核心代码如下// 后端返回类似: // { menus: [{ path: /system, component: Layout, children: [{ path: user, component: system/user/index.vue }] }] } const modules import.meta.glob(/views/**/*.vue) function buildRoutes(menus) { const routes [] for (const menu of menus) { const route { path: menu.path, name: menu.name, component: modules[/src/views/${menu.component}.vue] } if (menu.children) { route.children menu.children.map(child buildRoute(child, modules)) } routes.push(route) } return routes } const dynamicRoutes buildRoutes(menus) dynamicRoutes.forEach(route router.addRoute(route)) router.addRoute({ path: /:pathMatch(.*)*, redirect: /404 })这里最核心的 API 是import.meta.glob按需导入。它会把所有匹配到的.vue文件都打包进构建产物但只有当路由被实际访问时才会真正加载组件代码。配合路由懒加载达到首页先出来其他页面按需加载的效果而不是一开始就把全部页面 JS 下载下来。有几个坑必须提醒你路由刷新后消失。动态路由只存在内存里用户按 F5 刷新Pinia 和路由全重置了。所以必须把路由持久化到sessionStorage或者pinia 的持久化插件里刷新后先从缓存恢复。后端返回的 component 字符串要和glob的 key 精确匹配。我这边后端存的是system/user/index.vue这种相对路径前端拼成/src/views/system/user/index.vue。这个规则一开始就要约定好不然后端改一个路径前端菜单就 404。404 路由要在动态路由添加之后最后 add否则你动态添加的路由会被 404 通配符先匹配掉。4.4 状态管理 Pinia用户信息、就诊卡数据、全局字典Pinia 相比 Vuex 最大的改进是没了 mutationstoreToRefs用起来也比mapState舒服。我这个项目里Pinia 主要装了三类数据userStoretoken、用户信息、权限列表、动态路由是否需要重新生成的标记dictStore全局数据字典性别、挂号类型、医保类型、药品单位等登录后一次性拉取缓存页面里用dictStore.getValue(gender, 1)翻译。避免每个页面都去请求字典接口visitStore当前操作的患者上下文。这是门诊医生站的特殊需求——医生进入页面后选中一个患者接下来开医嘱、开检查、看历史病历都要基于这个患者它就是一个当前会话数据。这个visitStore是我在开发到门诊模块时加的之前没这个词。当时遇到的实际场景是医生从接诊队列点开一个患者浏览器地址变成?patientId123然后他开处方时要在表单里隐藏传入 patientId每次页面跳转都要从路由参数里取特别容易忘。抽一个 store 之后所有开单组件直接读 store 里的 currentPatient 就行代码清晰很多。4.5 Vite 代理配置开发环境的跨域怎么解决开发时前端跑在http://localhost:5173后端跑在http://localhost:8080两者端口不同浏览器直接发请求必然跨域。有两种做法一是后端开 CORScors.allowed-origins二是前端 Vite 配置代理。我强烈建议开发阶段用 Vite 代理// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } })这样前端代码里所有请求都发/api/...Vite 开发服务器把请求转发到后端浏览器角度看到的是同源请求没有跨域问题。生产环境下 Nginx 也是同样的思路把/api/反向代理到后端服务地址。整套前后端分离的跨域问题在 Nginx 和后端同样住一个域名下后就彻底消失了。那 CORS 还要不要配我建议后端还是配一份万一有需求变化比如某个第三方巡检系统要直接调你的接口那就直接能用。最好的策略是开发用 Vite 代理生产用 Nginx 反向代理后端 CORS 配置作为兜底三管齐下保证任何接入方都能顺畅调通。5. 联调与部署实战本地跑通到服务器上线最容易被拦住的几个点5.1 前后端联调时最容易出问题的是字段命名而不是代码逻辑跟很多项目不一样我们前后端联调花时间最多的往往不是鉴权而是JSON 字段命名不一致。后端 Java 类属性习惯createTime前端 JSON 也返回createTime但哪天有人把数据库字段做成create_time且没开驼峰映射前端拿到的就是create_time页面上一堆 undefined。MyBatis-Plus 默认开启驼峰映射数据库的create_time字段自动映射到实体类的createTime属性再序列化为 JSON 时就是createTime。但如果某个字段没走自动映射比如用了自定义 SQL 查询返回的是 Map那 JSON 字段名就跟数据库字段一模一样。前端协商统一使用驼峰命名后端的 Map 查询也手动as createTime。我这里提供一个经验前端所有列表数据先别急着写死字段先把后端返回的 JSON 打印到控制台看一眼。这一步 30 秒能避免后面一小时的界面空数据排查。5.2 MySQL 连接配置的三个经典报错照着改就行后端连 MySQL 最容易报的错热搜榜上出现了mysql ssl连接错误和mysql 安装配置教程这里我直接说结论。第一个报错是SSL connection error: java.net.SocketException。MySQL 8.0 默认启用 SSL但 JDBC 客户端有时校验会失败。解决办法是 JDBC URL 里显式关闭 SSL 并追加时区参数spring.datasource.urljdbc:mysql://localhost:3306/hospital_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltruecharacterEncodingutf8mb4其中serverTimezoneAsia/Shanghai不写的话会报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized乱码一样的错误就是时区没配。allowPublicKeyRetrievaltrue不写的话MySQL 8.0 的 caching_sha2_password 认证会报Public Key Retrieval is not allowed。第二个经典报错是Access denied for user rootlocalhost。这多半是 MySQL 安装完成后 root 的认证方式问题。MySQL 8.0 默认 root 用户用的是auth_socket插件Ubuntu 上常见导致你用密码连不上。处理方式是登录 MySQL 后执行ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY yourpassword; FLUSH PRIVILEGES;第三个是我遇到的Unknown database hospital_db。这通常是数据库还没创建或者项目里配置的库名和实际建的库名不一致。注意要先建库再启动后端程序不然数据源初始化直接在启动阶段就失败了。5.3 部署方案前端进 Nginx后端打成 jar跑在服务器上生产环境我的部署结构是这样服务器 (Linux Nginx JDK17) │ ├── /opt/hospital/server/ │ └── hospital-server.jar # SpringBoot 后端 │ └── /usr/share/nginx/html/ └── hospital-web/ # 前端打包后的静态文件dist前端打包在hospital-web目录执行npm run build产物在dist目录。把dist里的内容复制到服务器的hospital-web目录即可。后端打包有两种常见方式。如果你用spring-boot-maven-plugin配置了repackage执行mvn clean package -DskipTests后得到的就是可直接运行的java -jar包。如果你用外置 Tomcat 部署 war 包项目就得改成 war 包并且 SpringBoot 启动类要继承SpringBootServletInitializer。我的建议是直接打 jar 包托管给系统服务因为 jar 包部署方式更简单启动脚本好写进程管理也方便。用nohup启动是初级做法进阶一点写一个 systemd 服务单元[Unit] DescriptionHospital Server Afternetwork.target [Service] Typesimple Userroot WorkingDirectory/opt/hospital/server ExecStart/usr/bin/java -Xms512m -Xmx1024m -jar hospital-server.jar Restartalways RestartSec10 [Install] WantedBymulti-user.target然后systemctl daemon-reload systemctl enable hospital systemctl start hospital日志用journalctl -u hospital -f实时追踪。这套方案在医院服务器上跑得很稳进程崩溃能自动拉起不用人肉盯着。Nginx 配置前端和后端接口转发server { listen 80; server_name hospital.example.com; client_max_body_size 50m; # 前端静态资源 location / { root /usr/share/nginx/html/hospital-web; index index.html; try_files $uri $uri/ /index.html; # 解决前端路由刷新404 } # 后端接口代理 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; } }注意 Nginx 里location /的try_files ... /index.html是必须的。不然用户在挂号页按 F5 刷新Nginx 去磁盘找registration/list这个文件找不到就返回 404而 Vue Router 的 history 模式需要把不存在的路径都回退到index.html由前端路由自己去匹配。那tomcat部署前后端分离项目热搜里说的部署方式怎么理解其实 Tomcat 部署和后端 jar 部署本质一样区别只是端口由你来定用 Tomcat 的话 8080 端口就交给了 TomcatSpringBoot 内置容器闲置即可。如果你公司服务器上已经装了 Tomcat 8.5 并且运维只认这个就按 war 方式出包。我自己更偏好 jar systemd因为少一层容器管理定位日志也简单。5.4 部署后必须做的三个验证部署完别急着宣布上线成功先做三件事验证静态资源路径验证浏览器打开http://服务器IP/确认你能看到登录页控制台没有 JS 404。如果 CSS/JS 404多半是base配置问题——你前端部署在子路径下Vite 的base要设置成/hospital-web/Nginxroot也要对得上。接口代理验证打开浏览器开发者工具Network 面板发起一次登录请求看请求是否打到http://127.0.0.1:8080且返回 JSON。如果 404检查proxy_pass后面的路径拼接——我配置里写的proxy_pass http://127.0.0.1:8080/;尾部有个斜杠表示把/api/前缀去掉后转发不带斜杠则会把/api原样带过去后端又没有/api前缀就会 404。这个斜杠非常容易漏。数据库资源验证登录后随便打开一个需要查库的页面比如患者列表。如果接口报 500直接看后端的journalctl -u hospital -f日志大多数是数据库连接问题或者字段映射问题。5.5 SpringBoot 版本过高时的一个实际案例热搜词里有一条springboot版本太高这个我深有体会。SpringBoot 3.x 基于 Jakarta EE把javax.servlet全换成了jakarta.servlet。如果你在网上搜到的老教程代码用的是import javax.servlet.*直接复制到 SpringBoot 3.x 的项目里编译期就报错。另外 SpringBoot 3.x 要求 JDK 17 以上如果你的服务器只装了 JDK 8那直接将java -jar起不来。我的选择是 SpringBoot 2.7.x JDK 8/11不是因为它新而是因为团队现有技能栈和大量线上资料都是围绕这个版本线遇到问题搜得到答案。新版虽好但对一个要快速交付的医院管理系统来说稳定可控比强行追新更重要。6. 从单体到分布式演进不推翻重来怎么一步步改成多机部署6.1 当前架构哪里撑不住是决定要不要引入新组件的前提你的系统如果只是部署在一台服务器上用户量大了以后最先撑不住的通常是两个点数据库连接数和并发读写瓶颈。很多分布式医院管理系统的标题下实际架构演进路径是这样的第一步单台服务器前端 Nginx 后端 jar 单实例 MySQL。第二步后端应用水平扩容部署 2 个 jar 实例Nginx 做负载均衡MySQL 从单实例升级为一主一从主库负责读写从库做备份和部分统计报表读取。第三步引入 Redis 做业务缓存科室列表、药品字典、验证码、token 等不再每次查 MySQL。第四步引入消息队列做削峰和异步解耦比如预约成功后的短信通知、药房发药任务推送。第五步按业务边界拆库拆应用挂号服务、收费服务、药房服务独立部署。每一步都是在现基础上增量演进不需要推翻重来。前提是你代码里 Service 之间的调用必须清晰模块边界不能糊成一团。6.2 Redis 缓存了哪些数据效果最直接医院系统里读多写少的数据非常适合 Redis 缓存。我最先接入的四个场景字典数据性别、科室类型、药品单位、收费项目分类几乎每个页面下拉框都要用。登录后从 MySQL 全量加载到 Redis修改字典时同步更新缓存。科室信息科室列表、楼层位置、医生排班计划高频只读缓存后接口响应从 80ms 降到 5ms。验证码登录页图形验证码、短信验证码天然适合 Redis 的SET key value EX 300过期机制。当日号源余量挂号列表页一打开就要显示各科室剩余号源用户可以刷好几遍。这个数据可以直接放 Redis挂号成功时用 Redis 的DECR扣减。不过注意真正闭单时最终数据一致性还是靠 MySQL 的乐观锁保证Redis 只负责展示层的高并发读。6.3 消息队列在这里解决什么问题门诊高峰期患者注册、挂号、收费、取药各种操作攒在一起有些动作不需要用户当场等结果。比如挂号成功后要发短信通知、要推送消息到接诊大屏、要更新预约统计报表。这些如果都放在挂号这个接口里同步执行接口响应就要多耗几百毫秒高峰期还可能被这些慢操作拖垮主链路。引入消息队列后挂号接口只做最重要的事——写挂号记录、扣号源、返回结果。发短信、推大屏这些操作封装成事件投递到队列里异步消费。选型上小项目用 RabbitMQ 或 ActiveMQ 就够不用一上来就 Kafka。要注意的是队列消费必须做幂等。同一个挂号消息被投递两次消费端要能用biz_id 消费状态表识别出重复避免短信重复发、报表重复统计。这是一套最终一致的思路主流程先落库异步动作允许短暂延迟但保证最终执行完成。热搜里java怎么保证数据一致性常常把这种机制和分布式事务混为一谈其实这里是最终一致性不是强一致业务上完全够用且更现实。6.4 数据一致性的兜底策略本地消息表在引入正式消息队列之前有一个非常简单但可靠的过渡方案本地消息表。挂号事务里把需要发送短信通知这个事件也作为一条记录写在业务数据库的一张message_dispatch表里跟挂号记录同一个事务提交。事务成功后定时任务扫描这张表把未发送的消息发出去发送成功后在表里标记状态失败就重试。这个方案的意义在于不用引入任何外部组件就已经解决了本地事务和外部调用不一致的问题。挂号记录写成功但短信服务恰好宕机了本地消息表里的事件还在服务器恢复后照常发送。等团队熟悉了这一套最终一致性的思路再平滑切换到消息队列理解成本会低很多。7. 最后分享一个团队新人常犯的错以及我的建议这个系统前后端整个流程走下来我最想叮嘱的是不要在项目一开始就想着把某些功能做得特别复杂来证明自己医院管理系统本质上是一套严谨的业务系统它的价值在于稳定、准确、可追踪。我自己见过初学同学一上来就设计了个非常复杂的医生排班自动生成算法结果基础的患者建档流程还没有跑通这种项目交付日期一到焦虑会铺天盖地。正确顺序是先把主链路的增删改查打通——患者建档挂号门诊开单收费发药。前端能登录、能跳转、能录数据后端接口能返回正确的 JSON数据库关系干净、索引合理。把这套骨架做到极致再考虑加 Redis、加消息队列、加分布式部署每一步都有清晰的目标和验收指标。另一个我工作里养成的习惯是每个模块完成当天就写一段简单的联调自测记录不用写文档就记在自己项目的 TODO 或者运营手册里。比如挂号接口已用 Postman 测过并发200 并发无超卖前端门诊医生站部署测试环境正常。这种零碎记录到系统交付时汇总成项目资料写风评、PPT、技术文档都有据可查比最后一天补文档靠谱一百倍。
返回列表