在孩子接种疫苗这件事上,我见过太多基层社区卫生服务中心的手忙脚乱:接种本翻得哗哗响、冰箱里疫苗批号对不上登记表、家长排队半小时结果发现孩子今天不能打、到了该打第二针的时间没人提醒……这些问题的根源,基本都是靠Excel表和纸质台账在硬扛。
所以当我接到一个儿童疫苗医疗管理系统的开发需求时,第一反应不是“又要写个CRUD”,而是意识到这事儿的核心难点压根不在技术,而在业务流程的严谨程度。疫苗管理跟普通商品库存完全是两回事:疫苗有冷链温度要求、有批号效期、有最小包装单位、有严格的接种间隔天数,更别提儿童档案里那些“过敏史”“出生体重”“上次接种日期”等一堆会影响接种决策的字段。
这篇文章我就以Spring Boot为技术底座,完整拆解一个儿童疫苗医疗管理系统的设计和落地过程。从需求分析、表结构设计,到核心业务代码和并发扣库存的坑,再到实际测试中踩过的雷,我会把能直接抄作业的部分全放出来。适合正在做医疗类管理系统、或者想了解Spring Boot项目从需求到落地完整过程的开发者参考。
1. 需求拆解:儿童疫苗系统到底在管什么
1.1 疫苗管理与普通库存管理的本质差异
很多第一次接触疫苗系统的开发者,容易陷入一个误区:把疫苗当成普通商品来设计,无非就是商品表、入库单、出库单、库存表。但真正跑过业务就会明白,疫苗的管理约束条件比普通商品多得多。
首先是批号效期。每一支疫苗都有生产批号和有效期,而且疫苗的最小管理单位往往不是“个”而是“支”,一支对应一次接种。系统必须能做到按批号追踪:这批疫苗从哪个厂家来、什么时候入库、被哪个孩子接种了、还剩多少支。一旦某批次疫苗被通报有问题,系统要能在几分钟内查清楚该批次都打给了谁,这个能力叫“反向追踪”,是疾控部门明确要求的。
其次是接种间隔。不同疫苗有自己的免疫程序,比如乙肝疫苗是“0-1-6月龄”三针方案,两针之间至少间隔28天。系统在做接种计划时,不能简单按日历提醒,必须校验与上一针的时间差。我见过一个项目经理想当然把间隔设成“大于0天就行”,结果差点闹出医疗事故级别的低级bug。
最后是禁忌症校验。儿童在发热、急性感染期、或者对某成分过敏时,是不允许接种特定疫苗的。这意味着系统必须保存儿童的基础健康信息,在开单接种前自动做一轮规则校验,而不是单纯由操作员肉眼判断。
1.2 核心角色与主业务流程
一个完整的儿童疫苗管理平台,至少要覆盖五类角色:
- 家长/监护人:在线建档、预约接种、查看接种记录和接种计划。
- 接种医生/护士:核对儿童信息、执行接种登记、记录不良反应。
- 库房管理员:负责疫苗入库、盘点、效期预警、报损处理。
- 门诊管理者:查看接种数据统计、异常报告、工作量分析。
- 系统管理员:维护基础数据字典、疫苗品类目录、用户与权限。
主业务流程可以概括成一条线:家长在线建档 -> 系统根据儿童月龄自动生成接种计划 -> 家长选择时间段预约 -> 医生在接种台核对档案和疫苗 -> 系统自动校验禁忌和间隔 -> 扣减库存、生成接种记录 -> 系统自动短信提醒下一针。
这条链路每个环节都有独立的业务规则和数据要求。我实际开发时把系统划分成了八个子系统模块:儿童档案、疫苗目录、库存管理、预约管理、接种登记、不良反应、消息通知、系统管理。前三个是基础数据,中间三个是核心业务流程,后两个是辅助能力。
1.3 非功能性需求同样不能忽视
除了功能模块,有几个非功能性需求在医疗场景里特别要命。
一个是操作审计。谁在什么时间对哪条儿童档案做了修改、修改前是什么值,必须有日志。医疗系统的数据合规性要求比普通业务系统高得多,我用Spring Boot的拦截器加自定义注解,给核心写操作全部加了操作日志记录。
另一个是高可用和容灾。接种门诊上午的业务高峰,可能同时有几十个家长在前台办理,系统慢几秒就会排长队。数据库连接池、缓存层、慢查询优化,这些都要提前考虑。我遇到过系统上线第一个月,一个不算复杂的统计报表接口把数据库CPU打满的情况,原因就是没做合理的索引和分页。
2. 技术选型与工程结构:Spring Boot生态下的取舍
2.1 技术栈选择的理由
Spring Boot在这个项目里几乎是必然选择,不是因为“流行”,而是它的生态能覆盖医疗管理系统几乎所有诉求。
持久层我用的是MyBatis-Plus,理由很直接:CRUD操作能少写大量重复代码,同时保留手写XML SQL的灵活性。疫苗管理系统的查询条件非常多——按批号、按厂商、按效期区间、按接种状态,这些复杂查询用MyBatis-Plus的QueryWrapper不够表达时,直接落到XML里写动态SQL更可控。
数据库选型上,MySQL 8.0是这个体量项目的稳妥方案。它支持JSON字段(用来扩展存储儿童过敏史这类不规则数据)、窗口函数(方便做批次库存的先进先出分析)、事务和行级锁(后面说并发扣减库存会用到)。
缓存我用Redis解决两类问题:一是预约时段的实时余号查询,二是高频读取的基础数据(比如疫苗品类的标准接种程序)。前者要保证数据实时性,缓存只是挡一层读压力;后者可以长时间缓存,因为目录变更频率极低。
鉴权方面,Spring Security加JWT是最成熟的组合。但我实际做母婴类或医疗机构项目时,会刻意拆出“管理端”和“家长端”两套登录体系——后台用RBAC权限模型,前台家长端用简单的手机号验证码登录,两套Token互相隔离,避免越权访问。
2.2 Maven工程如何拆分多模块
单模块的Spring Boot工程做小Demo没问题,做儿童疫苗这种业务域清晰的项目,我会建议直接拆多模块Maven工程,哪怕前期麻烦点。模块边界越早划清楚,后面改起来越不会牵一发动全身。
我习惯拆成下面几个模块:
vaccine-parent(父POM,统一依赖版本) ├── vaccine-common(公共工具类、统一返回体、异常定义) ├── vaccine-system(系统管理:用户、角色、权限、字典) ├── vaccine-file(儿童档案模块) ├── vaccine-stock(疫苗目录与库存模块) ├── vaccine-appointment(预约模块) ├── vaccine-register(接种登记模块) ├── vaccine-notify(消息通知模块) └── vaccine-admin(管理端Web入口)每个业务模块内部再分 controller、service、mapper、entity、dto 五层,这是比较常规但实用的分包方式。有人觉得这种分包太“传统”,但我个人经验是:医疗类项目合作的业务人员往往不是技术背景出身,传统分层能降低沟通成本,保证任何人打开工程都能在三分钟内找到对应功能的位置。
模块之间通过接口调用,而不是直接new对方的Service实现。比如预约模块需要查疫苗库存,就定义好库存模块提供的Feign接口或本地接口,这样后期拆微服务时不需要重写业务代码。实际这个项目的体量不太需要拆微服务,但接口隔离的习惯在前几个项目里救过我,所以一直保留着。
2.3 核心依赖清单
在pom.xml里,有几个依赖是这个项目的关键。版本号我统一放到父POM的dependencyManagement里管理,避免子模块版本漂移。核心依赖如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency>额外提一下validation这个依赖。医疗项目里前端传来的参数不校验是绝对不能容忍的,比如儿童出生日期传了个未来时间、疫苗批号传了空字符串,这些都要在后端入口拦截住。Spring Boot的validation注解加自定义校验器,能在Controller层就把脏数据挡掉,而不是等落库了才发现。
3. 数据库设计:把疫苗业务的严谨性落到表结构里
3.1 核心表结构全景
数据库是整个系统的地基,表结构设计错了,后面写再多代码都别扭。我按业务域把表拆成六组,每组之间有外键关联,但实际查询主要靠业务字段关联,不过度依赖数据库外键约束,这是为了避免高并发插入时外键检查带来的性能损耗。
儿童档案域是基础中的基础:
- child_profile:儿童档案主表,包含姓名、性别、出生日期、父母信息、联系方式。
- child_health_info:儿童健康扩展表,存过敏史、慢性病、出生情况,一对一到child_profile。
- child_vaccine_plan:儿童个体接种计划表,由系统根据免疫程序自动生成,也支持医生手动调整。
疫苗库存域是业务核心:
- vaccine_category:疫苗品类目录,比如“乙肝疫苗”“卡介苗”“百白破疫苗”。
- vaccine_batch:疫苗批次表,记录生产批号、厂家、生产日期、有效期、入库数量。
- vaccine_stock:实时库存表,按“疫苗品类 + 批次”维度记录当前库存量。
业务流程域:
- appointment_order:预约单主表,记录儿童、预约时段、预约状态。
- appointment_time_slot:时间段表,配置每天哪些时段可预约、每个时段最大号源。
- vaccination_record:接种记录表,接种完成后写入,关联儿童、疫苗批次、接种医生。
- vaccination_record_detail:接种记录明细,关联到具体的疫苗品类和批号。
辅助能力域:
- sys_user、sys_role、sys_user_role:后台用户与角色。
- operation_log:操作审计日志。
- message_log:短信/站内信发送记录。
- vaccine_adverse_event:疑似预防接种异常反应报告。
3.2 关键表字段设计细节
这里我摘几张设计时纠结最多的表,把字段选择背后的考虑讲清楚。
vaccine_batch表的重点在效期和批号追踪。除了常规的batch_no、manufacturer、production_date、expire_date,我还加了一个status字段,取值包括“正常/即将过期/已过期/冻结/报废”。为什么不只用expire_date去算?因为效期预警需要定时任务扫描更新状态,而且冻结这种操作不是效期决定的,是质量问题导致的。把状态独立出来,查询和展示都方便。
child_vaccine_plan表里有三个字段被很多人忽略:plan_status、due_date、completed_date。plan_status有“待接种/已接种/已逾期/已取消”四种状态,due_date是计划接种日期。系统的短信提醒任务每天扫描due_date在三天内的待接种计划,所以这个字段必须建索引,否则数据量上来后定时任务会把数据库拖垮。
appointment_time_slot表除了日期和时段,一定要有total_count和booked_count两个字段。前端展示余号时直接拿total_count减去booked_count,不需要实时count预约表,减轻数据库压力。但这两个字段的更新要放在同一个数据库事务里,后面讲并发控制时细说。
我把最核心的两张表的建表SQL放在下面,你可以直接参考。
CREATE TABLE vaccine_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, category_id BIGINT NOT NULL COMMENT '疫苗品类ID', batch_no VARCHAR(64) NOT NULL COMMENT '生产批号', manufacturer VARCHAR(128) NOT NULL COMMENT '生产企业', production_date DATE NOT NULL COMMENT '生产日期', expire_date DATE NOT NULL COMMENT '有效期至', stock_count INT NOT NULL DEFAULT 0 COMMENT '当前库存数量', status TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1即将过期 2已过期 3冻结 4报废', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_batch_no (batch_no), KEY idx_category_status (category_id, status) ) ENGINE=InnoDB COMMENT '疫苗批次表'; CREATE TABLE vaccination_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, child_id BIGINT NOT NULL, batch_id BIGINT NOT NULL, vaccine_category_id BIGINT NOT NULL, inoculate_date DATETIME NOT NULL, inoculate_doctor VARCHAR(32) NOT NULL, inoculate_position VARCHAR(32) COMMENT '接种部位', batch_no VARCHAR(64) COMMENT '冗余批次号,便于快速查询', dose_no TINYINT COMMENT '第几剂次', reaction_flag TINYINT DEFAULT 0 COMMENT '是否发生不良反应', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_child_date (child_id, inoculate_date), KEY idx_batch (batch_id) ) ENGINE=InnoDB COMMENT '接种记录表';这里特别说一下batch_no冗余设计。在vaccination_record表里存一份batch_no,看起来违反数据库范式,但实际查询时极其有用:当需要按批号反向追踪接种儿童名单时,直接一条SQL就能查出来,不需要join批次表。这种“冗余换性能”在业务报表类系统里是值得的。
3.3 状态机设计:让业务流程不可逆操作有迹可循
疫苗管理系统的很多状态是单向流转的,尤其接种记录,一旦登记成功,不允许随意修改或删除。我在设计阶段就给核心流程定义了状态机。
预约单的状态流转:待接种 -> 已接种 / 已取消 / 已过期。待接种状态可以取消,但已接种状态下不能修改接种日期。
库存批次的状态流转:正常 -> 即将过期 -> 过期,正常 -> 冻结 -> 报废。冻结可以由质量问题触发,但报废不可逆,需要审核权限。
代码层面,我不建议把状态校验散落在各个Service方法里,而是用一个状态流转校验器统一处理。简单地说就是定义一个Map,key是当前状态,value是允许流转到的状态集合,流转前先校验。这个做法能防止后人维护代码时随手加一条绕过状态判断的更新语句。
4. 核心业务模块的实现要点
4.1 儿童档案与自动接种计划的生成
儿童建档是系统数据链路的起点,档案质量直接决定后续所有业务能否正常开展。建档表单除了常规信息,最重要的三个采集项是:出生日期、出生体重、过敏史。出生日期用于计算月龄,月龄又决定接种计划;出生体重决定部分疫苗的禁忌(比如出生体重低于2500克时,卡介苗需要暂缓);过敏史则在每次开单前做禁忌校验。
接种计划的自动生成,是医疗规则和代码结合最紧密的地方。国内现行的儿童免疫规划程序,我简化成了一张配置表存到数据库里,每条配置包含:疫苗品类、适用月龄、剂次序号、间隔天数、补种规则。这样设计的好处是,如果地方疾控有特殊调整,只需要改配置表,不用改代码。
生成计划的核心逻辑是递归加计算。系统根据儿童当前月龄,遍历所有疫苗配置,判断是否满足“当前月龄到达适用月龄”和“与上一剂次间隔满足要求”这两个条件,满足就生成一条child_vaccine_plan记录。这里有个边界情况要处理:如果儿童已经错过了推荐月龄,系统要生成的是“补种计划”,而不是简单忽略。补种计划的接种间隔往往和常规程序不同,所以配置表里补种间隔要单独维护。
4.2 疫苗入库与批次效期管理
入库操作是库存模块最基础也最不能出错的功能。我设计的入库单包含两级结构:入库单主表和入库明细表。一个入库单可能包含多个疫苗品类,每个品类又对应一个批次号,明细表记录每个批次的入库数量。主表记录操作人、入库时间、供应商、冷链温度记录单号。
这里有一个实际业务细节:疫苗入库时必须录入冷链温度记录。疫苗在运输途中温度超标,这批货是要拒收或者走报废流程的。所以入库表单里我加了temperature_records字段,存储运输途中的温度监控数据,后端做校验,凡是温度超出2-8℃范围的,强制走异常入库流程,不允许正常入库。
效期预警是通过Spring Boot自带的定时任务实现的。我每天凌晨跑一次任务:查所有status为正常且expire_date距当前时间小于三个月的批次,把status改成“即将过期”,同时发送通知给库房管理员和门诊负责人。三个月这个阈值是可以配置的,因为不同疫苗的效期差异很大,统一阈值会误报或者漏报。
4.3 预约时段设计与并发控制
预约模块面向家长端,是系统里并发压力最大的地方。早上八点放出新号源的时候,一个门诊可能同时有上百个家长在刷,如果余号扣减不做并发控制,很容易出现“同一个号源被两个家长同时约到”的问题。
我在设计时先把时间段表设计成固定总量模式。每天由管理员维护第二天到第七天的号源,每个时段一个总量。家长预约时,后端做两件事:校验预约单是否已存在(防止同一个儿童重复预约同一个时段),然后用UPDATE语句原子扣减余号。
原子扣减的关键SQL是这样的:
UPDATE appointment_time_slot SET booked_count = booked_count + 1 WHERE id = #{slotId} AND booked_count < total_count这条SQL利用MySQL的行锁和条件判断,在同一个语句里完成“检查余号+扣减”两个操作,返回影响行数为1说明扣减成功,为0说明号源已满。这比先select后update的写法安全得多,彻底避免了并发超卖。扣减成功后,再插入预约单,两步放在同一个事务里。
为什么不用Redis的incr操作?理论上Redis做计数器性能更好,但预约单和时段表都是数据库中的核心业务数据,如果Redis里扣减成功、数据库事务回滚了,就会出现计数不一致。对于这个量级的并发,数据库原子更新完全够用,而且保证了数据一致性,没必要引入双写一致性的复杂度。
4.4 接种登记:信息核对与禁忌校验
接种登记是医生在接种台上执行的操作,是整个系统最严肃的一环。医生扫描儿童的接种本条码,或者输入儿童编号,系统展示儿童基本信息和本次待接种的疫苗计划。此时不是直接点“确认接种”就完了,系统自动执行三道校验:
第一道是身份校验,页面上用大字体展示儿童姓名、出生日期、监护人信息,要求操作员肉眼核对,页面必须停留至少三秒才能继续。
第二道是疫苗批次校验。库存的批次必须满足:status为正常、有效期未过、批次和接种计划中的疫苗品类一致。这里我额外做了一个逻辑:同一个批次如果存在冻结记录,就算status还是正常,也不允许使用。这个冻结状态可能是临时的,比如疾控通知某批次有问题在排查中,但报告出来之前不能继续接种。
第三道是禁忌症校验。系统读取child_health_info中的过敏史,和疫苗品类的禁忌成分表做匹配,匹配到任何一项就弹出强警示。这套规则我放在了后端,而不是单纯前端校验,就是为了防止绕过页面直接调接口的情况。
接种确认后,事务内完成四个操作:写入接种记录、扣减疫苗批次库存、更新儿童接种计划状态、把预约单置为已完成。四个操作必须全部成功,任何一步失败都要整体回滚,否则会出现“疫苗打了但记录没生成”的情况。
5. 关键代码与踩坑实录
5.1 疫苗效期校验的工具类实现
效期校验在多个环节复用:入库时校验、接种时校验、盘点时校验。所以我把校验逻辑抽成了一个独立工具类。这里有坑:很多人直接拿当前日期和expire_date比大小,忽略了一个业务规则——疫苗不能到有效期当天还在使用,一般会要求提前一个月停止使用或进入预警状态。具体提前多少天,不同疫苗有差异,所以要支持传入阈值参数。
public class VaccineExpireChecker { public static final String STATUS_NORMAL = "0"; public static final String STATUS_EXPIRING = "1"; public static final String STATUS_EXPIRED = "2"; public static boolean isExpired(LocalDate expireDate) { return expireDate.isBefore(LocalDate.now()); } public static boolean isExpiring(LocalDate expireDate, long warnDays) { LocalDate warnDate = expireDate.minusDays(warnDays); return !expireDate.isBefore(LocalDate.now()) && !warnDate.isAfter(LocalDate.now()); } public static boolean canUse(LocalDate expireDate, long stopUseDays) { LocalDate stopDate = expireDate.minusDays(stopUseDays); return stopDate.isAfter(LocalDate.now()); } }在我做的项目里,接种环节使用的stopUseDays配的是30天,也就是说疫苗距离过期不到一个月时,系统会直接禁止使用。这个阈值是和门诊负责人确认过的,各地可能不一样。接入实际项目时,建议先确认当地疾控的管理要求,别想当然用我这里的配置。
5.2 并发扣减库存的完整代码
库存扣减是另一个高并发风险点。预约时段扣的是号源,接种确认时扣的是疫苗实物库存。两种扣减我都用了数据库原子更新,区别在于疫苗库存需要在扣减后再校验一下库存不能为负数。
@Transactional(rollbackFor = Exception.class) public boolean deductStock(StockDeductRequest request) { LocalDate expireDate = vaccineBatchMapper.selectExpireDate(request.getBatchId()); if (!VaccineExpireChecker.canUse(expireDate, 30)) { throw new BusinessException("该批次疫苗临近有效期,禁止使用"); } int updated = vaccineBatchMapper.deductStock( request.getBatchId(), request.getDeductCount()); if (updated == 0) { throw new BusinessException("疫苗库存不足,请更换批次"); } vaccineBatchMapper.updateStockStatus(request.getBatchId()); VaccinationRecord record = buildVaccinationRecord(request); vaccinationRecordMapper.insert(record); return true; }<update id="deductStock"> UPDATE vaccine_batch SET stock_count = stock_count - #{deductCount} WHERE id = #{batchId} AND stock_count >= #{deductCount} </update>注意这里有一个我正在用的状态同步技巧:扣减成功后,调用updateStockStatus把“即将过期”状态校准一下。因为批次一旦被领出使用到最后一支,库房里就不存在即将过期的实物了,状态字段需要跟着刷新,否则效期预警报表会显示一条已经打空的批次还在预警列表里。
5.3 定时任务的实现与避坑
定时任务我用Spring Boot自带的Scheduling,没有引入额外的分布式任务框架。单机部署场景够用,但有两个坑必须提醒。
第一个坑是任务重复执行。如果后续系统做了多实例部署,不带分布式锁的@Scheduled会在每个实例上各跑一遍,导致同一批人收到两次到期提醒短信。解决方式是引入Redis分布式锁,让同一时间只有一个实例执行任务。
第二个坑是@Scheduled任务线程默认是单线程的。如果你在一个项目里定义了多个定时任务,它们默认串行执行。我遇到过效期预警任务因为前面的统计报表任务跑了一小时,导致当天预警短信延迟到下午才发出。解决方式很简单,配置一个线程池来执行任务,每个任务独立线程。
@Configuration public class ScheduleConfig implements SchedulingConfigurer { @Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(5)); } }还有一点,定时任务里所有写操作(比如更新批次状态、写通知记录)一定要做好幂等控制。任务重复执行时,不能把状态从“已过期”又改回“即将过期”。我习惯在每个任务入口先检查执行标记,或者利用数据库的唯一索引防重。
5.4 权限设计与接口安全
医疗系统的接口安全分两层:登录认证和数据权限。
认证用的是JWT,登录成功后签发Token,后续请求在拦截器里解析Token并放入ThreadLocal。管理端接口要求用户具备对应角色权限,比如“报废批次”这个操作在接口上加@PreAuthorize("hasRole('STOCK_ADMIN')"),只有库房管理员角色能调用。家长端走的是另一套Token体系,只能访问自己关联的儿童数据。
数据权限上有个容易遗漏的点:儿童档案查询接口,如果不加数据隔离,任何一个家长登录后构造一个childId就能查别人家孩子的信息。我的做法是所有查询家长数据的接口,都从Token里取出当前登录用户ID,强制带上“监护人ID = 当前用户ID”这个条件,而不是信任前端传参。这个坑我见过太多次了,很多接口都是拿什么查什么,完全没有意识到ID遍历的风险。
另外,接种登记接口这种敏感操作,我加了双人复核机制:第一操作人录入,第二操作人扫码确认后记录才算完整。这个属于业务流程对系统提出的硬性要求,代码实现也不复杂,就是在接种记录表加一个confirm_operator字段,未确认的记录不能参与统计报表。
6. 常见问题与系统上线后的真实复盘
6.1 典型问题排查速查表
下面这些问题是项目开发和试用阶段最常遇到的,我整理成了速查表,对应的排查思路都是实践过的。
| 问题现象 | 常见原因 | 排查与解决 |
|---|---|---|
| 预约成功但数据库预约单为空 | 扣减号源与插入预约单不在同一事务 | 检查Service方法是否有@Transactional,扣减和插入必须在同一事务 |
| 疫苗明明有库存,接种确认却失败 | 批次状态被冻结或已过期 | 查vaccine_batch的status字段,确认是否被定时任务更新为过期 |
| 定时提醒短信没有发送 | 定时任务未被触发或线程被阻塞 | 看scheduling线程池配置,检查任务上一次执行时间 |
| 家长端能访问其他儿童档案 | 查询时未强制关联当前登录用户 | 检查数据权限,必须从Token取用户ID作为过滤条件 |
| 效期预警报表数据不刷新 | 批次没货后状态未同步 | 扣减库存后立即调用updateStockStatus同步状态 |
| 接口报错但日志没有堆栈 | 全局异常处理器吞掉异常 | 检查@RestControllerAdvice,生产环境要打印完整异常栈 |
6.2 数据统计与报表查询的优化
系统运行一个月后,门诊负责人会开始要各种统计:本月接种了多少人次、按疫苗品类分布怎么样、逾期未接种的儿童有多少、某批次疫苗的接种名单是什么。这些报表接口一不小心就会写成多表join加全表扫描,数据量上去之后接口响应速度直线下降。
我做了三个调整,效果很明显。
第一,报表查询统一走独立的统计SQL,用“先缩小范围再join”的思路。比如统计逾期儿童,先按plan_status和due_date过滤出子集,再和其它表join,而不是先join所有表再where。
第二,给疫苗批次表、接种记录表这些高频维度的字段建了联合索引。索引不是越多越好,我是在慢查询日志里找到响应超过500毫秒的SQL,针对性加索引,不搞无脑全字段索引。
第三,核心统计结果做了短时缓存。比如“今日接种人次”这种变化不那么频繁的数据,我会在Redis里缓存五分钟,超时后重新计算。报表页面的访问压力大,这个优化直接让接口响应从两秒降到两百毫秒以内。
6.3 上线部署与运维经验
系统的部署架构我用了最简单的单体加单机MySQL,因为门诊规模决定并发量有限,没必要一开始就上集群。但做了三件提升可靠性的事:
一是Nginx前置做HTTPS卸载和静态资源缓存。家长端的页面资源走Nginx直接返回,不经过Java应用服务器,减少不必要的请求压力。
二是MySQL开启binlog,并配置了每天凌晨的物理备份。医疗数据是无价的,我还额外做了异地备份,把备份文件同步到另一台存储服务器。没出事故的时候觉得备份多余,真出事故的时候这就是救命稻草。
三是应用日志按天滚动,并加了关键业务的操作审计。操作日志不是简单的logback输出,而是单独落一张表,记录操作人、操作类型、操作对象、变更前后值。有一次门诊内部核对一笔接种记录,就是靠这张审计表还原了完整链路。
7. 从开发到落地的几点体会
这个项目做完,我最大的感受是:医疗管理系统考验的从来不是技术多花哨,而是对业务规则的理解有多深。疫苗管理系统尤其如此,一个批号追踪、一个效期校验、一个并发扣减,每个环节都关系到真实世界的孩子。
如果你准备做类似项目,我建议先在业务调研阶段多花时间。找真的接种医生聊一聊,看看他们每天的工作流是什么样,哪些环节最耗时,哪些环节最怕出错。我最初的设计稿里预约功能占了很大篇幅,但实际一聊才发现,医生最痛恨的是“电脑上点半天发现疫苗批次不对”,所以后来把库存校验放到了极高的优先级。
技术实现上,Spring Boot本身没什么门槛,真正的开发成本都在业务逻辑和边界条件里。建议把核心业务模块的单元测试写全,尤其是效期计算、状态流转、并发扣减这三个点,测试通过能省掉后面几百次手工回归的时间。
还有一点要提醒:医疗系统的权限设计和审计日志不要事后补。先上线再补权限的后果就是,你永远不知道哪个操作已经绕过权限发生了。哪怕MVP阶段也要把这些基础能力做进去,因为医疗数据一旦出问题,没有追溯能力是灾难性的。
这套系统的代码框架和数据库设计我保留得很完整,后续如果有需要,可以再展开讲讲每个模块的接口定义和具体的页面交互方案。希望这篇拆解能帮你少走一些弯路。