H 医院管理系统这类项目,在 Java Web 方向里属于常青树。你随便搜一下项目源码,十有八九会看到SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0这个组合。我最早接触这套技术栈是在做一家小型民营医院的信息化改造,当时甲方要求系统能跑通门诊挂号、医生开单、药房发药、收费退费这一整条业务链,工期又卡得紧。用了这套组合之后,我只想说一句:它是真的能扛住中小型业务系统的开发节奏,但背后的坑也一点不少。这篇文章就围绕“医院管理系统”这个选题,结合这套技术栈的实际落地过程,把我踩过的坑、验证过的设计思路和最终沉淀下来的工程结构都拆开讲清楚。
如果你正在做毕业设计、刚入职做业务系统开发,或者想从简单的增删改查过渡到有业务状态流转的管理系统,这篇文章应该能帮你少走一大段弯路。我不会去重复那些官网文档里的 Hello World,重点放在“医院管理场景里才会遇到的实际问题”上。
1. 为什么我最终选定SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0这套组合
1.1 技术选型的那点前因后果
很多人一上来就问“为什么不用 Spring Cloud”“为什么不用 Vue2 + Element UI,偏要上 Vue3”“为什么数据层不用 JPA 要用 MyBatis-Plus”。但我实际做完几套业务系统之后,发现医院管理系统这种体量的项目,根本不需要微服务那套复杂性。它需要的是:开发速度快、排错容易、前后端联调顺畅、最后交付给人维护的时候不那么劝退。
SpringBoot2的生态成熟度在那摆着。尤其和 MyBatis-Plus 配合的时候,起步最顺。别看我平时也会跟人聊 SpringBoot3,但真要交付一个时间紧急的系统,我倾向还是SpringBoot2,因为网上资料最多、碰到的坑几乎都能搜到现成答案,团队协作时不会有太多版本适配的意外。
Vue3 的理由更直接。Vue2 已经进入维护后期,新项目没必要再用老技术写。Vue3 的组合式 API 在处理复杂表单、复用逻辑的时候,比选项式 API 舒服太多,尤其像挂号记录、收费清单这种状态多的数据,用ref、computed、watch拆分逻辑,代码结构一眼就能看懂。
MyBatis-Plus 在中小型管理系统里就是效率神器。它把单表 CRUD 几乎全包了,而医院系统虽然业务表多,但大部分操作仍然是单表级别,真正需要手写关联 SQL 的地方不超过全部接口的三成。不需要像 JPA 那样为懒加载和 Session 关闭问题操心,也没有失去对 SQL 的掌控感。
1.2 数据库为什么死活要上 MySQL 8.0
说实话,MySQL 5.7 足够稳定,但我在对比之后发现,8.0 带来的优势在这个项目里是实打实的:窗口函数让统计类查询简洁很多、默认字符集升级成了utf8mb4、对 JSON 类型的支持更顺手、查询优化器的执行计划也更有参考价值。
医院管理系统的数据特点是:患者表、挂号表、处方明细表这种记录量会持续增长,早晚会面临“统计最近一个月门诊量”这类聚合查询。用 5.7 你得写一堆临时表、子查询,用 8.0 直接上窗口函数,几行 SQL 就结束了。平时做报表统计的时候,这种差距特别明显。
不过用 8.0 有个绕不开的坑:认证插件默认是caching_sha2_password,老一点的 JDBC 驱动和可视化工具根本连不上。解决方法倒不复杂,要么换成新驱动,要么在连接串里加allowPublicKeyRetrieval=true&useSSL=false。这个坑在后面环境准备一节我还会细说。
1.3 这套组合在医院管理系统里的适用边界
说句实在话,这套技术栈最适合的是:中小型医院的门诊管理、体检中心的信息系统、诊所连锁的日常运营系统,或者是高校用来做毕业设计的医院管理系统项目。它不适合去碰三甲医院的完整 HIS 那种体量,那是需要排队机、LIS 对接、PACS 影像、医保接口、多院区同步的大型工程。
认清边界特别重要。我在做需求分析的时候就跟甲方明确过:做出来的系统能管好门诊病人、医生排班、病历处方、药房库存、收费退费这个闭环,就是成功。没有必要把系统设计成什么都想管,结果每个模块都很浅。技术栈选型和业务范围是一起确认的,这样后面开发才不会被“加需求”拖垮。
2. 从零搭建工程骨架:MySQL 8.0环境与项目初始化里最容易出问题的环节
2.1 MySQL 8.0 的安装、字符集和时区
这一步看似简单,实际卡住过不少人。我见过太多人把 MySQL 8.0 装好后,连接、建库都正常,结果一启动 SpringBoot 项目就报时区错误,还有人建完表之后才发现中文乱码。这些问题的根源基本都是同一个:没在初始化阶段把字符集和时区设置对。
推荐的做法是在安装完成之后,直接修改配置文件。Windows 下一般是my.ini,Linux 下是/etc/my.cnf,在[mysqld]段里加上:
[mysqld] character-set-server=utf8mb4 collation-server=utf8mb4_0900_ai_ci default-time-zone=+08:00 max_allowed_packet=64M注意max_allowed_packet这个配置。后面用 MyBatis-Plus 做批量插入的时候,如果一次插入的数据量比较大,默认 4M 的包大小很可能不够用,提前调大能少踩一个坑。
建库的时候必须显式指定字符集,不要依赖默认值。执行:
CREATE DATABASE hospital_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;如果你用的是 MySQL 命令行或者可视化工具连 8.0,遇到Authentication plugin 'caching_sha2_password' cannot be loaded这类的报错,别急着改认证插件,先升级连接工具版本,或者用新的 JDBC 驱动。如果在局域网内测试,连接串里加上allowPublicKeyRetrieval=true通常是解决问题的最快路径。
2.2 SpringBoot 工程的初始化与 MyBatis-Plus 装配
SpringBoot 版本我建议选 2.7.x,因为 MyBatis-Plus 的mybatis-plus-boot-starter对这个版本的支持最成熟,而且 Java 8 和 Java 11 都能跑。不要一看有新版本就往前冲,稳定压倒一切。
pom.xml里的依赖核心就几个:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-generator</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>启动类上记得加@MapperScan扫描 Mapper 包,很多人漏了这一步,然后在启动的时候会莫名其妙报“找不到 mapper”。配置文件里的数据源和 MyBatis-Plus 配置我一般这样写:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hospital_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: your_password mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0map-underscore-to-camel-case必须开启,不然数据库里的patient_name映射不到 Java 的patientName字段上。logic-delete-field先配好,因为后面建立表的时候我会建议你几乎每张业务表都加一个deleted字段。
MyBatis-Plus 的分页插件需要在配置类里手动注册,这一步特别容易忘:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }没有这个配置的话,你调用selectPage的时候会发现分页根本不起作用,返回的还是全量数据。
2.3 Vue3 工程的创建与初始化
Vue3 现在的标准创建方式是用 Vite。执行npm create vue@latest创建项目,选择需要安装的路由和 Pinia。尤其注意 Node 版本:Vite 5 以上要求 Node.js 18 及以上,如果本机 Node 版本太老,直接装依赖阶段就会报错。
医院管理系统前端需要的核心依赖包含:
element-plus:后台管理组件库axios:请求库vue-router:前端路由pinia:状态管理sass:自定义主题样式时需要
集成 Element Plus 的时候,我建议全量引入,省心。虽然按需引入可以减体积,但管理系统的首屏优化压力不大,全量引入反而降低使用门槛。入口文件里写:
import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' app.use(ElementPlus)别在项目创建之后急着写页面。先把基础请求封装、路由结构、布局框架搭好,再往里填业务代码,比你写一半发现结构不对拆了重来要高效得多。
2.4 前后端联调时的跨域问题与代理配置
开发阶段,前端跑在http://localhost:5173,后端跑在http://localhost:8080,直接请求一定会有跨域问题。最优雅的解法不是在后端加 CORS 注解,而是在 Vite 的配置里做代理。
在vite.config.js里这样配置:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } }这样一来,前端请求/api/patient/list就会自动转发到后端的/patient/list,开发环境不用再处理跨域。前后端联调时,规范接口统一加上/api前缀,后面切到生产环境只要让 Nginx 也按相同规则转发就行。
3. 医院管理系统的核心业务表设计与后端实现
3.1 从业务闭环反推数据库模块
医院管理系统看似只是增删改查,但它真正的难点在于:一张挂号单后面,连接着患者、医生、科室、号源、病历、处方、收费、药品库存多个实体。你必须在建表阶段就想清楚数据之间的关联关系,不然写代码的时候会不断返工。
我实际用到的核心数据表大概这些:
| 模块 | 核心表 | 关键字段 |
|---|---|---|
| 用户权限 | sys_user, sys_role, sys_user_role | username, password, role_id |
| 科室医生 | department, doctor | dept_name, doctor_name, title, specialty |
| 患者档案 | patient | patient_name, id_card, phone, gender, birth_date |
| 挂号管理 | registration | patient_id, doctor_id, dept_id, registration_time, status |
| 病历处方 | medical_record, prescription, prescription_item | record_id, diagnosis, drug_id, quantity, dosage |
| 药品管理 | drug, drug_stock | drug_name, spec, unit_price, stock_quantity |
| 收费退费 | charge_order, charge_item | registration_id, amount, pay_status, charge_time |
字段名不要偷懒,也别为了省事把所有表都用id、name这种泛化命名。比如药品表里spec代表规格,unit_price代表单价,stock_quantity代表库存,这样后面写 SQL 的时候,一眼就能明白字段含义。
3.2 实体类映射和自动填充的细节
表结构设计好之后,实体类用 MyBatis-Plus 的代码生成器生成最省事。生成器可以自己写一个测试类,也可以直接用工具 IDEA 插件。但生成之后需要做的几件事,很多人会忽略。
第一个是自动填充。create_time、update_time这两个字段不要手动去设置,把实体类对应的字段加上@TableField(fill = FieldFill.INSERT)和@TableField(fill = FieldFill.INSERT_UPDATE),然后定义一个MetaObjectHandler:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }第二个是逻辑删除和历史记录的关系。医院数据不建议物理删除。比如退号之后,挂号记录必须保留原样,只是状态变成“已退号”。逻辑删除在这里是合理的。但需要注意:如果患者表做了逻辑删除,而患者的身份证号字段有唯一索引,删除后再次录入同一个人就会撞唯一索引。后面第5节我会专门展开这个问题。
3.3 挂号、退号这种状态流转业务怎么设计
整个系统里最容易被当成普通 CRUD 做砸的是挂号。表面上看,挂号不就是往registration表插一条记录吗?但它内部隐藏着状态流转:待就诊、已就诊、已退号。每次状态变化还会影响号源余量、医生的排班数据、收费表的状态。
我的设计是在registration表加一个status字段,中间用常量或者枚举类统一管理:
public class RegistrationStatus { public static final Integer PENDING = 0; // 待就诊 public static final Integer FINISHED = 1; // 已就诊 public static final Integer CANCELED = 2; // 已退号 }退号接口不能只写一句UPDATE registration SET status = 2。它必须在一个事务里完成三件事:修改挂号状态、释放对应医生排班的号源、生成一条退费记录。任何一步失败都要回滚。这种跨表的业务操作,才是医院管理系统和普通 CRUD 项目拉开差距的地方。
为了防止并发情况下同一个号源被抢两次,还要在更新号源时带上状态条件。开号源的 SQL 可以写成:
UPDATE doctor_schedule SET remaining_count = remaining_count - 1 WHERE id = #{scheduleId} AND remaining_count > 0用行锁来保证同一个排班下不会超卖。如果更新返回影响行数为 0,就说明号源已经满了,直接抛业务异常。
3.4 分页、条件查询和统计报表的实现方式
管理后台列表页几乎都离不开分页。MyBatis-Plus 的分页查询我建议统一封装到一个 Service 里,页面传pageNum和pageSize,返回给前端的结构包含total、records两个关键字段。
条件查询注意时间范围。比如“按挂号时间筛选”这类需求,很多人前端传了开始日期和结束日期,结果查出来的数据少了当天最后一条,原因是时间被默认为零点。正确做法是把结束日期加一天,或者在 SQL 中用:
WHERE registration_time >= #{startTime} AND registration_time < DATE_ADD(#{endTime}, INTERVAL 1 DAY)统计报表方面,MySQL 8.0 的窗口函数非常有用。比如统计每个科室最近一周的门诊量排行:
SELECT department_name, COUNT(*) AS visit_count, RANK() OVER (ORDER BY COUNT(*) DESC) AS rank FROM registration WHERE registration_time >= DATE_SUB(NOW(), INTERVAL 7 DAY) GROUP BY department_name这比手动维护统计表简洁得多,而且数据准确度更高。
4. Vue3前端:从登录鉴权到页面组件的实战落地
4.1 路由守卫、Token 管理与前端权限控制
前端骨架搭好后,第一个要做的业务模块是登录鉴权。登录成功后后端返回一个 token,前端存到 Pinia 和 localStorage 里。每次请求在 axios 拦截器里带上Authorization: Bearer ${token}。
路由守卫写在router/index.js里,用beforeEach做三件事:判断当前访问的路径是否在白名单(比如登录页);读取本地 token;没有 token 就跳转到登录页。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() } else if (!token) { next('/login') } else { next() } })这里要说一个容易被忽略的细节:前端守卫只是拦截跳转,后端接口必须有真正的权限校验。我在后端用一个 HandlerInterceptor 统一处理,检查除登录接口之外的每个请求是否带有效 token。前端控制只负责体验,后端校验才负责安全。
4.2 Axios 封装与统一返回结构
我前后端约定了一个统一的 JSON 返回结构:code表示业务状态码,data是真实数据,message是提示信息。后端写了个通用类:
public class ResultData<T> { private Integer code; private String message; private T data; }axios 封装就比较简单了,核心逻辑是请求拦截器和响应拦截器。响应拦截器里重点处理 http 200 之外的场景,以及业务 code 非 0 的情况:
service.interceptors.response.use( response => { if (response.data.code === 401) { localStorage.removeItem('token') router.push('/login') return Promise.reject(new Error('未登录')) } if (response.data.code !== 0) { ElMessage.error(response.data.message) return Promise.reject(new Error(response.data.message)) } return response.data.data }, error => { ElMessage.error('请求异常,请稍后重试') return Promise.reject(error) } )顺便说一句,401这个状态码在前后端意义不一样。我这里指的其实是业务上的 token 失效。千万别在code为 401 的时候用router.push之后又继续处理别的逻辑,容易造成多次弹窗。
4.3 用组合式 API 组织一个标准管理页面
以患者管理列表页为例,我用组合式 API 组织代码的结构大概是这样的:
const searchForm = ref({ keyword: '', status: '' }) const pageNum = ref(1) const pageSize = ref(10) const total = ref(0) const tableData = ref([]) const loading = ref(false) async function fetchList() { loading.value = true try { const data = await patientApi.getPage({ pageNum: pageNum.value, pageSize: pageSize.value, ...searchForm.value }) tableData.value = data.records total.value = data.total } finally { loading.value = false } } function handleSearch() { pageNum.value = 1 fetchList() } function handleReset() { searchForm.value = { keyword: '', status: '' } pageNum.value = 1 fetchList() }这里我强调三个新手很容易犯的错:
第一,搜索按钮点击后必须把pageNum重置为 1。你要是在第三页执行搜索,结果可能查无数据,或者数据对不上。
第二,重置搜索表单不能用Object.assign直接赋值给另一个新对象,最好是把表单字段逐项置空。
第三,每次pageNum或pageSize变化都要触发表格的重新加载。在 Element Plus 的el-pagination上我直接用@current-change和@size-change事件调用分页函数,这两个事件在组合式 API 里要用函数表达式绑定。
4.4 表单校验、日期规范与常见交互细节
管理后台里最重的前端交互是 Doctor 排班和处方表单这种组合型表单。拿处方来说,每张处方包含多个药品明细,前端需要动态添加行、删除行。Vue3 里用数组加v-for渲染动态行,每一行的el-input用v-model绑定到数组对象的某个属性。
Element Plus 的表单校验要注意rules的写法。日期范围选择器会返回一个数组,校验规则要做类型配置:
{ type: 'array', required: true, message: '请选择日期范围', trigger: 'change' }之前看到有人问“vue3 rules 日期检验”这个问题,十有八九就是没写type: 'array',导致校验总是报不是字符串。
动态表单的每一行最好都绑定一个唯一的key,我习惯用Date.now() + index的形式,避免 Vue 在复用组件状态时把上一行的数据带到下一行。
前端权限控制上,按钮级别的权限我建议直接用自定义指令控制,比如v-permission="['admin']",由指令内部判断当前用户角色并决定是否移除该按钮。但记住,这只是提升体验,后端接口必须有校验兜底。
5. MyBatis-Plus在医疗业务里的常用技巧与陷阱
5.1 逻辑删除和唯一索引的“神仙打架”
这是我在做患者模块时遇到的一个真实问题。患者表用deleted字段做逻辑删除,但身份证号id_card建了唯一索引。删除一个患者之后,如果再用同样的身份证号新建患者,数据库会报“Duplicate entry”。
破解思路有两个方向。一是把唯一索引改成复合索引,把deleted也放进去:UNIQUE KEY uk_id_card_deleted (id_card, deleted)。但这样只能解决删除一次的问题,要是删除过两次同样的身份证就会再次冲突。二是设计一个回收站机制,删除患者时不真正改变身份证字段内容,而是额外记录一条作废历史,新建患者时使用一个随机的标识码作为身份证去重依据。
我在实际项目中更推荐第二个方向。医院场景下,同一个患者的就诊史必须可追溯。真正避免的唯一性设计是让业务主键和身份证号解耦,用patient_no作为业务编号自动生成,这样删除恢复都不影响唯一约束。
5.2 批量插入与循环插入之间的差距
医生排班表经常需要一次性生成一周甚至一个月的排班记录。如果循环单条插入,按一次事务、每秒 50 条计算,30 个医生一个月就有几百条数据,耗时可能十几秒。用户根本等不起。
MyBatis-Plus 的ServiceImpl没有内置的批量方法,但是有一个insertBatchSomeColumn的扩展方法可以注册到 SQL 注入器里。如果不想扩展,也可以直接写一个 Mapper 接口,用 provider 注解实现:
@InsertProvider(type = SqlProvider.class, method = "batchInsert") int batchInsert(@Param("list") List<DoctorSchedule> list);对应的 SQL provider 里面生成VALUES的拼接。每批建议控制在 500 到 1000 条内,太大容易触发 MySQL 的max_allowed_packet限制。批量插入时整个方法要加事务,注意一次事务时间不宜过长。
5.3 自定义 SQL 与 Mapper.xml 的注意点
单表操作 MyBatis-Plus 基本包了,但多表关联查询还是得写 XML。我自己最常用的是<select>标签配resultMap。这里有几个容易踩的坑:
第一个坑:动态条件要谨慎使用${},它会被直接拼接成 SQL,有注入风险。能用#{}的地方绝对不要用${}。只有在表名、排序字段这种不能预定义的地方才拿出来用。
第二个坑:XML 文件里<和>需要转义。写a < b的时候要用<,或者整个包在<![CDATA[ ]]>里。不少人在写时间范围查询时栽过这跟头。
第三个坑:如果 Service 类继承了ServiceImpl,自定义方法不要和 IService 里已有的方法重名。我见过一个项目,自定义了一个saveBatch,结果调用时找不到自己写的实现,排查了很久才弄清楚是方法签名冲突,不是业务逻辑的问题。
6. “含文档”这三个字,到底应该交付什么才算合格
6.1 文档结构:从需求说明到部署手册
标题最后写着【含文档】,但很多情况下,所谓“文档”就是几十页全屏截图加上一句说明。真正能辅助交付的文档,结构应该是这样的:
- 项目概述与需求范围:系统是谁用的、解决什么问题、核心角色有哪些
- 技术架构说明:技术栈选择、模块划分、工程目录结构
- 数据库设计说明:ER 关系说明、每张表的结构、字段设计理由
- 接口说明文档:接口路径、请求参数、响应示例
- 部署手册:环境要求、安装步骤、初始化数据、常见问题排查
- 操作手册:面向使用人员的功能操作说明,最好配截图
不需要写成长篇大论,但每块内容都要能让人照着操作上手机。我整理文档的习惯是:代码完成一个模块后,立刻补这个模块的文档,不要等项目结束再集中写。集中写会导致你忘记细节,写出来的文档基本都是废话。
6.2 数据库设计文档怎么写才算有内容
数据库设计文档不能只是一张建表 SQL 的复制。每张表要说明设计意图。比如registration表为什么要有status字段,为什么退号还要保留原记录,这些设计决策放到文档里才能体现你对业务的理解。
还有一个小技巧:在文档里加上“扩展性”说明。比如患者表里预留一个ext_fieldsJSON 字段,用来应对未来增加的个性化信息需求。把这类思考写清楚,答辩或者评审的时候会加分不少。
6.3 部署文档和演示数据的准备
部署文档要写到“一个没看过你项目的人也能按步骤跑起来”的程度。环境部分把 JDK、Node、MySQL 版本写清楚;MySQL 部分要把初始化 SQL 的顺序写出来,先建库、再建表、最后导入基础数据;后端要把配置文件的修改点列出来,尤其是数据库账号密码;前端要把npm install和npm run dev这些命令写清楚。
演示数据特别重要。我一般会在系统里准备一两个完整的业务演示链路:一个患者从建档到挂号,到医生接诊写病历开处方,再到药房发药、患者缴费。这样验收的时候直接操作一遍完整流程,比在空数据库里东点西点要有效得多。
6.4 实际交付时的常见问题
最后分享几个交付时才会遇到的实际问题。
第一个是环境差异。我开发时用的 MySQL 8.0.33,但部署机上可能是 8.0.20。两个版本的默认目录结构有差异,最容易挂在路径分隔符和下划线命名上。交付前一定要在至少一台干净的环境上完整安装一遍。
第二个是端口冲突。后端如果跑在 8080,很容易和本地其他服务冲突。文档里建议写明如何修改application.yml中的端口号。
第三个是数据库初始化数据。有些基础数据,比如管理员账号、科室列表、医生角色,必须在init.sql里放到最前面。不然登录都登不进去,运维人员会因为一条数据卡住半天。
我在实际做这个项目时养成的一个习惯是:建一个docs目录,把所有文档跟源码放一起进行版本管理。这样项目一 clone 下来,文档也跟着在,不会因为换电脑或者交接而丢失。看起来不复杂,但真到了交付那天,你会发现这个习惯帮你省了不少麻烦。