疾病预防控制中心婴幼儿疫苗预约与接种信息管理系统开发实战记录在疾控中心实习的第二周我接到了一份《婴幼儿疫苗预约与接种信息管理系统开发任务书》。说实话刚看到标题时我并没有太当回事——信息管理系统无非就是登录、增删改查、报表导出这一套。但真正走进接种门诊看着护士小姐姐一边抱着哭闹的孩子一边手写登记本还要抽空安抚排队家长的焦虑情绪时我才意识到这个系统远不是一个普通的 CRUD 项目。这份任务书把接种门诊日常运转这个庞杂流程压缩进了系统需求里而我接下来两个月的工作就是把它从纸面变成一套能真正上线使用、被一线医护和家长们接受的完整系统。这篇文章我会完整记录这次毕业实习项目从需求调研、技术选型、数据库建模到核心流程实现的全过程并把开发过程中排查过的几个代表性坑点单独拿出来复盘。如果你正在做类似的医疗健康类管理系统、毕业设计或者刚进入实习岗位面对一份不明确的任务书不知道从哪里下手这篇记录应该能给你不少直接可用的经验。1. 任务书拆解与一线需求调研比想象中复杂得多的业务现场1.1 我先花了两天时间把任务书翻译成真正的业务流程任务书原文写得很官方核心就几句实现婴幼儿疫苗预约、接种信息登记、查询统计、基础数据维护系统需面向疾控中心、接种门诊、家长三类用户。这些描述看起来很清楚但接种信息这四个字背后的东西远不是一张表能装下的。我先做了业务侧的梳理。一个婴幼儿从出生到六岁要接种的疫苗包含国家免疫规划疫苗如乙肝、卡介苗、脊灰、百白破、流脑、乙脑、麻腮风等和非免疫规划疫苗如水痘、流感、轮状病毒疫苗等。每一类疫苗还分不同的剂次和接种程序——比如乙肝疫苗要打三针第一针出生24小时内第二针1月龄第三针6月龄。所以预约这个动作必须是某个孩子 某个疫苗剂次 某个接种点 某个时间段的四元组缺一个信息接种现场就会出问题。紧接着我去接种门诊蹲了半天观察到的实际场景是这样的家长带孩子到前台护士先翻查纸质接种证确认今天该打什么然后让家长签字再从冰箱里取疫苗、核验批次、扫码、接种最后在接种证和电子档案上同时记录。如果是第一次来还要建立儿童预防接种档案。整个流程中最耗时的其实是询问—查档—确认这一环而排队最堵的地方则是早上九点半之后的集中时段很多家长不知道什么时间来最合适。这一段现场观察非常关键它直接决定了我后续的功能设计系统预约时就要能自动核对儿童月龄与该剂次疫苗的时间窗是否匹配避免约错接种点需要配置每天的号源时段和最大人数而家长端的一个核心价值是能帮他们记住下一次该打什么、什么时候来。1.2 调研结束后我把需求重新划分为四个角色视图调研完业务我整理了一份需求清单并且按角色拆分家长端在线建档、疫苗预约、我的接种计划、接种记录查询、留观倒计时、接种提醒。接种护士端当日预约列表、取号/叫号、疫苗批次选择与库存扣减、接种确认登记、异常反应记录。门诊管理员端号源时段配置、疫苗库存与批次管理、冷链温度补录、护士排班、数据统计。疾控中心管理员端辖区各门诊的接种数据汇总、应种未种查询、接种率统计报表、疫苗出入库台账。这份清单从一开始就把系统边界划清楚了避免后期需求蔓延。这里有一条很重要的经验实习生的毕业设计/实习项目最怕的不是功能少而是需求边界不清。任务书只给大方向如果自己不去现场调研把细节补出来很容易做出一个看起来什么都能干实际上谁都嫌难用的系统。2. 技术选型与架构设计Spring Boot Vue 这套组合是怎么定下来的2.1 选型对比我为什么放弃了 SSM 和 JSP 单体方案学校课程里教的是 SSMSpring Spring MVC MyBatis加 JSP。但我实习的第一步就是跟带教老师确认现场技术栈。疾控中心目前已有的信息系统大多是 B/S 架构运维团队熟悉 Java 生态前端希望用组件化框架方便后续迭代。综合下来我选定了 Spring Boot 2.7 MyBatis-Plus MySQL 8 Redis 做后端Vue 3 Element Plus Axios 做前端部署用 Nginx 反向代理单体 Jar 包。这个选型我在实习周报里写了三条理由第一Spring Boot 自动配置能大幅减少 XML 配置适合短期开发且社区资料多遇到问题容易查第二前后端分离能让家长端H5/小程序、护士端PC 管理台复用同一套后端 API后期如果还要接大屏展示也很方便第三Redis 不是我为了炫技加的而是预约防超、验证码、短时缓存这些场景确实需要它。2.2 整体架构与项目结构系统整体分三层接入层Nginx 做静态页面托管和 /api 反向代理统一处理跨域。应用层Spring Boot 应用按功能拆包包括 controller、service、mapper、entity、dto、task 等。认证采用 JWT 配合拦截器没有上完整的 Spring Security——因为系统角色只有三种且无复杂权限矩阵我评估后觉得轻量拦截器足够还能少踩一堆过滤器链的配置坑。数据层MySQL 存业务主数据Redis 存验证码、预约临时号源、分布式锁。核心业务数据每夜定时同步到统计库一套只读表避免统计查询把业务表拖慢。前端部分按模块拆视图家长 H5 端通过微信内置浏览器打开和 PC 管理端。两个前端共用一套组件规范但路由和页面完全独立。开发时我用 Vite 做构建环境变量区分 dev / prod 接口地址。这里也踩过选型上的一个教训最开始 MyBatis-Plus 版本用了 3.5.x代码生成器模板在 JDK 8 下有些兼容问题后来固定在 3.5.3 才稳定。所以实习项目里如果时间紧张依赖版本千万别追求最新锁一个团队常用的稳定版本才是最实在的。3. 数据库建模儿童档案、疫苗批次和预约号源的设计细节3.1 核心表不是简简单单一张接种记录表数据库设计是这个系统最见功力的地方也是我花时间最多、返工最多的一块。我最终拆出了 12 张业务表核心的几张分别是child_profile儿童档案表除了姓名、性别、出生日期、监护人手机号外还冗余了户籍区划和接种点 id。冗余是有意的统计接种率时按区划聚合是疾控中心的高频查询如果每次 join 区划表代价太高。vaccine_dict疫苗目录表记录疫苗名称、生产厂家、剂次序号第几针、适用月龄区间、是否免疫规划疫苗。这里有一个容易忽略的点同一种疫苗在不同剂次下的适用月龄区间是不同的所以剂次字段不能简单用一个 int我用的是疫苗通用名 剂次联合唯一索引。vaccine_batch疫苗批次表批次号、到货日期、有效期、库存量、冷链温度记录。疾控行业对批次追溯要求很严格所以批次的进出每一笔都要留痕对应还建了一张 vaccine_batch_log 表。appointment预约表儿童 id、接种点 id、时段 id、疫苗字典 id、状态已预约/已取号/已登记/已接种/已取消/已过期、取号时间、接种时间。这是整个系统中并发压力最高的一张表。vaccination_record接种记录表对应一次实际接种必须记录疫苗批次 id、接种护士 id、接种部位、接种时间、留观结束时间。这表相当于电子接种证的数据底座。建表时我给每个业务表都加了 create_time 和 update_time并统一用逻辑删除位。疾控中心的数据审计要求高物理删除是坚决不能做的。这一点在需求评审时带教老师专门强调过。3.2 预约号源的数据结构我是怎么设计时间段和每日限额的预约号源如果设计成每一天一行的配置表那后台管理员要手点一整年的日期显然不现实。我采用了两级配置site_schedule门诊排期模板表配置某门诊在一周内的哪些天开放接种每个开放日分成若干个时段比如 8:30-9:00、9:00-9:30每半小时一档每档的限号人数。site_open_day开放日实例表定时任务每天凌晨按模板为未来 7 天生成具体的开放日记录和每个时段的剩余号源。这样管理员维护一次周模板就行日期实例由程序自动生成。剩余号源放在 Redis 里做并发扣减数据库里的号源数作为最终一致性校验。这个设计让我在后面处理并发预约时轻松了很多因为一周模板 每日实例拆开了配置与运行两种角色的关注点。关于日期计算我埋了个很深的坑后面单开一节说。3.3 疫苗批次效期和可用批次的计算逻辑接种现场不能直接选疫苗目录然后随便拿一支就打。疫苗有严格的效期管理系统必须支持护士在登记接种时只看到当前库存充足且在有效期内的批次。我的实现逻辑是根据所选疫苗字典 id查找该疫苗的所有未过期批次按有效期排序优先使用近效期批次FEFO 原则first expire first out批次库存扣减前用 Redis Lua 脚本做原子自减并校验剩余量不小于 0扣减成功后写入 batch_log记录接种记录 id实现一支疫苗对应一次接种的追溯链路。这个逻辑写起来不难但校验顺序要当心。我最初是先查库存再扣减结果并发下出现过超卖。后来改成先原子扣减、扣成功后再写业务记录把这个顺序定成了固定模式才彻底解决。4. 核心业务实现预约防超、接种状态机与档案生成的完整链路4.1 预约接口的并发控制Redis 预扣加数据库落地的双重校验预约是整个系统并发压力最集中的环节尤其是每周一早上开放下周号源的时候。我的实现分两步第一步前端调用/appointment/preCheck后端校验儿童月龄是否在预约疫苗剂次的时间窗内并检查今日是否已存在同剂次有效预约避免重复预约。如果都通过返回一个预扣凭证。第二步家长确认预约后端执行/appointment/create。这个接口内先执行 Redis Lua 脚本DECRBY对应时段剩余号源如果扣减后小于 0 就回滚并返回该时段已约满如果扣减成功再用数据库事务插入预约记录并把状态设为已预约。这里有个细节Redis 预扣成功但数据库插入失败怎么办我的做法是引入一个预约流水表 appt_flow_record在预扣前就写入一条预约中的流水携带 Redis 扣减的 key 和值。一旦数据库事务失败后置补偿逻辑会读取流水并调用 Lua 脚本把号源回补回去同时兜底定时任务每五分钟扫描超过两分钟仍处于预约中的流水强制处理。这套方案虽然多写了不少代码但保证了号源既不超卖也不少卖。4.2 接种状态机让一次预约的生命周期清清楚楚一次预约从产生到完成要经历六个状态已预约 → 已取号 → 已登记 → 已接种以及两个旁路状态已取消和已过期。我在代码里用了一个状态机枚举类来管理流转不允许跳变。比如已取号之后就不能再取消必须走完登记和接种这是医疗场景的特色——疫苗一旦从冰箱取出并与某个孩子绑定就必须完成接种或按严格的异常流程处理不能像网购订单一样随意取消。取号逻辑发生在家长到门诊后护士在管理端输入预约号或扫描二维码取号。取号成功会生成一个排队序号格式是门诊编码日期当日序号例如 JD-20240617-013。这个序号同时用于叫号屏展示和后续留观记录关联。接种环节是这个流程的核心。护士选择当前预约系统自动展示该疫苗在有效期内且库存充足的批次列表默认按 FEFO 原则排序。护士锁定一个批次后要输入实际接种的疫苗批号系统校验该批号确实属于当前批次且效期有效然后一次性完成三件事写接种记录、扣减批次库存、把预约状态翻转为已接种。这三步必须在一个事务里任何一步失败都整体回滚。我在开发时特意把这三步拆在三个 service 方法里再由一个门面方法编排而不是在一个方法里顺序写死这样后续接入扫码枪或第三方接口时可以单独复用。4.3 接种档案生成与家长端留观体验接种完成后系统需要同步生成/更新儿童预防接种电子档案。我第一次设计的版本是在接种记录落库后同步调一个生成档案的方法后来发现如果这时候打印服务恰好异常档案就漏了。于是改成把生成逻辑放进 MQ 异步消息里由订阅者消费后写档案表并触发接种证 PDF 生成。虽然为了一个小系统引入消息队列有点重但考虑到档案数据的严肃性和不可丢性这个折中是值得的。如果你不想引入 MQ也可以用本地事务表加定时任务轮询的方式实现最终一致性原理差不多。家长端的留观体验是个加分项。接种完成后护士扫描接种记录上的条码系统开启 30 分钟留观倒计时同时给家长手机号推送一条提示您的孩子已完成 XX 疫苗接种请留观至 HH:mm期间避免剧烈活动。我还在 H5 端做了一个留观打卡入口留观时间结束后自动展示可离开的提示并附上接种后常见反应及处理方式。这个小功能被护士姐姐们评价为整个系统里最贴心的设计因为它确实让留观这个原本靠人工喊话的环节变得有序了。5. 排错实录开发过程中几个最典型的坑这部分是重头戏我把这次实习里踩过、并且花了不少时间才定位到的四个问题完整复盘一遍。这些问题网上不一定搜得到现成答案但对做医疗信息系统的同学很有参考价值。5.1 坑一预约时段跨天日期格式化把时段算到了前一天问题现象很怪每天早上 8 点之前的预约单显示的时间段总比配置的时段早一天。比如配置的是 6 月 18 日 8:30-9:00实际落库却变成 6 月 17 日 8:30-9:00。排查了很久最后发现是我在生成 site_open_day 日期实例时用了LocalDate.now().atStartOfDay()来拼当天的开始时间而在解析前端的时段字符串 08:30 时用了LocalTime.parse两者拼接时用了atDate()本来没问题。真正的问题出在 Docker 容器的时区是 UTCLocalDate.now()拿到的是 UTC 日期在东八区早上 8 点之前UTC 日期还是前一天。这个问题几乎让我怀疑是框架 bug最后发现是环境时区。修复方式是在启动类里加TimeZone.setDefault(TimeZone.getTimeZone(Asia/Shanghai))同时 MySQL 连接串加serverTimezoneAsia/ShanghaiDocker 启动参数加-e TZAsia/Shanghai。做医疗系统日期时间永远要当作一等公民对待统一时区是上线前的必查项。5.2 坑二并发扣库存出现负数超卖疫苗批次这个问题出现在压力测试阶段。我用 JMeter 模拟 50 个并发同时为不同儿童预约同一批次疫苗跑完发现批次库存变成了 -3。原因是我在 Service 层做了三步操作先查库存、判断充足、再执行 update。这种 check-then-act 模式在并发下必然有竞态窗口。修复方案是把扣减操作改成一条带条件的原子 SQLUPDATE vaccine_batch SET stock stock - 1 WHERE batch_id #{batchId} AND stock 0执行后判断影响行数如果为 0 说明库存不足抛出友好异常。Redis 的 Lua 脚本预扣减同理。这个修改虽然只是一行 SQL 的差别但正好说明了为什么生产环境不允许先查后改这一步多余设计。5.3 坑三疫苗效期校验遗漏差点让过期疫苗进入扣减候选这个坑是在写测试用例时偶然发现的。我的批次查询逻辑是expire_date 今天但我没有加上expire_date 接种当天——两者在逻辑上是一回事但我在早于当天的模拟数据里看到了漏洞如果系统日期是 6 月 18 日而某批次有效期刚好是 6 月 17 日查询条件用 今天会把它排除这没问题。但我的批次详情接口里有另一个查询条件只赛选了过期日期不为空没有赛选有效期于是这个已过期批次还是出现在了详情接口的候选列表里。虽然接种接口会再次校验效期但列表里出现过期疫苗这个体验上的误导也很危险。修复是在所有涉及批次候选的查询里统一加一个有效期赛选并把校验逻辑抽成一个公共方法assertBatchValid(batch, date)在列表查询、详情查询、接种确认三处同时调用。这里也提醒自己医疗系统的校验逻辑必须保证前后端、列表与详情、预校验与终校验的一致性最好的办法是把校验规则收敛到一个公共入口。5.4 坑四接种证 PDF 打印错位与二维码模糊最后一个是打印问题。系统需要生成带条码和接种记录的 PDF 接种凭证方便家长在留观结束后带走。我用的是开源的 iText 库本地开发时打印完全正常但部署到服务器后打印出来的二维码锯齿严重、扫码枪扫不出来。排查过程发现两个原因第一PDF 模板里二维码用的是低分辨率位图放大后失真第二服务器端的字体缺失导致中文文本回退到默认字体排版整体偏移。解决方案是二维码改用矢量形式的条形码组件同时在服务器安装思源黑体并配置字体路径。这个坑让我明白了一个通用规律凡是涉及 PDF、打印、图片这类和本机环境强相关的功能必须在目标环境上尽早做验证本地能跑通不算数。6. 如果重新做一遍设计改进与实习复盘项目通过验收、交付任务书要求的全部功能之后我做了一次完整的复盘复盘。有些问题在当时的时间约束下选择先绕过但如果重来一遍我会在以下三处做改进一是引入真正的自动化测试。当时项目的测试主要靠手工点接口和 Postman 集合状态机流转的回归测试全靠人肉点点点效率低且容易漏。如果重新开始我会至少给预约流程和库存扣减这两个核心链路补上 JUnit 集成测试用 H2 内存库模拟场景把核心业务逻辑用测试用例固定下来。二是把操作日志系统化。疾控中心对数据操作有审计要求当时我用拦截器简单记录一下关键接口的调用日志但格式不统一查询不方便。重新做的话我会单独建操作审计表记录操作人、操作类型、业务对象、变更前后值并提供专门的查询页面。三是考虑微服务拆分。当前是单体应用对实习项目级别完全够用。但如果系统真的要支撑一个城市的多个接种门诊高并发预约至少要把预约服务、档案服务、统计服务拆成独立进程用消息队列解耦避免一次 Full GC 影响预约高峰。这也是我给自己列的后续学习方向。最后给正在做类似毕业设计或实习任务的同学几点直接可用的建议拿到任务书先别写代码花至少三天做业务调研找真实的用户聊比读十篇论文管用。需求边界要提前划定并和带教老师确认避免后期加需求成为项目延期主因。医疗信息系统里时间、批次、库存、状态流转这四个关键词比任何花哨功能都要重要设计时优先保证它们的严谨性。如果数据库查询和接口逻辑能保证核心数据一致性演示和评优时就没人会在意你的页面是不是用了最前沿的组件库。这套系统在实习结束时实际部署到了实习所在辖区的两家接种门诊试运行连续跑了四周累计处理预约超过一千人次生成接种记录六百余条库存和档案数据全部吻合。看着护士姐姐终于不用在高峰时段埋头翻纸质登记本家长也能在手机上提前知道孩子这次该打什么针、几点来、去哪打的时候我才真正理解任务书那句提升预防接种服务效率和满意度背后的含义。技术本身不复杂复杂的是把一个真实场景里的每个细节都安放妥当。
