☰
基于Vue3+SpringBoot+MySQL的分布式医院管理系统:从单体到分布式演进实践
2026/10/1 4:19:58 网站建设 项目流程

做医疗信息化项目这些年,我被问得最多的问题不是"系统怎么开发",而是"这套系统为什么这么设计"。很多人拿着"基于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 前端选 Vue3,Composition 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(序号)、status(0待就诊 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:zzz,B 模块用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> <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>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>4.4.0</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.18</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

这里说明一下为什么加 Druid 连接池。SpringBoot 默认的 HikariCP 性能其实很好,但 Druid 自带监控页面,可以看到每个接口执行的 SQL、连接池的活跃连接数。医院管理系统上线后,特别是门诊高峰期,DBA 要排查"连接数怎么突然飙到 200",Druid 的监控台几分钟就能定位到是哪个接口的 SQL 慢导致连接释放不及时。这种排查能力,在医疗系统这种"不能随便重启"的场景里太重要了。

3.2 统一返回结构 R 类和全局异常:客户端的错误提示全从这里来

前后端分离后,前端拿到的不再是服务端渲染的页面,而是一堆 JSON。如果每个接口返回结构都不一样——有的返回{code:0, data:...},有的直接返回一个 List——前端 axios 拦截器怎么做统一处理?异常怎么区分"参数错误""未登录""服务器异常"?

所以后端第一件事是定一个统一的返回类。我写的R类就是这样:

@Data public class R<T> { private Integer code; private String message; private T data; public static <T> R<T> success(T data) { R<T> r = new R<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> R<T> error(Integer code, String message) { R<T> 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 的跨域问题、会话共享问题都是麻烦。JWT(JSON 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 vite@latest hospital-web -- --template vue

注意这里有个版本相关的坑。Node.js 的版本直接决定了你能不能顺利装依赖。Vite 5 要求 Node 18+,Vite 7 甚至要求 Node 20+。如果你的开发机 Node 还是 14 或 16,npm 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发出。封装的核心逻辑就三件事:

  1. 请求拦截器里从userStore拿 token,有则加到 header;
  2. 响应拦截器里判断 HTTP 状态码,如果 401 就清空登录态,跳回登录页;
  3. 如果 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 最大的改进是没了 mutation,storeToRefs用起来也比mapState舒服。我这个项目里,Pinia 主要装了三类数据:

  • userStore:token、用户信息、权限列表、动态路由是否需要重新生成的标记;
  • dictStore:全局数据字典(性别、挂号类型、医保类型、药品单位等),登录后一次性拉取缓存,页面里用dictStore.getValue('gender', '1')翻译。避免每个页面都去请求字典接口;
  • visitStore:当前操作的患者上下文。这是门诊医生站的特殊需求——医生进入页面后选中一个患者,接下来开医嘱、开检查、看历史病历都要基于这个患者,它就是一个"当前会话"数据。

这个visitStore是我在开发到门诊模块时加的,之前没这个词。当时遇到的实际场景是:医生从"接诊队列"点开一个患者,浏览器地址变成?patientId=123,然后他开处方时要在表单里隐藏传入 patientId,每次页面跳转都要从路由参数里取,特别容易忘。抽一个 store 之后,所有开单组件直接读 store 里的 currentPatient 就行,代码清晰很多。

4.5 Vite 代理配置:开发环境的跨域怎么解决

开发时前端跑在http://localhost:5173,后端跑在http://localhost:8080,两者端口不同,浏览器直接发请求必然跨域。有两种做法:

一是后端开 CORS(cors.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.url=jdbc:mysql://localhost:3306/hospital_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&characterEncoding=utf8mb4

其中serverTimezone=Asia/Shanghai不写的话,会报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,乱码一样的错误就是时区没配。allowPublicKeyRetrieval=true不写的话,MySQL 8.0 的 caching_sha2_password 认证会报Public Key Retrieval is not allowed。

第二个经典报错是Access denied for user 'root'@'localhost'。这多半是 MySQL 安装完成后 root 的认证方式问题。MySQL 8.0 默认 root 用户用的是auth_socket插件(Ubuntu 上常见),导致你用密码连不上。处理方式是登录 MySQL 后执行:

ALTER USER 'root'@'localhost' 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] Description=Hospital Server After=network.target [Service] Type=simple User=root WorkingDirectory=/opt/hospital/server ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar hospital-server.jar Restart=always RestartSec=10 [Install] WantedBy=multi-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 端口就交给了 Tomcat,SpringBoot 内置容器闲置即可。如果你公司服务器上已经装了 Tomcat 8.5 并且运维只认这个,就按 war 方式出包。我自己更偏好 jar + systemd,因为少一层容器管理,定位日志也简单。

5.4 部署后必须做的三个验证

部署完别急着宣布"上线成功",先做三件事验证:

  1. 静态资源路径验证:浏览器打开http://服务器IP/,确认你能看到登录页,控制台没有 JS 404。如果 CSS/JS 404,多半是base配置问题——你前端部署在子路径下,Vite 的base要设置成/hospital-web/,Nginxroot也要对得上。
  2. 接口代理验证:打开浏览器开发者工具,Network 面板,发起一次登录请求,看请求是否打到http://127.0.0.1:8080且返回 JSON。如果 404,检查proxy_pass后面的路径拼接——我配置里写的proxy_pass http://127.0.0.1:8080/;尾部有个斜杠,表示把/api/前缀去掉后转发,不带斜杠则会把/api原样带过去,后端又没有/api前缀,就会 404。这个斜杠非常容易漏。
  3. 数据库资源验证:登录后随便打开一个需要查库的页面,比如患者列表。如果接口报 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、技术文档都有据可查,比最后一天补文档靠谱一百倍。

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

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

立即咨询