做公共卫生信息化的朋友应该都有体会,“企业级疾病防控综合管理系统”这几个字我第一次看到是在一个基层卫生信息化的需求单里。不少疾控中心、社区卫生服务中心还在用Excel加微信群做病例汇聚和物资统计,每到报表周期就加班到深夜,数据从几个入口进来,字段口径对不上,上级临时换一个统计维度,又是几千条记录的重复劳动。这套基于SpringBoot + Vue + MyBatis + MySQL架构构建的管理系统,正是奔着解决这些问题去的。本文会从需求复盘、架构设计、核心模块、数据库表结构、关键代码实现、部署运维几个层面完整展开,把我实际做这类项目时踩过的坑和验证过可行的方法一并写出来。适合准备做卫生信息化项目的开发者、想学习前后端分离项目的读者,以及需要快速落地一套管理系统的团队参考。
1. 为什么会有这套系统:需求复盘与技术选型逻辑
1.1 公共卫生管理到底在管什么
很多人觉得疾病防控就是“报个病例”,真做起来才知道是个多线程业务。一个基层机构日常要管的至少包括:传染病监测与病例报告、慢性病随访管理、防控物资入库出库、疫苗接种安排与留观记录、健康宣教内容发布,以及给上级领导看的数据分析报表。这些业务在传统Excel时代最大的问题不是某个环节慢,而是数据各自为政:填报表的医生一套模板,做统计的科员另一套模板,物资管理员手里的账本又是独立的,同一批数据对不上是常态。
这套系统在设计时就把这些业务拉到了一个平台里。医生在网页上填报病例,审核人员在系统里完成审核,物资出入库有独立模块,统计报表由后端聚合后直接生成图表。业务数据从入口开始就是结构化的,后面做任何统计和追溯都不需要再人工清洗。
1.2 技术选型:为什么是SpringBoot + Vue + MyBatis + MySQL
这个组合在中小型管理系统中几乎是标准答案,每个成员都有明确分工。
SpringBoot解决的问题是后端快速开发和部署成本。它内置Tomcat,依赖管理靠starter就能搞定,不需要像传统SSM那样维护一堆XML配置。我要暴露一个REST接口,一个@RestController加几个注解就行,这对业务变化快、需求迭代频繁的管理系统来说非常关键。
Vue负责前端交互。疾病防控系统的表单特别多,病例填报、物资台账、接种记录,全是密集型输入场景。Vue的组件化开发可以把手写重复的表单封装成通用组件,配合Element UI这类现成UI库,后台管理界面能节省大量工作量。
MyBatis是持久层里最“看得见”的选择。防控系统有大量多表联查和动态统计SQL,比如按月按机构按病种聚合数据。MyBatis把SQL直接写在Mapper XML里,哪条SQL慢、需要怎么优化一眼就能看出来,调起来非常直接。如果是JPA,复杂查询的自动生成SQL有时候反而让你摸不着头脑。
MySQL就是成本与稳定性的平衡点。5.7和8.0都有大量生产案例,社区资料多,员工入职上手快。对一个区县级机构或者中小型团队来说,不需要一上来就上分布式数据库,MySQL单库分表用好索引已经能支撑相当长时间。
对比其他方案,Node.js后端写小型接口很快,但业务层一复杂,类型约束和工程规范就跟不上;Python Django适合原型快速验证,但大规模并发和高密度事务处理的历史经验相对少;PostgreSQL功能更强,但如果团队以前都是用MySQL的,迁移学习成本也是实打实的。这套技术栈最大的优势是:不追求某一个环节最强,而是保证整个链路最稳。
1.3 这套系统到底适合谁
第一种是做毕业设计或者课程项目的学生,前后端完整、模块清晰、可以直接跑起来改,比从零搭框架节省大量时间。第二种是中小型卫生机构的信息化负责人,想用一套内部系统替代Excel流程,不需要买几十万的商业平台。第三种是技术团队想快速交付类似的管理系统,拿这套源码做基座,替换业务模块就能复用。
需要说清楚的是,这类系统不是区域级全民健康信息平台,不涉及跨机构大规模数据交换,它的边界就是“一个机构或多个机构内部的防控业务闭环”。理解了这层定位,后面看架构和表设计就不会觉得简单了。
2. 整体架构与工程结构:前后端分离到底怎么分
2.1 前后端分离的协作逻辑
现在这套源码采用标准的前后端分离方式。用户在浏览器里打开Vue页面,页面通过axios发送HTTP请求到SpringBoot后端,SpringBoot通过Controller接收请求、Service处理业务规则、Mapper访问MySQL,最后把JSON数据返回给前端渲染。开发阶段前端用vue-cli的devServer代理解决跨域问题,部署阶段可以独立用Nginx托管前端静态文件,再把/api前缀的请求转发到后端。
整个调用链路是这样的:
- 浏览器加载Vue页面和路由
- 页面组件调用src/api目录下的模块函数
- 这些函数通过封装好的axios实例发出HTTP请求
- SpringBoot的Controller接收参数并校验
- Service层执行业务规则
- Mapper接口对应的XML执行SQL
- MySQL返回结果,逐层封装成ResponseResult结构的JSON
- 前端拿到数据后更新页面状态
2.2 后端工程结构:每个目录都不是摆设
后端工程的结构直接决定了项目后期好不好维护。我把这套源码的包结构列出来:
dd-system ├── pom.xml └── src/main/java/com/health/dd ├── DDApplication.java // 启动类 ├── config/ // CorsConfig、WebMvcConfig等配置 ├── controller/ // LoginController、ReportController... ├── service/ // 业务接口 ├── service/impl/ // 业务实现 ├── mapper/ // MyBatis Mapper接口 ├── entity/ // 数据库实体对象 ├── dto/ // 前端传入参数对象 ├── vo/ // 返回前端的数据对象 ├── common/ // Result、ResultCode、PageResult ├── utils/ // JwtUtil、PasswordUtil等 └── interceptor/ // JwtInterceptor这里我要重点强调一个原则:Controller不要写业务逻辑。很多人图省事,直接把数据库查询写在Controller里,项目一大了Service层形同虚设。这个源码里Controller只做参数接收、简单校验和调用Service,所有事务、权限、业务规则都封装在Service实现类中。这样做的直接好处是,以后如果要暴露给第三方接口或者写定时任务,直接复用Service即可,不用重新实现一遍。
2.3 前端工程结构
前端的目录结构同样为长期维护做了划分:
dd-web ├── package.json ├── vue.config.js // 开发代理和构建配置 └── src/ ├── main.js ├── App.vue ├── api/ // request.js及按模块拆分的接口文件 ├── router/ // index.js和动态路由处理 ├── store/ // Vuex,用户状态和token ├── views/ // login、dashboard、report、material、vaccine等页面 ├── components/ // 通用组件:分页、上传、弹窗 └── utils/ // auth.js、validate.js前端模块划分的逻辑是:api层统一管接口地址,views层只关心页面渲染,store统一管理用户登录态,router负责页面跳转和权限拦截。这样前端团队开发时各自负责自己的模块,互不干扰。
2.4 接口设计规范:统一返回结构
全站接口统一返回一个Result对象,前端axios拦截器只需要处理这一个结构,不用每个接口单独判断。Result的核心字段是code、message、data,code为200表示成功,401表示未认证,403表示无权限,500表示系统异常。
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }分页接口统一返回PageResult,包含total、records、current、size四个字段。前端分页组件直接对接这个结构,不需要每张表单独做分页逻辑。这套规范看起来简单,但实际团队协作时能省下大量联调时间。
3. 核心业务模块与数据库设计:疾病防控的闭环长什么样
3.1 模块全景与用户角色
这套系统的业务模块可以拆成七块,每块解决一个具体场景:
| 模块 | 核心功能 |
|---|---|
| 系统管理 | 用户、角色、菜单、机构管理 |
| 传染病监测 | 疾病字典维护、监测数据录入 |
| 病例上报 | 病例填报、提交、审核、驳回、归档 |
| 防控物资管理 | 物资台账、入库出库、库存预警 |
| 疫苗接种管理 | 接种人员登记、批次管理、接种记录 |
| 健康宣教 | 文章发布、公告推送、浏览统计 |
| 数据统计 | 病例趋势、病种占比、机构排名报表 |
用户角色设计上,至少需要区分系统管理员、疾控科人员、填报医生、机构管理员、普通查看者。不同角色看到的功能菜单不同,这由前端动态路由配合后端返回的菜单数据实现。权限管理如果做得好,后续给第三方机构开账号也方便。
3.2 核心数据库表设计逻辑
数据库是整个系统的地基。我挑几张核心表说一下设计思路。
用户表sys_user用于认证和基础信息:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| username | varchar(50) | 登录名,唯一索引 |
| password | varchar(100) | BCrypt加密后的密码 |
| real_name | varchar(50) | 真实姓名 |
| org_id | bigint | 所属机构ID |
| status | tinyint | 1启用 0禁用 |
| deleted | tinyint | 逻辑删除标记 |
病例上报主表rep_case_report是关键业务表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| report_no | varchar(32) | 报告编号,唯一 |
| org_id | bigint | 上报机构ID |
| disease_id | bigint | 疾病字典ID |
| patient_name | varchar(50) | 患者姓名 |
| patient_id_card | varchar(20) | 身份证号 |
| report_date | date | 发病/上报日期 |
| report_status | tinyint | 0草稿 1待审核 2已通过 3驳回 4归档 |
| reporter_id | bigint | 填报人ID |
| remark | varchar(500) | 备注 |
物资表mat_material和出入库表mat_stock_log用两张表实现库存和流水分离。mat_material只维护当前库存总量,mat_stock_log记录每一次入库出库的明细。以后要查“某个批次什么时候入库、发给哪个科室了”,直接查流水表即可,不用翻Excel。
这里有几个设计经验值得展开说:
第一,统一主键用自增ID还是雪花ID?如果系统只在一个机构内部跑,自增ID完全够用;如果以后要考虑数据合并或对接上级平台,主键最好用类似雪花算法的分布式ID,避免多库合并时冲突。
第二,时间字段统一用datetime类型,并设置create_time和update_time,所有表都加上,方便排查数据和做增量同步。
第三,数量、金额用decimal而不是float/double。防控物资里哪怕库存数量出现0.1的误差都会让盘点非常难受,数据库字段设计阶段就要避免浮点误差。
第四,所有核心业务表都加deleted逻辑删除字段。业务系统里物理删除记录会让审计追踪失效,比如病例上报后如果被物理删除,审核记录就断了,后面想追溯根本找不到。
第五,字符集要求utf8mb4。别用utf8,否则遇到患者姓名里的生僻字或特殊符号直接报错或乱码。
3.3 病例上报状态流转设计
病例从填报到归档,不是一条直线,而是一个状态机。我在源码里看到的状态流转是:医生创建记录时是草稿,可以随时修改保存;确认无误后点击提交,状态变为待审核;疾控科人员查看后,可以选择通过或驳回;驳回时需要填写原因,以便填报医生修改后重新提交;通过后记录进入已归档状态,不再允许修改,只能查看。
这个流程设计的要点在于“审核记录要单独建表”。有些人图方便,只在主表上加一个status字段,驳回原因直接覆盖。但真实业务中,一次病例可能被驳回两次、修改三次,所有历史状态都应该可追溯。所以我在设计时增加了rep_case_audit表,每产生一次审核动作就插入一条记录,主表的status只代表当前状态。这一个细节在项目上线后的实际使用中帮了大忙,月底对账时所有驳回和修改记录都能翻出来,责任清晰。
3.4 多表联查与统计SQL:口径一致是命根子
统计报表最容易翻车的地方是“统计口径”。什么叫口径?就是每条数据要不要过滤状态、要不要去重、时间范围怎么算。这套系统里统计都是走SQL聚合,比如按机构按月统计上报病例数:
SELECT o.org_name, DATE_FORMAT(r.report_date, '%Y-%m') AS month, COUNT(DISTINCT r.id) AS report_count FROM rep_case_report r LEFT JOIN sys_org o ON r.org_id = o.id WHERE r.report_status = 2 AND r.report_date BETWEEN #{startDate} AND #{endDate} GROUP BY o.org_name, DATE_FORMAT(r.report_date, '%Y-%m') ORDER BY month DESC这里有几个关键点:一是为什么用LEFT JOIN,如果某个机构当月没有上报记录,INNER JOIN会直接把它过滤掉,领导想看的是“所有机构的完成情况”,没上报的机构也要显示为0;二是report_status = 2表示只统计已审核通过的数据,草稿和驳回中的脏数据不能混入统计;三是COUNT(DISTINCT r.id)防止因为联查产生重复记录。
疾病类型占比统计也是一样,核心是GROUP BY加COUNT:
SELECT d.disease_name, COUNT(*) AS cnt FROM rep_case_report r LEFT JOIN dis_disease d ON r.disease_id = d.id WHERE r.report_status = 2 AND r.report_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY d.disease_name ORDER BY cnt DESC这种SQL在MyBatis XML里写清楚,别用三层的select嵌套把可读性搞没了。统计逻辑写在SQL里而不是在Java内存里去数,效率高得多。
4. 关键功能实现拆解:JWT登录、上报流程、图表报表
4.1 基于JWT的登录鉴权实战
登录是前后端分离系统第一道关卡。前端把用户名密码用POST方式传给后端,后端从sys_user表查出用户,校验密码是否正确,正确则生成一个带签名和过期时间的JWT返回给前端。之后前端每一次请求都在Header里带上Authorization: Bearer token,后端拦截器解析token,拿到当前用户的userId和角色信息。
JwtUtil的核心代码:
public class JwtUtil { private static final String SECRET = "your-secret-key"; private static final long EXPIRE_TIME = 24 * 60 * 60 * 1000; // 24小时 public static String generateToken(Long userId, String username) { Date now = new Date(); Date expireDate = new Date(now.getTime() + EXPIRE_TIME); return Jwts.builder() .setSubject(username) .claim("userId", userId) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }JWT拦截器里要做的事很简单:预检请求OPTIONS直接放行,白名单路径如登录接口和静态资源放行,其余请求解析Header中的token,失败则返回401。解析成功后将userId放入request attribute里,后续Controller通过参数绑定或者一个自定义注解取到当前用户。
密码存储我建议用BCrypt而不是MD5加盐。MD5加盐虽然比明文好,但是BCrypt每次生成的hash都带随机盐,同样的密码两次加密结果不一样,暴力破解成本高很多。Spring Security或者Shiro里都提供BCrypt实现,这套源码中实际上也使用了BCrypt方式,复制代码时注意别把PasswordEncoder漏掉。
这里有几个坑我要特别提醒:
第一,token过期时间别设太短也别太长。太短用户频繁掉线,太长有安全风险。内网管理系统24小时比较合适,如果你担心token被偷用,建议在Redis中维护一个session状态,token过期后强制重新登录。
第二,拦截器放行配置要非常小心。登录接口、验证码接口、前端静态资源、Swagger文档路径都要加白名单,否则前端刷新一下页面就被重定向到登录页,排查半天发现是拦截器把静态请求也拦了。
第三,前后端联调时最容易出的问题是token传了但没按规范带上Bearer前缀,后端的解析逻辑要从Header中截取完整值再解析。
4.2 病例上报核心流程:多表写入的事务一致性
病例上报不是简单insert一条记录。一次规范的填报,除了主记录还要写审核日志、更新机构的日统计表,如果有附件还要保存附件记录。这些操作要么全部成功,要么全部失败。
在Service实现类上加@Transactional:
@Transactional(rollbackFor = Exception.class) public Long createReport(ReportDTO dto) { ReportCase report = new ReportCase(); BeanUtils.copyProperties(dto, report); report.setReportNo(generateReportNo()); report.setReportStatus(0); report.setReporterId(CurrentUser.get().getId()); reportMapper.insert(report); AuditLog audit = new AuditLog(); audit.setReportId(report.getId()); audit.setActionType(1); // 创建 audit.setOperatorId(CurrentUser.get().getId()); auditMapper.insert(audit); statisticsMapper.updateDailyReport(report.getOrgId(), report.getReportDate(), 1); return report.getId(); }SpringBoot默认情况下@Transactional只对RuntimeException回滚,如果方法里抛出受检异常,默认是不会回滚的。我这里显式指定rollbackFor = Exception.class,确保任何异常都触发回滚。这一点在真实业务中很重要,尤其是后面接消息队列或者第三方接口时,受检异常在调用链里很常见。
再提一个容易被忽略的问题:事务不要开在Controller层。Controller把事务注解加上虽然也能回滚,但拦截器、参数校验等操作也会被纳入事务范围,白白拉长数据库连接占用时间。把事务放在Service实现类才是标准姿势。
4.3 Vue端权限控制:路由守卫与按钮级指令
前端要做两级权限。
第一级是页面访问权限。用户登录后,后端根据角色返回一个菜单树,前端拿到菜单后动态注册路由。这样角色是填报医生的用户根本不会加载出“系统管理”页面。路由守卫的关键代码:
router.beforeEach((to, from, next) => { const token = getToken() if (token) { if (to.path === '/login') { next('/') } else { next() } } else { if (to.path === '/login') { next() } else { next('/login') } } })注意动态路由不能一进入系统就全部注册,否则刷新页面路由丢失会出现白屏。常见做法是把后端返回的菜单数据缓存到store里,刷新时先调一次获取用户信息接口,再动态addRoutes。
第二级是按钮级权限。比如审核按钮只能疾控科人员看到,填报医生看不到。实现方式是用vue自定义指令,把需要的权限编码挂在按钮上:
Vue.directive('permission', { inserted(el, binding) { const required = binding.value const userPerms = store.getters.permissions if (!userPerms.includes(required)) { el.parentNode.removeChild(el) } } })前端权限只是体验优化,真正的安全边界在后端。后端接口必须在查询条件里加上机构ID和角色过滤,防止登录用户直接改URL越权访问。
4.4 数据可视化报表:后端聚合、前端图表展示
统计报表前端用的是ECharts。后端提供一个聚合接口,返回结构化的统计数据,前端拿到后直接塞给图表组件。
后端统计接口伪代码:
@GetMapping("/statistics/reportTrend") public Result<List<Map<String, Object>>> reportTrend(String startDate, String endDate) { List<Map<String, Object>> list = statisticService.getReportTrend(startDate, endDate); return Result.success(list); }Vue页面中初始化图表时,要注意在组件销毁时调用chart.dispose(),否则页面来回切换会导致内存泄漏。现在很多图表库在SPA里的卡顿问题都出在没及时释放实例。我这里给出一个标准写法:
mounted() { this.chart = echarts.init(this.$refs.chart) this.loadData() }, beforeDestroy() { if (this.chart) { this.chart.dispose() } }, async loadData() { const res = await api.getReportTrend() this.chart.setOption({ xAxis: { data: res.data.map(item => item.month) }, yAxis: {}, series: [{ type: 'line', data: res.data.map(item => item.count) }] }) }5. 从零到一部署运行:环境准备、数据库初始化与典型踩坑
5.1 环境版本清单
要跑起这套源码,首要任务是版本匹配。我把推荐的版本列成一张表:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8 | Spring Boot 2.x默认支持 |
| Maven | 3.6+ | 依赖管理 |
| MySQL | 5.7或8.0 | 建议8.0,性能更好 |
| Node.js | 14或16 | vue-cli 4/5的稳定环境 |
| npm | 6+ | 可用国内镜像加速 |
| 前端构建产物 | dist目录 | Nginx或SpringBoot static目录托管 |
很多人上来就卡在版本问题。老源码跑不起来最常见的原因就是JDK版本太高,Spring Boot 2.x在高版本JDK下会出现模块访问报错。如果pom.xml里spring-boot-starter-parent版本是2.2.x或2.3.x,建议直接升到2.7.x,同时保持JDK 1.8。Spring Boot 3.x改成了jakarta.*命名空间,很多老代码的javax.*导入要批量替换,那又是一轮折腾,不建议在起步阶段碰。
5.2 数据库初始化的正确姿势
第一步创建数据库,注意字符集:
CREATE DATABASE dd_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;第二步导入SQL文件:
mysql -u root -p dd_system < dd_system.sqlWindows下执行这个命令之前,先确认SQL文件本身是UTF-8编码,cmd终端可以执行chcp 65001切到UTF-8代码页,否则中文字段注释可能变成乱码。另外MySQL 8.0的认证插件默认是caching_sha2_password,如果后端用的还是旧版mysql-connector-java,启动时会报Public Key Retrieval is not allowed。解决办法是把驱动升级到8.x版本,或者在JDBC连接串上加allowPublicKeyRetrieval=true。
数据库连接字符串里的时区参数也不能省。MySQL 8.0默认时区可能导致日期字段相差8小时,我一般这样写:
url: jdbc:mysql://localhost:3306/dd_system?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true5.3 后端启动步骤与报错排查
数据库准备好后,打开application.yml检查数据源配置:
server: port: 8080 spring: datasource: username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/dd_system?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl启动命令:
mvn spring-boot:run或者打包后运行:
mvn clean package -DskipTests java -jar target/dd-system-1.0.0.jar我遇到过的后端启动报错基本就几类:
一是端口被占用,报错信息里有Port 8080 was already in use。用netstat -ano | findstr 8080查PID,然后结束进程。
二是数据库连接失败,检查MySQL服务有没有启动、密码对不对、防火墙有没有放行3306。如果本机装了多个MySQL实例,端口可能不是3306,要在连接串里改。
三是MyBatis提示找不到SQL,检查mapper-locations是否配置正确,以及Mapper接口和XML文件的namespace是否完全匹配。
四是SQL打印不出来,看log-impl配置。StdOutImpl会直接在控制台输出SQL,联调阶段很有用,生产环境记得关掉或者改成logback输出。
5.4 前端启动与打包部署
前端环境准备阶段,npm install是最大的一道坎。网络不好时建议用淘宝镜像:
npm config set registry https://registry.npmmirror.com npm install启动开发模式:
npm run serve开发模式下跨域靠vue.config.js代理解决:
module.exports = { devServer: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }注意,前端请求的接口地址必须带/api前缀,后端Controller的RequestMapping也要统一用/api开头,代理规则才生效。这个约定如果前后端不一致,开发环境看起来能跑通,一打包部署就白屏。
构建生产包:
npm run build产物在dist目录。部署可以选择两种方式:
第一种是用Nginx托管静态文件,反向代理API:
location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; }try_files $uri $uri/ /index.html;这句必须有,否则Vue用history模式路由时,刷新二级页面会报404。
第二种是把dist里的文件直接复制到SpringBoot的src/main/resources/static目录,重新打包后端,然后直接访问8080端口就能打开系统。这种方式适合内网临时使用,但不适合正式环境,因为静态文件和API混在同一个服务里,不方便做独立扩容。
5.5 部署后一定要验证的三件事
系统跑起来之后,不要急着提测,先按真实业务走一遍主流程。我在上线前的检查清单基本固定:登录后能否获取菜单和权限;填报一个病例后,审核流程能否正常流转;统计报表的数字是否和数据库手工查出来一致。这三件事只要有一件对不上,后面返工成本很高。
尤其在统计报表这块,我吃过亏。有一次系统上线后,领导问某月份上报数,系统显示100,但Excel底档是103,后来排查发现是统计口径没统一,Excel里把草稿状态也算了进去。从那以后,我的做法是先写统计SQL,再用同样的条件在数据库里手工验证一次,确认无误再给前端接图表。
6. 生产环境必须注意的细节:性能、安全与演进方向
6.1 数据库索引和性能调优
这套系统的核心业务表是病例上报表,数据量一旦上十万,查询速度就会明显下降。我建议在以下几个字段上建联合索引:
ALTER TABLE rep_case_report ADD INDEX idx_org_date_status (org_id, report_date, report_status);这个索引针对的是最常见的查询模式:按机构查某段时间的上报记录,同时过滤状态。创建索引后,用EXPLAIN看一下执行计划:
EXPLAIN SELECT * FROM rep_case_report WHERE org_id = 1 AND report_date >= '2024-01-01' AND report_status = 2;执行计划里type应该至少是range或ref,而不是ALL。如果是ALL,说明全表扫描,索引没建对或者SQL写法有问题。
统计查询里尽量避免SELECT *,只需要id、org_id、disease_id这些字段就只查这些。MySQL的联合索引可以把查询条件都覆盖在索引里,减少回表次数,这个优化对报表类接口特别明显。另外,当月数据量特别大时,可以考虑按月做分表,比如rep_case_report_202401,但分表会带来跨表查询复杂度,前期数据量没到百万级别不建议引入。
6.2 MyBatis缓存与N+1查询问题
MyBatis的缓存是面试题常客,实际项目里也要小心用。
一级缓存是SqlSession级别的,同一事务内两次相同查询会命中缓存。这看起来是好事,但如果你在事务里先查了一条记录,然后通过别的SQL更新了这条记录,再用同一个SqlSession查询,拿到的是缓存里的旧值。所以事务内部要改数据时,别依赖一级缓存的新鲜度。
二级缓存是跨SqlSession的,在Mapper XML里加<cache/>就开启了。听起来很爽,但实际上如果表经常更新,二级缓存反而会产生脏读。我的建议是:只对基本不变化的基础数据表开二级缓存,比如疾病字典、机构表,病例上报这种频繁插入更新的表坚决不开。另外开二级缓存的实体类要实现Serializable接口,否则反序列化时直接报错。
N+1问题是新手最容易踩的。比如查列表时,先查10条病例,再循环每一条的机构名称和疾病名称,每条触发一条SQL,一共11条查询。正确做法是一步联查或者用Mapper的嵌套结果映射。MyBatis支持类似:
<resultMap id="ReportDetailMap" type="ReportCaseVO"> <id property="id" column="id"/> <result property="patientName" column="patient_name"/> <association property="org" javaType="SysOrg"> <id property="id" column="org_id"/> <result property="orgName" column="org_name"/> </association> </resultMap>这样一条SQL就能把主表和关联表的数据都装进来,日志里SQL数量从11降到1,接口响应时间能快一个数量级。
6.3 安全加固事项
卫生信息系统的数据涉及个人隐私,安全不是可选项。
第一是SQL注入防护。MyBatis的#{}是预编译参数,直接用没问题,但${}拼接的字段要特别小心。最典型的是排序字段,用户传一个orderColumn,如果直接ORDER BY ${orderColumn}就有注入风险。我的做法是做一个白名单映射,后端收到字符串后先去Map里查对应的真实字段名,查不到就返回默认字段。
第二是接口越权。分页接口如果只按条件查询但没限定机构ID,一个医生登录后把请求里的参数改一改,就能看到其他机构的数据。所有业务查询都必须从token里解析当前用户所属机构,SQL条件里强制带上机构范围。这个规矩在代码评审时要反复强调。
第三是敏感数据脱敏。系统日志和接口返回值里,手机号、身份证号不能全量打印。身份证至少要隐藏中间8位,手机号隐藏中间4位。日志脱敏工具在logback里配置PatternLayout自定义规则,或者干脆在DTO输出实体上对敏感字段打@JsonSerialize注解。
第四是附件上传限制。病例填报经常需要上传检验单截图,上传接口必须校验文件扩展名白名单、文件大小上限、文件名过滤特殊字符。我见过一个项目因为没限制上传类型,被人传了JSP木马,直接导致服务器被控制,这个教训很沉重。
6.4 这套系统后续可以怎么扩展
源码的优势在于可以基于完整骨架继续生长。我认为比较典型的扩展方向有几个:
一是对接上级平台。通过定时任务把审核通过的病例记录按标准XML或JSON格式推送出去,或者用消息队列异步传输,避免影响主业务流程。
二是做可视化大屏。把统计接口的数据接到大屏模板上,展示机构上报完成率、疾病趋势、物资库存实时预警,对管理层汇报时非常加分。
三是增加消息通知。病例被驳回、物资库存低于预警线时,通过短信或企业微信通知到负责人。这个功能可以让系统从“被动录入”变成“主动提醒”。
四是移动端适配。实际使用中医生更多在门诊间隙填报,PC端操作不如手机方便。做一个H5版本或者小程序版本,复用后端接口,前端的表单校验和草稿本地缓存做一套就能用。
五是集成Swagger或者Knife4j生成接口文档。前端和后端联调时,接口文档是刚需。一个更新及时的接口文档能省下大量沟通成本,特别是团队里有新人加入时。
最后说点个人体会。这套系统从最初的需求调研到跑起来,我最大的感触是:业务口径必须在数据库设计阶段就定死。状态字段的取值、统计报表的过滤条件、机构层级关系,这些如果一开始不明确,后面每改一个地方都要波及好几张表和十几行SQL。我后来再做类似管理系统,先把状态机和统计口径画在纸上,找业务方逐个确认,确认完再动手写代码,返工次数明显少了很多。如果你也在做这类业务,建议先花两天梳理流程,不要急着开IDE,这部分前置工作比写一万行代码都值钱。