这套系统是我前两年实际交付过的一套企业级疾病防控综合管理系统的完整源码复盘,技术栈就是标题里的SpringBoot + Vue + MyBatis架构,数据库用MySQL,前后端分离的经典组合。做这类系统最大的感受是:它表面上是一个增删改查的管理后台,真正做进去才发现,业务规则、状态流转、权限矩阵、数据统计每一块都比想象中复杂。如果你正在做医疗公卫类信息化项目,或者想找一套结构完整、能直接二次开发的全栈源码来学习,这篇文章应该能帮你节省不少时间。我会从业务拆解、技术选型、后端落地、前端实现、数据库调优、环境部署到实战踩坑,把整个项目的设计思路和关键细节讲清楚。
1. 疾控系统到底在管什么:业务模块与角色权限拆解
先别急着看代码。做企业级系统最忌讳的就是拿到需求就建表,我在这套项目上踩过最大的坑就是前期业务分析不够,后面反复改表结构。疾病防控综合管理系统之所以叫"综合",是因为它服务的不是一个科室,而是疾控中心内部多个业务线,同时还要对接医疗机构、基层社区卫生服务中心和被管理人群。
1.1 从业务痛点反推系统模块
疾控中心的日常工作,大致可以拆成这么几条线:传染病监测与报告、病例管理和流行病学调查、疫苗接种管理、重点场所卫生监督、应急物资保障,以及面向社会层面的健康状态申报。过去这些工作大量依赖Excel和微信群,数据散落在不同人手里,一旦需要汇总统计,几个科室的人要折腾好几天。
所以我做这套系统时,第一件事不是画原型,而是把业务模块拆成了七个边界清晰的功能域:
| 模块 | 核心功能 | 关键数据对象 |
|---|---|---|
| 传染病监测报告 | 病例直报、审核、订正、查重 | 报告卡、病种字典 |
| 病例个案与流调管理 | 个案建档、流调信息采集、密接管理 | 个案档案、密接人员 |
| 疫苗接种管理 | 预约登记、疫苗库存、接种记录 | 疫苗批次、接种档案 |
| 健康状态申报 | 居民自助申报、社区审核 | 申报记录 |
| 应急物资管理 | 物资出入库、库存预警 | 物资台账、出入库单 |
| 统计分析大屏 | 日报周报、地区分布、趋势图 | 统计结果集 |
| 系统权限管理 | 用户、角色、菜单、操作日志 | 用户表、角色表 |
每个模块都不是孤立存在的。比如传染病监测模块里,一个病例报告被审核通过后,会自动关联生成一条待流调任务,流调完成后再生成密接人员管理记录,整个链路是通的。这也是我把它设计成"综合系统"而不是几个独立小系统的原因。
1.2 角色权限矩阵与业务状态流转
这套系统的用户角色有五类,权限设计上采取RBAC模型,菜单和按钮都挂在角色上。我直接给出一份权限矩阵参考:
| 角色 | 数据范围 | 核心权限 |
|---|---|---|
| 超级管理员 | 全局 | 系统配置、用户管理、所有模块 |
| 疾控中心业务员 | 本区域 | 病例审核、流调录入、物资管理 |
| 医疗机构上报医生 | 本单位 | 病例直报、修订 |
| 基层网格员 | 街道/社区 | 健康申报审核、密接随访 |
| 普通市民 | 本人 | 健康申报填报、记录查询 |
角色设计直接影响后端的接口鉴权策略。数据范围这一列非常关键,疾控中心业务员只能看本区域数据,市级的能看全部区县。这意味着所有列表查询接口都必须带上行政区划编码的过滤条件,不是简单登录之后就能查全库。
状态流转方面,病例报告模块我设计了这样一个状态机:待审核、审核通过、调查中、已结案,以及异常分支:已作废、已订正。每个状态变更都会写一条审计日志。这类业务状态流转代码,看起来不起眼,但比CRUD复杂得多,也恰恰是企业版源码和教学demo的差距所在。
2. 技术栈选型复盘:为什么这套组合最适合企业级交付
技术选型这部分,我可以说是在交付压力下被逼出来的结论。市面上各种框架层出不穷,但真正拿到疾控中心这种企业级项目里,稳定、可维护、团队能上手才是第一位的。
2.1 单体优先:企业级内部系统的架构克制
这套源码没有上微服务,架构就是一个SpringBoot单体应用加上Vue前端。很多人可能会觉得"企业级"就该是Spring Cloud全家桶,我一开始也差点这么干,后来想通了:疾控系统的真实并发量根本没那么夸张,更多是几百个工作人员在上班时间集中录入和审核,单体应用配合MySQL完全扛得住。
微服务带来的服务拆分、分布式事务、链路追踪、部署运维成本,在这个业务场景里全是负资产。所以我坚持单体优先,保留清晰的模块分包,将来如果某个模块需要独立拆分,代码层面也是现成的。这也是我想给做同类项目的人一个建议:先评估业务规模和团队运维能力,再决定要不要分布式。
2.2 MyBatis与MySQL:把复杂查询握在自己手里
持久层我没有用JPA,选了MyBatis。原因非常直接:疾控系统里的统计报表太多了。什么"本月手足口病报告数按区县分布"、"疫苗接种率按月趋势"、"密接者转归情况统计",这类查询的SQL非常复杂,动辄多表联查加条件聚合,用JPA的Criteria API写起来能写到怀疑人生,而且生成的SQL很难优化。
MyBatis的XML里写SQL,DBA同事也能直接review,索引怎么走一目了然。配合MyBatis-Plus提供的基础CRUD和分页插件,简单的单表操作用BaseMapper,复杂的统计查询用自定义XML,开发效率很高。
MySQL的选择更简单:团队熟、运维熟、云上数据库兼容性好。用得最多的就是InnoDB引擎,事务、行级锁、外键约束都靠谱。要注意的是字符集必须用utf8mb4而不是utf8,不然疾控系统里录入了生僻字或者特殊符号(比如体温符号℃、特殊标点)会直接报错。
2.3 前端框架选型与版本搭配的细节
前端我选了Vue3 + Element Plus + Vite。Vue3的组合式API在维护复杂表单逻辑时确实比Options API顺手,Element Plus的组件库覆盖了后台管理系统90%的场景,表格、表单、弹窗、树形控件都有,不需要自己造轮子。
后端用的是SpringBoot 2.7.x配JDK 8,没有盲目上SpringBoot 3。为什么?SpringBoot 3强制要求JDK 17,但很多单位的服务器上还跑着JDK 8,升级会引入兼容性问题。MyBatis-Plus用的3.5.x版本,对SpringBoot 2.x支持得最好。整个项目的版本搭配如下:
| 组件 | 版本选择 | 说明 |
|---|---|---|
| JDK | 1.8 | 服务器兼容性最好 |
| SpringBoot | 2.7.18 | 稳定维护版,支持JDK8 |
| MyBatis-Plus | 3.5.3 | 增强CRUD与分页 |
| MySQL | 5.7 / 8.0 | 幂等兼容 |
| Vue | 3.x | 组合式API |
| Element Plus | 2.x | 后台组件库 |
这套版本组合经过了实际生产环境验证,踩坑最少,网上能查到的资料也最全。技术选型就是这么回事,与其追新版本,不如选最适合团队维护的组合。
3. 后端落地:从表结构到核心接口的实现笔记
后端这部分是整个系统的心脏。我不会把全部源码贴出来,那没有意义,我把最关键的表结构设计、接口规划和鉴权链路拿出来讲,你拿到源码后就能对上号。
3.1 核心表结构与业务状态流转
疾病防控系统的数据库表,核心的几张表我列一下,看完你就知道为什么说这不是简单CRUD了。
以传染病报告卡为例,建表DDL关键字段如下:
CREATE TABLE `case_report` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `case_no` varchar(32) NOT NULL COMMENT '报告卡编号', `patient_name` varchar(64) DEFAULT NULL COMMENT '患者姓名', `patient_id_card` varchar(64) DEFAULT NULL COMMENT '身份证号(加密存储)', `disease_code` varchar(20) NOT NULL COMMENT '病种字典编码', `onset_date` date DEFAULT NULL COMMENT '发病日期', `diagnosis_date` datetime DEFAULT NULL COMMENT '诊断日期', `report_level` tinyint(4) DEFAULT NULL COMMENT '报告级别', `report_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0待审核 1审核通过 2调查中 3已结案', `region_code` varchar(12) NOT NULL COMMENT '行政区划编码', `reporter_id` bigint(20) DEFAULT NULL COMMENT '报告人ID', `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_case_no` (`case_no`), KEY `idx_region_date` (`region_code`, `onset_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='传染病报告卡';注意几个细节:身份证号必须加密存储,这是疾控数据的合规要求;地区编码region_code单独建索引,并且和日期组合成联合索引,因为这类系统最高频的查询条件就是"某地区某时间段内的报告数";报告状态用tinyint数字字典,而不是直接用字符串,既省空间又方便扩展。
状态流转我把它做成了枚举类配合状态机服务,每个状态变更都走统一入口,自动校验前置状态是否合法。比如"已结案"的病例不能直接改回"待审核",否则数据在流转链路上就乱套了。这些逻辑放在service层,用Spring的事务注解保证状态更新和日志写入要么都成功要么都失败。
3.2 接口设计与鉴权链路
后端整体采用标准的controller-service-mapper三层结构,包名按com.disease.xxx划分,看源码时路径很清晰。接口设计遵循RESTful风格,核心接口大致这么规划:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/case/report | 新发病例直报 |
| GET | /api/case/page | 病例分页查询 |
| PUT | /api/case/{id}/audit | 病例审核 |
| POST | /api/vaccine/appointment | 疫苗接种预约 |
| GET | /api/vaccine/stock | 疫苗库存查询 |
| GET | /api/statistics/trend | 趋势统计 |
| POST | /api/materials/inbound | 物资入库 |
鉴权用的是Spring Security + JWT。登录成功后签发token,前端把它存在本地,每次请求放进Authorization头。拦截器里做两件事:校验token是否有效,校验当前用户是否拥有访问接口所需的权限标识。
// 核心权限校验逻辑 @PreAuthorize("hasAuthority('case:audit')") @PutMapping("/{id}/audit") public Result<Void> audit(@PathVariable Long id, @RequestBody AuditDTO dto) { // 只有具备病例审核权限的角色才能调用 return caseService.audit(id, dto); }数据权限这块容易被忽略。同样是病例列表,市级用户要看所有区县,区级用户只能看本辖区。我在查询条件里统一拼接了regionCode的过滤逻辑,从JWT里解析出当前用户的区域编码,不让前端传区域参数。这样前端怎么改请求都越权不了,数据安全才真正落地。
4. 前端Vue工程实践:动态路由、权限菜单与数据可视化
前端是整个系统的门面,疾控中心的工作人员天天对着这些页面录入数据,不好用就会被天天吐槽。Vue侧我重点做了三个事:动态路由、axios封装、可视化大屏。
4.1 动态路由与菜单权限的实现方式
后台管理系统的菜单,不同角色进来看到的不一样。如果所有路由都写死在代码里,那前端就得做一堆v-if判断,很臃肿。我的方案是动态路由:登录成功后从后端拉取当前用户的菜单和权限标识,用addRoute动态注册。
// 登录成功后动态注册路由 const menuList = await getMenuList(); const routes = generateRoutes(menuList); routes.forEach(route => router.addRoute(route));generateRoutes函数把后端返回的菜单树转换成Vue Router的RouteRecordRaw数组,每个菜单节点对应一个组件路径。这样菜单配置、路由注册、页面渲染三者是一一对应的,新增一个菜单只需在数据库里加一条记录,完全不用改前端代码。
路由守卫也很关键。我在全局前置守卫里做了三件事:判断本地有没有token,没有就跳登录页;有token但当前路由不在已注册列表里,重新拉取菜单并addRoute;每次路由跳转前校验目标路由所需的权限标识。
router.beforeEach((to, from, next) => { if (!getToken()) { next('/login'); return; } if (!hasRoutes()) { // 刷新页面后路由丢失,重新拉取 initDynamicRoutes().then(() => next({ ...to, replace: true })); return; } if (to.meta.permission && !hasPermission(to.meta.permission)) { next('/403'); return; } next(); });刷新页面后动态路由丢失这个问题,几乎所有做动态路由的项目都会遇到,我直接在上面代码里做了重新拉取处理。这个细节如果你不做,用户按一下F5就直接白屏了。
4.2 axios封装、跨域代理和报表展示
axios封装是每个前端项目的基础工程。我在请求拦截器里自动带上token,在响应拦截器里统一处理HTTP错误码和业务错误码,特别是401状态码,后端返回这个说明token过期了,直接清理本地状态并跳转重新登录。业务错误码非0的,统一用Element Plus的Message提示,前端不用在每个请求里写一堆错误处理。
跨域问题在开发环境用Vite的proxy解决:
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }生产环境部署时,我用Nginx转发或直接把前端dist放进SpringBoot的static目录,就都不存在跨域问题了,这个部署细节放到后面章节详细说。
报表和大屏用的是ECharts。疾控大屏上最常放的图表有:近30天报告数趋势折线图、各区县病例分布柱状图、病种占比饼图。ECharts通过npm安装,按需引入,打包体积控制在合理范围。数据从统计分析接口异步加载,接口返回结构统一为日期、区域、数量、占比字段,图表配置完全数据驱动。
5. 数据库与MyBatis的调优实践:慢SQL、缓存坑、批量插入
疾控系统上线运行半年后,问题开始浮出水面。这个阶段数据库层面的优化是最有价值的,我总结了三个典型的调优方向。
5.1 统计报表慢SQL定位与索引优化
系统刚上线时,统计报表接口偶尔会慢到几秒钟。我先在MySQL里开启了慢查询日志,定位到最慢的一条SQL是就诊趋势统计,它在case_report表上同时做了日期过滤、地区分组和病种关联三件事。explain一看,病种字典表走了全表扫描。
优化手段就是加联合索引。我在病例表上建了(region_code, onset_date, disease_code)联合索引,统计查询直接在索引上完成过滤,不需要回表取完整行数据。这条SQL从1.8秒降到了80毫秒左右。另一条高频查询是疫苗接种预约记录查询,我在预约时间字段和疫苗批次ID上加了普通索引,效果非常明显。给张表加索引谁都会,但要知道给哪些字段建组合索引、建完之后explain的type是ref还是index,这才是实践里的真功夫。
5.2 MyBatis缓存引发的数据一致性事故
这个坑我必须详细说下,否则很多人会中招。MyBatis有一级缓存和二级缓存,一级缓存默认开启,作用域是同一个SqlSession内;二级缓存需要手工开启,作用域是namespace级别。
那次事故是这样的:疫苗接种记录模块,我在Mapper上开了二级缓存,应用启动后前几次查询很快,但没过多久就发现,某个批次的库存扣减之后,重新查询还是显示旧库存。原因就是二级缓存在namespace下缓存了查询结果,而库存变更走的是另一个Mapper的方法,MyBatis不知道要清哪个缓存,于是读到了脏数据。
排查链路就是先确认不是MySQL事务问题,然后在SQL日志里发现同样的查询没有真正执行,直接命中了缓存,才定位到二级缓存上。修复方案很简单:完全关闭这个模块的二级缓存,或者把库存类实时性要求高的查询设置为useCache=false。我最终的方案是干脆关闭全局二级缓存,只保留一级缓存。像疾控这种数据实时性要求高的系统,缓存的收益远小于数据不一致的风险。
5.3 批量插入与分页查询的性能优化
系统里最常见的两个大数据量场景,一是大批量导入历史病例数据,二是分页查询大量密接人员记录。批量插入如果不做优化,几千条数据一条条insert,能慢到让人怀疑人生。两个关键配置:
jdbc:mysql://localhost:3306/disease_control?rewriteBatchedStatements=true加上这个参数后,MyBatis批量插入才能真正走JDBC的batch模式,而不是模拟执行。另一个是useServerPrepStmts=true,允许服务端预编译,也能提升执行效率。
分页查询我用的MyBatis-Plus的分页插件,底层是物理分页,自动拼接LIMIT语句。需要注意大偏移量问题,比如查看第10000页、每页20条,MySQL要扫描前面20万条数据再丢弃,性能很差。这种场景我改成基于游标的分页,用上一页最后一条记录的ID作为查询条件,性能提升非常明显。
6. 环境搭建与源码运行:MySQL、SpringBoot、Vue联调全流程
拿到源码第一件事就是跑起来,这一步我见过太多人在环境上卡住。我把完整的运行流程捋一遍,按照这个顺序操作基本不会出问题。
6.1 MySQL安装与初始化配置
MySQL的安装方式,Windows上推荐直接下载zip免安装版。步骤其实很简单:下载zip包解压到指定目录,在根目录建一个my.ini配置文件,指定datadir、端口、字符集,然后用mysqld --initialize-insecure初始化,最后用net start方式注册成Windows服务。
配置文件里有几个生产环境必须注意的点:
[mysqld] port=3306 character-set-server=utf8mb4 collation-server=utf8mb4_general_ci default-time-zone='+08:00' max_connections=500default-time-zone这个参数非常重要,不设置的话,SpringBoot连接时用serverTimezone=Asia/Shanghai能连上,但数据库内部的时间函数返回值可能是UTC时间,导致疾控报表里"今日新增病例数"永远对不上。
数据库初始化完成后,创建项目数据库并导入源码自带的SQL脚本:
mysql -uroot -p -e "CREATE DATABASE disease_control DEFAULT CHARACTER SET utf8mb4" mysql -uroot -p disease_control < disease_control.sqlSQL脚本里包含了所有表结构和初始化数据,包括用户表、菜单表、字典表,直接用管理员账号登录就能看到完整的页面结构。
6.2 SpringBoot启动配置与常见报错处理
后端启动前,修改application.yml数据源配置:
spring: datasource: url: jdbc:mysql://localhost:3306/disease_control?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 server: port: 8080 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpllog-impl配置成StdOutImpl后,控制台会打印每一条执行的SQL和参数,调试阶段几乎离不开。端口修改直接改server.port,如果你本地的8080被占用了,改成8081或其他端口都行,但前端代理和目标地址要对应调整。
最常见的启动报错有这么几类:数据库连接失败(检查MySQL服务启动了没有、密码对不对);Invalid bound statement(Mapper接口找不到XML,检查mapper-locations路径);端口占用(被别的进程占了,改端口)。这些基本都是环境问题,按上面的配置逐项排查很快就能解决。
6.3 Vue打包放进SpringBoot的两种部署方式
前端开发模式跑起来很简单,npm install装依赖然后npm run dev。但真实交付时,前端要打包部署。这里有两种成熟方案。
第一种是双服务部署:Vue打包生成dist目录,扔给Nginx,Nginx把/api开头的请求反向代理到SpringBoot端口。这种方案前后端完全隔离,适合前端、后端分别部署在独立服务器上的场景。Nginx配置需要特别注意,history路由模式下要配置try_files规则:
location / { try_files $uri $uri/ /index.html; }第二种是把Vue打包产物放进SpringBoot的static目录。把dist目录下的文件复制到src/main/resources/static下,重新打包SpringBoot的jar,访问同一个端口就能同时提供页面展示和接口服务。这种方式适合单机部署、不想额外装Nginx的小团队场景。需要注意的坑是,如果你的Vue路由用了history模式,SpringBoot需要把非接口请求转发到index.html,否则直接刷新某个二级页面会返回404。我在项目里加了一个WebMvcConfigurer,把非/api路径的页面请求forward到/index.html,问题就解决了。
7. 上线后遇到的坑:完整的排查链路复盘
这个章节我挑三个最典型的线上问题,完整还原当时的排查过程。这些问题不会写在官方文档里,但做企业级系统迟早会遇到。
7.1 大批量导出Excel导致内存溢出
现象是疾控中心的工作人员导出全年病例Excel报表时,应用之前是个SpringBoot服务,直接OOM崩掉了。第一反应是JVM内存不够,把堆内存调大后重新触发,还是崩。定位分析后发现是Excel导出工具的问题:我用的是Apache POI的HSSFWorkbook,它会把所有数据行全部加载到内存里,全年几万条记录加上合并单元格样式,内存直接爆掉。
排查链路是先看GC日志,发现频繁Full GC,抓了堆转储文件用MAT分析,定位到org.apache.poi.hssf包下的对象占用了80%的堆内存。根因清楚了,修复方案是改用SXSSFWorkbook,这个类专门用于流式导出,内存里只保留最近100行数据,其余全部刷到磁盘临时文件。上线后同样的导出操作内存占用降到了原来的十分之一,OOM问题彻底解决。
7.2 定时统计任务里的事务失效
系统有一个每天凌晨的定时任务,负责把前一天的各区域上报数据汇总到统计表。上线后某天发现统计表里有几条重复数据,排查后发现定时任务所在的方法被@Scheduled注解调用,但方法内部的事务没有生效。
根因是Spring的@Transactional注解是通过AOP代理实现的,而定时任务在同一个类的内部方法之间调用时,不会经过代理对象,事务自然就失效了。排查链路是这样的:先在数据库日志里看有没有BEGIN/COMMIT语句,发现没有,说明事务注解根本没起作用。解决办法有两种:一是把定时任务调用的方法拆到另一个Service类里,让外部调用走代理;二是在定时任务方法里手动获取事务管理器,用编程式事务包裹业务逻辑。我选了第一种,因为代码改起来最清晰。
7.3 前端路由刷新404与打包体积过大
前端部署上线后,用户反馈在列表页刷新一下浏览器就404了。这个问题根因就是前面说的history路由模式,刷新时浏览器直接向服务器请求当前URL对应的路径,而服务器上并没有这个文件。排查链路是先用浏览器开发者工具看请求URL,发现请求直接到了Nginx,Nginx返回404。修复就是在Nginx配置里加try_files把请求回退到index.html,或者后端SpringBoot做转发。两种方案我都采用了,双服务部署的走Nginx配置,单jar部署的走后端转发。
打包体积过大其实也是上线后才暴露的问题。整个Element Plus直接全量引入,打包出来2MB多,首屏加载白屏好几秒。优化方案是按需引入组件,Vite配合unplugin-vue-components插件自动处理组件和对应样式的按需引入,ECharts也改成按模块引入,用到的图表类型才打包。最后打包体积从2.2MB降到500KB左右,首屏加载时间快了很多。
8. 源码二次开发的建议
最后说点实际的建议。这套系统源码我在设计之初就刻意把业务模块和基础框架剥离开来,如果要在它基础上做二次开发,按照我的经验,你可能会遇到的问题主要集中在这几个方向:新业务模块怎么挂上去、数据字典怎么扩、现有接口满足不了新需求怎么办。
扩展新模块时,后端照着现有的controller-service-mapper结构复制一套,前端在菜单表里加一条记录,不用改权限框架和路由代码就能把新模块挂进去。这是这套源码最大的增量价值。数据字典扩展更简单,所有下拉选项都走字典表,改数据库即可,前端不需要硬编码。
如果觉得现有接口返回的字段不够用,优先选择新增接口而不是改老接口,因为老接口可能已经被报表、大屏、移动端多处绑定,改字段很容易引发连锁问题。每次改完接口,用Swagger的接口文档对照检查一遍,能省掉很多联调时来回扯皮的麻烦。
我个人实际做这套系统的过程中,最大的体会是这类企业级系统的难点从来不在某个技术点上,而在于你怎么把散乱的需求整理成清晰的模块边界,再通过合理的表结构和接口设计,让整套系统在三年内都经得起业务变化的折腾。如果你准备在这个源码基础上做医疗公卫领域的项目,建议第一件事就是把病种字典和行政区划编码表梳理清楚,这两张表是整个系统所有统计报表的数据地基。