前阵子帮人从头搭了一版医院后台管理系统,从数据库建模、后端接口、前端页面到最终部署,全程走了一遍。做这类系统的最大感受是:它看起来就是个“信息管理系统”,但真把挂号、门诊、收费、药房、床位这些环节串起来之后,涉及到的并发控制、事务一致性、权限分级、报表统计,全都是后端开发里最实在的硬功夫。这套系统的技术选型是SpringBoot + Vue前后端分离,持久层用MyBatis,数据库MySQL,完整跑通后非常适合当成毕设、实习项目或者小团队内部自用系统的底子。这篇文章我把整个系统的设计思路、数据库表结构、核心代码逻辑、前后端联调流程和踩过的坑都拆开讲清楚,需要完整源码做参考的可以直接按文里的步骤复现。
1. 项目整体设计与思路拆解
1.1 为什么是SpringBoot + Vue,而不是服务端渲染
医院管理系统里有一种非常典型的业务特征:角色多、页面多、操作流程长。同一个页面上可能既要显示挂号队列,又要实时更新病床状态,还要弹出收费窗口。这种交互密度下,前后端分离的体验优势很明显——前端专注交互,后端专注数据和业务规则,两边各自演进,互不拖累。
SpringBoot在这套系统里承担的是标准的三层职责:Controller层接收请求、Service层处理业务、Mapper层访问数据库。用SpringBoot最大的好处不是它“自动配置”多华丽,而是团队协作时约定俗成的东西多,任何人接手都能快速找到入口。MyBatis作为持久层方案,在这类业务系统里尤其合适,因为医院的查询场景特别复杂,百名患者列表、按科室聚合统计、多表关联查询,用XML手写SQL比JPA的自动生成的查询灵活得多。
Vue这边的定位就是纯展示和交互。通过Vue Router做路由分发,Vuex或Pinia管理登录态、角色信息、全局字典数据,Axios负责和后端接口通信。项目分成两个独立工程,前端打包成静态文件扔进Nginx,后端打包成Jar包独立运行,部署时互不干扰。
这种选型在真实项目里还有一个隐形优势:招人容易。SpringBoot和Vue几乎是国内Java后端和前端开发的标配,就算团队里来了新人,看代码的难度也远低于那些自研框架。
1.2 角色权限模型怎么设计
医院后台管理系统最忌讳的就是所有用户一把梭。医生能开医嘱,但不应能改药品价格;护士能录入体温,但不应能看到财务流水;管理员什么都能干,但不应去操作挂号单。所以权限模型要提前设计好,不然后面每一次接口上线都变成一场灾难。
系统里我设计了四种核心角色:管理员、医生、护士、收费员。后端权限控制采用RBAC模型,就是“用户表—角色表—权限表”三层关系,用户身上挂角色,角色身上挂权限,接口上通过拦截器校验当前用户具备哪些权限点。
具体落地时,登录接口会返回一个JSON,里面带JWT令牌和用户基本信息、角色列表、权限列表。前端拿到权限列表后配合Vue Router的动态路由,把当前用户没权限访问的菜单直接过滤掉。后端每个需要鉴权的接口都会写一个@RequirePermission("patient:add")这样的自定义注解,由拦截器统一处理,而不是在Controller代码里到处写if判断。这样权限逻辑集中在一个地方维护,增删权限点也方便。
注意:前端隐藏菜单只做体验优化,真正的安全控制必须放在后端接口上。我见过不少项目只在前端做了菜单过滤,结果有人绕过前端直接调接口,数据就全漏了。安全底线一定留在服务端。
1.3 功能模块边界划分
系统按医院后台的日常业务拆成六个模块:用户登录与权限管理、挂号管理、门诊医生工作站、收费管理、药房库存管理、系统统计报表。
用户登录与权限模块管的是登录、验证码、修改密码、用户信息维护。挂号模块处理窗口挂号和退号,记录挂号科室、医生、号别、就诊状态。门诊医生工作站是最核心的业务页面,医生在这里查看候诊列表、书写病历、开检查单、开处方。收费模块对接处方和检查单,处理收费和退费。药房库存关注药品入库、出库、库存预警。统计报表则从多个维度汇总数据,比如科室接诊量、药品消耗排行、每日收入汇总。
这些模块的边界在设计初期就要用接口文档和数据表划分清楚。模块之间尽量不要有隐形的数据库依赖,比如收费模块需要知道处方信息,那就定义prescription_id字段做外键关联,而不是直接去查医生工作站的表。边界清晰了,后面改需求就不会牵一发动全身。
2. 数据库设计:医院场景下的MySQL建模
2.1 核心表结构怎么拆
数据库是整个系统最不能偷懒的部分。表结构一旦建错,后面写接口时就会发现各种别扭,要么字段缺了要加列,要么表之间关系不对要重跑数据。
系统里最核心的表我列一下:用户表、角色表、权限表、患者信息表、挂号表、病历表、处方表、处方明细表、药品表、收费记录表、退费记录表、科室表、病床表。
以挂号表为例,我最终定下的字段包括主键、患者ID、科室ID、医生ID、挂号类型、挂号费、就诊状态、挂号时间、操作员ID。这里的就诊状态是状态机:WAITING待就诊、VISITING就诊中、FINISHED已完成、CANCELLED已退号。状态字段全部用字符串常量,并且在后端Service层做状态流转校验,避免出现“已完成还能退号”这种逻辑漏洞。
处方表和处方明细表是一对多的关系。处方主表记患者、医生、开单时间、总金额、状态;明细表记每一种药品的用量、频次、数量和单价。这么拆的好处是日后的统计报表好写,也更贴近药房发药的业务流程——药房人员只关心某个处方需要发哪些药。
2.2 索引设计:给慢查询留好退路
医院业务有一个特点,就是时间维度的查询特别多。我今天接了多少个患者、本月每个科室收入多少,这类查询都绕不开时间范围。如果不在时间字段上做索引,数据量大了之后,统计报表接口能把数据库CPU直接打满。
我在三张表上做了重点索引:
- 挂号表的
doctor_id + create_time联合索引,支撑医生工作台查今日待诊列表; - 收费记录表的
payment_status + pay_time联合索引,支撑财务统计; - 药品表的
stock单列索引,支撑库存预警查询。
建索引这事要克制,不是每个字段都加就完事。索引越多,写入性能越差。核心原则是:先看业务查询条件,再决定在哪些列上加索引,一般单表索引不超过五六个,宁缺毋滥。
2.3 数据一致性:挂号、收费、退费的事务处理
医院系统里的钱和药,容不得半点差错。最典型的是收费模块:一次收费操作,要更新收费记录表、更新处方状态、还要扣减药品库存。这三步只要有一个失败,就会出现“钱收了但药没发”或者“药出了但钱没收”的脏数据。
这里靠的是Spring的@Transactional注解。入口方法是收费操作,方法体内部做三件事:插入收费记录、把处方状态改成已收费、扣减库存。一旦中间环节抛出运行时异常,整个事务回滚,数据库恢复到操作前的状态。需要注意的是,@Transactional默认只在RuntimeException下回滚,如果是checked异常必须显式配置rollbackFor = Exception.class。
退费逻辑也一样。退费要校验原收费记录是否存在且已支付,恢复药品库存,并把收费记录状态更新为已退费。这个校验和状态更新必须在同一个事务里完成,否则高并发下可能出现重复退费。
3. 后端实现:SpringBoot分层与MyBatis细节
3.1 分层的标准姿势
后端工程我按常见的三层结构组织:controller、service、mapper。很多新手喜欢把业务逻辑全部写在Controller里,图省事,但系统一复杂就完了。打个比方,Controller就是前台接待,只负责收材料和递结果,不应该具备“决定业务能不能办”的权限。
我这里的代码习惯是:
- Controller只做参数接收、参数校验的第一道关口、调用Service;
- Service层写所有业务规则和事务控制;
- Mapper层只管SQL的增删改查。
举个例子,挂号这个操作看起来只是Insert一条挂号记录,但实际业务是:患者ID查不到要报错、选择的医生当天排班已满要报错、患者挂号次数超限要拦截。这一堆判断全在Service层完成,Controller层的代码就变得特别干净。
实体类用JavaBean风格定义,字段和数据库列一一对应。VO(视图对象)和DTO(数据传输对象)按需创建,比如患者列表页需要的PatientListVO是多个表拼出来的结果,就不会去硬套单表实体。
3.2 MyBatis映射文件里的实战技巧
MyBatis在这套系统里最重要的部分是XML映射文件的SQL编写。我用三个技巧来节省开发时间。
第一个是结果映射通用化。有些多表联查的结果集被多个接口复用,我会抽出BaseResultMap,然后通过extends扩展出各自需要的字段,避免每个查询都重复写一遍列名映射。
第二个是动态SQL的使用。患者列表页的筛选条件是医生传什么就查什么,比如只看某个科室的、只看今天挂号的、还要模糊搜索患者姓名。这种场景用<where>加<if>标签组合实现动态条件拼接。注意不要在<if>里拼出WHERE 1=1,MyBatis的<where>会自动处理首条条件前的AND,写出来的SQL美观也安全。
第三个是批处理操作。药房入库时可能是几十种药品一起录入,如果逐条Insert会产生大量数据库往返。我直接用<foreach>标签拼成批量插入SQL,一次请求把所有数据写入。实测导入500条药品数据,从原来的几十秒降到一两秒。
3.3 核心业务代码的要点实现
挂号接口是系统里最容易被并发打穿的地方。同一时刻可能多个窗口在给同一个医生挂第50个号,而号源上限是100。如果在Service层里写成“查出当前已挂号数,判断小于100,插入挂号记录”,高并发下必然超号。解决方式是在SQL层做原子判断:UPDATE doctor_schedule SET current_number = current_number + 1 WHERE id = ? AND current_number < total_number。这行SQL返回受影响行数,如果为0就是号源满了,直接向用户提示挂号失败。
门诊医生的处方录入又是另一种逻辑。一张处方包含多条明细,前端传来的是一个处方对象里嵌套着明细列表。前端会整整传入一个结构化的JSON,后端在Service层通过@Transactional把主表和明细表分多次插入。注意的是回滚逻辑同样要覆盖明细插入失败的情况。
接口返回格式我统一封装了一个ApiResponse对象:code、message、data三段式。这样前端拿到响应之后,不用每个接口都单独判断自己的返回格式,Axios拦截器里统一处理即可。
4. 前端实现:Vue工程与页面交互
4.1 工程结构和路由设计
前端工程我用Vue CLI初始化之后,按模块拆分为views目录下的二级目录:/views/register放挂号相关页面,/views/doctor放医生工作台,/views/pharmacy放药房页面,/views/finance放收费和统计页面,/views/system放用户和角色管理。
路由设计上做了一层动态路由。静态路由只保留登录页和404页,其余全部根据用户权限动态注册。登录之后,前端从后端拿到的权限列表里过滤出当前角色能访问的路由,然后通过router.addRoutes注入。这样做的好处是页面层级干净,不同角色登录进去看到的侧边栏菜单天然不同。
路由守卫必须要有。我在beforeEach里判断:未登录直接跳登录页,已登录但访问了无权限路由就提示并跳回首页。不过要再次强调,前端的路由守卫只负责体验,真正的数据安全还是后端说了算。
4.2 Axios封装和请求拦截
Axios在系统里要封装两个核心能力。第一是请求拦截,每次请求发出前把存储在localStorage里的JWT令牌自动加到请求头。第二是响应拦截,当后端返回code === 401时,说明令牌失效,这时清理本地登录态、跳转登录页并提醒用户重新登录。
响应拦截里同时要处理业务错误。后端返回的code不是200时,直接在拦截器里弹出错误提示,页面代码里就不需要每个接口都去处理失败分支,代码量能少很多。注意区分HTTP状态码和业务状态码,我这边HTTP请求返回一定都是200,具体业务是否成功看body.code。
4.3 核心页面的交互实现细节
挂号页面上有个很常见的交互:选了科室之后要联动加载该科室下的医生列表,再选医生之后加载出排班号源。这里的联动逻辑如果每次都在组件内部写死fetch调用,代码会很散。我习惯把所有关联数据请求收敛到一个store模块里,在dispatch动作里串行发起依赖请求,组件只拿结果渲染。
医生工作台页面是整个系统最复杂的页面,左侧是候诊患者列表,中间是大病历编辑区,右侧是常用药品选择区和检查单添加区。三个区域的数据是动态关联的:点击左侧某个患者,中间和右侧的内容全部切换。这个场景用Vue的computed结合vuex getter,当前选中患者ID存store,中间的病历内容和右侧的操作面板都通过患者ID计算渲染,状态管理起来不会乱。
药房的库存预警页加了简单的数字标记,低于安全库存的药品行高亮显示。这类交互本身不复杂,但是对使用体验提升很明显,值得做。
5. 完整实操:从数据库初始化到前后端联调
5.1 环境准备与版本选择
环境这块是最容易踩坑的地方。后端我用的开发环境是JDK 1.8 + SpringBoot 2.7.x,这是国内大量生产系统的实际组合,资料多、兼容性好,第三方依赖也好找。MySQL用8.0版本,注意MyBatis引入连接驱动时要选com.mysql.cj.jdbc.Driver,MySQL 5.x的驱动配置已经不适用了。前端用Node.js 14+版本和Vue CLI 4.x,Vue版本是2.6系,生态成熟稳定。
再说一下新版本的一个坑:SpringBoot 2.7以后项目结构上没有大的变化,但有些starter坐标的内部实现有调整,网上搜到的老教程可能对不上。所以建议直接用主流稳定版本,别追新。遇到版本问题先去官方迁移文档查,不要凭记忆改配置。
5.2 数据库初始化
项目根目录放了一个sql/init.sql文件,里面包含了建库、建表和插入测试数据。建表语句要保证可重复执行,我的做法是在每个建表语句前加DROP TABLE IF EXISTS,这样初始化时不容易报错。测试数据里造了十个科室、二十个医生和一百个模拟患者,方便直接体验流程。
初始化完成后,需要修改application.yml里的数据库账号密码。如果本地MySQL的账号不是root或者有密码,直接连不上,这是最常见的启动失败原因。建议在application.yml中把密码用环境变量引用,比如${DB_PASSWORD:root},这样换环境部署时不需要改代码,只改环境变量。
5.3 前后端联调的关键步骤
联调阶段要先把后端接口跑起来,启动类运行后,访问http://localhost:8080/api/v1/health确认服务正常。前端开发服务器默认端口是8080,但后端占用了8080,所以要改Vue项目的vue.config.js端口为8081,同时配置代理转发:开发环境下所有/api前缀的请求转发到http://localhost:8080,这样前端请求天然和后端同源,绕开了开发环境的跨域问题。
登录功能的联调优先级最高,因为几乎所有页面都要先拿到Token才能请求数据。拿登录接口举例,前端提交用户名和密码,后端返回的响应体结构是{code:0, data:{token:xxx, userInfo:{...}}}。在src/utils/auth.js里封装好Token存取方法后,马上在Axios拦截器里接入Token携带逻辑,然后手动调试一次登录请求,看到响应头带上了Authorization字段才算过。
联调过程中我习惯用Postman先测后端接口,测通了再用页面调。这样能把问题定位在代码还是网络层,节省大量来回沟通时间。如果后端接口在Postman里返回正常,前端页面请求却报错,九成是代理配置、请求头或参数序列化的问题。
5.4 部署发布
部署我用了最经典的两段式方案。前端执行npm run build,生成的dist目录拷到服务器的Nginx静态目录。后端执行mvn package -DskipTests,生成的Jar包放到服务器目录,通过nohup java -jar hospital-system.jar > log.out 2>&1 &启动。
Nginx配置上做了两个反向代理规则:静态资源直接指向dist目录,/api前缀的请求反向代理到http://127.0.0.1:8080。这种部署方式在前端页面刷新后路由不失效,比打开history路由模式要配置更多的Nginx规则。前端的Vue Router我用了hash模式,URL里带#不好看但省心,刷新页面不会出现404,适合小团队自用。
6. 常见问题与排查技巧实录
6.1 问题速查表
把我在开发过程中遇到的高频问题整理成表格,方便大家照着排查:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 前端访问接口报跨域 | 开发环境代理没配置,或生产环境Nginx没转发 | 检查vue.config.js的proxy和生产环境Nginx的proxy_pass |
| 登录请求返回401 | Token过期或请求头没携带Token | 检查Axios请求拦截器的Header设置和登录过期逻辑 |
| 数据库连接失败 | application.yml账号密码不对或驱动配置错误 | 确认MySQL连接串useSSL=false,驱动类用com.mysql.cj.jdbc.Driver |
| 批量插入报SQL语法错误 | <foreach>标签拼接时缺少分隔符或语句块不对 | 检查<foreach>的separator和value括号是否完整 |
| 事务没回滚 | 方法内抛的是checked异常 | 在@Transactional上显式指定rollbackFor = Exception.class |
| 前端页面404 | Vue Router模式选了history,Nginx未做fallback | 改用hash模式,或在Nginx加try_files $uri $uri/ /index.html |
| 新增字段后查询不到 | MyBatis结果映射没加对应字段 | XML的ResultMap和<select>的列名要同时补上 |
6.2 排查思路
遇到Bug先分清是前端还是后端问题。最有效的定位方式是看浏览器开发者工具的Network面板。请求如果都没发出去,那是前端代码的问题,看看是否有JS报错,路由是否正确,Axios拦截器哪里把请求拦了。请求发出去了但返回的状态码是500,那直接看后端日志。SpringBoot的启动终端和项目里的logs/目录都是查堆栈异常的好地方。
日志排查时重点看Caused by字段,那才是异常的根本原因。比如一条日志里前面一堆MyBatisSystemException,最后Caused by写着Unknown column 'xxx',那就是SQL或者表结构不匹配,直接去对应Mapper文件里修正即可。
还有一个实战经验:排查MyBatis相关问题时,先在后端配置里打开控制台SQL日志打印,在application.yml中配置mybatis.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,然后看控制台输出的完整预处理SQL和参数列表。绝大多数SQL问题一看日志就能定位,省下大量瞎猜时间。
6.3 性能优化的几点心得
系统数据量到了几十万条时,有几个优化收益很明显的点。第一是列表查询必须分页,MyBatis分页插件用PageHelper即可,但注意不要在循环里执行分页查询,会产生严重性能问题。第二是统计报表接口要善用MySQL的GROUP BY和DATE_FORMAT做按天、按月汇总,不要让Java层做大量内存计算。第三是医生工作台的患者列表查询要限制返回条数,日常场景一天一个医生的患者一般不会超过几百个,一次查全部纯属浪费。
另外可以给高频只读数据加一层缓存,比如科室列表、药品分类这类字典数据,用SpringBoot自带的@Cacheable注解就能实现。缓存能让这类读多写少的接口响应速度快上一个量级。注意改了字典数据之后要主动清理对应缓存,否则用户明明更新了科室名称,页面上却还显示旧值。
根据我个人的实际经验,这套系统做成之后,最大的学习价值不是“会写几个CRUD接口”,而是真正理解了业务规则如何落到代码和数据库上。从表结构设计到事务边界划分,从前端状态管理到后端分步部署,每一步都会遇到课本上不会写的真实问题。如果你拿到源码后想继续扩展,建议从“微信小程序预约挂号”和“电子病历的PDF导出”这两个方向入手,这两个需求在真实医院场景中需求量极大,而且技术上都完全能基于现有系统做增量开发。