尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Spring Boot 急诊抢救时间轴服务:事件溯源、幂等并发与时区排查
急诊类系统在技术圈一直是个比较特殊的方向业务方给的往往是一句话需求比如“把抢救过程的时间点都记下来”但落到代码里就会牵扯出时间基准、并发打点、状态流转、审计留痕、数据脱敏一堆问题。最近日剧《CROSSROAD 急诊救命的约定》迎来第 09 集大结局剧里那条分秒必争的抢救线看得人挺紧张也让我想把这套“急诊时间轴”的技术实现完整写一遍。本文不聊剧情只借这个场景讲清楚一套急诊抢救时间轴服务该怎么设计、怎么落地。内容会从数据模型讲到 Spring Boot 完整代码、再到并发与时区的坑和排查清单适合有 Java 基础、想做一个真实业务模块练手的同学也适合正在对接医疗或工单类时间轴需求的后端同学。需要先说明一句本文所有代码都是教学示例不构成任何医疗业务规范抢救节点阈值、分诊等级、时限要求请以你所在机构的管理制度为准示例里的数值只用来演示配置方式。1. 急诊时间轴的业务背景与核心概念1.1 为什么“记个时间”这件事并不简单很多人第一次听到“抢救时间轴”会觉得不就是往表里插几条记录吗。真正动手才会发现它和普通的业务日志完全不是一回事。普通日志允许偶发丢失、允许顺序混乱、允许事后补录而抢救时间轴是一条有严格先后关系、且必须可回溯的证据链。比如“入抢救室”一定不能晚于“首次用药”“会诊到达”一定不能早于“会诊呼叫”这些约束一旦被破坏数据就失去了参考价值。更麻烦的是这条链上的每个节点是由不同角色、在不同终端上打点的。分诊台护士打一个点抢救室医生打一个点检验科回传一个点设备网关自动上报一个点。多来源、多终端、可能弱网就必然带来重复提交、乱序到达、时钟不一致这三类问题。所以这个模块的技术重点从来不是“存”而是“存得准、存得可校验、存得能扛住并发”。从架构上看它本质上是一个事件溯源Event Sourcing的精简版不直接维护“当前状态”作为唯一真相而是先把每个事件按发生时间记录下来再由事件序列推导出当前状态和各项耗时指标。这样即使后来发现某个节点打错了也能通过追加一条更正事件来处理而不是把历史数据改得面目全非。1.2 几个容易混淆的概念节点、事件、状态在动手之前先把三个词分清楚后面看代码会顺畅很多。节点Node是业务上预定义的时间点类型比如“到院”“分诊完成”“绿色通道开启”“抢救开始”“首次给药”“转归”。它是一个字典项数量有限用编码表示例如ARRIVE、TRIAGE_DONE、CHANNEL_OPEN。**事件Event**是某个患者在某个节点上真实产生的一条打点记录包含患者标识、节点编码、发生时间、操作人、备注。一个患者在同一节点上正常情况下只应该有一条有效记录这就是后面要做唯一约束的原因。状态Status是根据事件序列推导出来的当前所处阶段例如“待分诊”“抢救中”“已转归”。状态是结果事件是原因两者不能混在一张表里互相覆盖否则一旦出现异常数据你根本说不清是状态错了还是事件错了。把这三者分开之后整个模块的结构就很清晰了一张节点字典、一张事件表、一个由事件推导状态和耗时的服务层。下面的所有代码都围绕这个结构展开。1.3 本文示例系统的边界为了不让示例膨胀成一个大而全的 HIS 系统这里明确一下范围。示例只做三件事接收打点请求并做幂等处理、按患者查询完整时间轴、根据时间轴计算关键环节耗时。不做排班、不做床位管理、不做医嘱、不做与外部检验设备对接这些在真实项目里都属于独立子系统应该是通过消息或接口把事件投递进来而不是由时间轴服务自己去拉。另外要强调一个工程原则时间轴服务只负责记录与计算不负责判断临床决策。比如系统可以算出“到院到用药 18 分钟”但不能在代码里写死“超过 20 分钟就是不合格”这种判断并对外输出结论。阈值判断属于规则引擎或者报表层的职责而且必须可配置、可审计。把这个边界守住后面代码的可维护性会好很多。2. 环境准备与版本说明2.1 运行环境与技术栈版本这块必须说清楚因为 Spring Boot 3.x 和 2.x 在包名上有硬差异javax.*换成了jakarta.*照抄代码很容易编译不过。本文示例基于下面这套组合如果你手上是别的版本按自己的环境调整即可重点是理解配置思路而不是死记版本号。组件本文示例说明JDK17Spring Boot 3.x 最低要求 JDK 17Spring Boot3.2.x若使用 2.7.x注意javax.persistence包名Spring Data JPA随 Boot 版本简化持久层也便于演示事务MySQL8.0.x需要支持DATETIME(3)毫秒精度构建工具Maven 3.8Gradle 也可以依赖坐标自行转换接口调试curl / Postman示例用 curl 演示如果你的项目是 JDK 8 Spring Boot 2.7代码结构完全一样只需要把jakarta.persistence.*改成javax.persistence.*把jakarta.validation.*改成javax.validation.*其余逻辑不动。2.2 项目目录结构先看整体结构后面每个文件都会标出路径方便你对照着建。emergency-timeline ├── pom.xml └── src ├── main │ ├── java/com/example/emergency │ │ ├── EmergencyTimelineApplication.java │ │ ├── controller │ │ │ └── RescueTimelineController.java │ │ ├── service │ │ │ ├── RescueTimelineService.java │ │ │ └── TimelineCalculator.java │ │ ├── repository │ │ │ └── RescueEventRepository.java │ │ ├── entity │ │ │ └── RescueEvent.java │ │ ├── enums │ │ │ ├── NodeCode.java │ │ │ └── EmergencyStatus.java │ │ └── dto │ │ ├── EventCreateRequest.java │ │ └── TimelineResponse.java │ └── resources │ ├── application.yml │ └── schema.sql └── test/java/com/example/emergency └── RescueTimelineServiceTest.java这个分层是比较常规的做法controller只做参数接收和响应封装service承载业务规则和事务边界repository只做数据访问entity与dto严格分开避免把数据库结构直接暴露给前端。刚开始写小项目容易把所有逻辑堆在 Controller 里一旦要做幂等、事务、状态校验就会非常痛苦建议一开始就按这个粒度分好。2.3 数据库与连接配置配置文件里有两个地方必须显式指定否则时间字段很容易出现八小时偏差这个问题在排查阶段极其耗时。# 文件路径src/main/resources/application.yml server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/emergency?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: validate open-in-view: false properties: hibernate: jdbc: time_zone: Asia/Shanghai jackson: time-zone: Asia/Shanghai date-format: yyyy-MM-dd HH:mm:ss serialization: write-dates-as-timestamps: false这里逐项解释一下为什么这么配。serverTimezoneAsia/Shanghai告诉 JDBC 驱动数据库会话的时区避免驱动按 UTC 解释DATETIMEhibernate.jdbc.time_zone让 Hibernate 在绑定参数时使用同一时区两层都配上才不会出现一半字段正常一半字段偏移的诡异现象spring.jackson.time-zone控制序列化输出保证接口返回的时间和数据库里看到的一致。ddl-auto建议在生产环境设为validate而不是update让表结构变更走脚本和评审流程不要让框架自动改生产表。示例里表结构通过schema.sql手动维护这样字段类型、索引、注释都能精确控制。3. 数据模型与状态机拆解3.1 事件表设计唯一约束是幂等的第一道防线事件表是整个模块的核心字段不多但每个都有讲究。-- 文件路径src/main/resources/schema.sql CREATE TABLE IF NOT EXISTS rescue_event ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, patient_id BIGINT NOT NULL COMMENT 患者标识, node_code VARCHAR(32) NOT NULL COMMENT 节点编码如 ARRIVE/TRIAGE_DONE, occurred_at DATETIME(3) NOT NULL COMMENT 节点实际发生时间毫秒精度, recorded_at DATETIME(3) NOT NULL COMMENT 记录写入时间用于审计补录, operator_id VARCHAR(64) NOT NULL COMMENT 操作人工号, source VARCHAR(32) NOT NULL DEFAULT MANUAL COMMENT 数据来源MANUAL/DEVICE/INTERFACE, remark VARCHAR(255) DEFAULT NULL COMMENT 备注, PRIMARY KEY (id), UNIQUE KEY uk_patient_node (patient_id, node_code), KEY idx_patient_time (patient_id, occurred_at) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 抢救时间轴事件表;关键点有三个。第一occurred_at和recorded_at必须分开前者是业务时间可能因为补录而早于写入时间后者是系统时间用于审计追溯“这条数据是什么时候进来的”。很多系统把两者合成一个字段事后完全无法区分是真实打点还是事后补录这在需要复盘的场景里是硬伤。第二uk_patient_node唯一索引把“一个患者同一节点只允许一条记录”这条业务规则下沉到了数据库层。应用层的判重可能因为并发而失效但唯一索引不会这是最可靠的兜底。第三DATETIME(3)的毫秒精度是必要的。抢救场景下两个节点相差几十秒很常见如果只精确到秒计算耗时时会出现 0 秒的诡异结果容易让人误以为是代码算错了。3.2 状态机用枚举定义合法流转状态不能随便改必须有一张明确的流转表。// 文件路径src/main/java/com/example/emergency/enums/EmergencyStatus.java package com.example.emergency.enums; public enum EmergencyStatus { WAITING_TRIAGE(待分诊), TRIAGED(已分诊), IN_RESCUE(抢救中), CLOSED(已结束); private final String desc; EmergencyStatus(String desc) { this.desc desc; } public String getDesc() { return desc; } }节点编码同样用枚举同时把“某节点会触发哪个状态”的映射关系写在枚举里这样以后新增节点只要改枚举不用到处找 if-else。// 文件路径src/main/java/com/example/emergency/enums/NodeCode.java package com.example.emergency.enums; public enum NodeCode { ARRIVE(1, EmergencyStatus.WAITING_TRIAGE), TRIAGE_DONE(2, EmergencyStatus.TRIAGED), CHANNEL_OPEN(3, EmergencyStatus.IN_RESCUE), RESCUE_START(4, EmergencyStatus.IN_RESCUE), FIRST_DRUG(5, EmergencyStatus.IN_RESCUE), CONSULT_CALL(6, EmergencyStatus.IN_RESCUE), CONSULT_ARRIVE(7, EmergencyStatus.IN_RESCUE), OUTCOME(8, EmergencyStatus.CLOSED); private final int order; private final EmergencyStatus targetStatus; NodeCode(int order, EmergencyStatus targetStatus) { this.order order; this.targetStatus targetStatus; } public int getOrder() { return order; } public EmergencyStatus getTargetStatus() { return targetStatus; } }这里的order字段用来做顺序校验targetStatus用来推导状态。把这两个属性放进枚举而不是散落在 Service 里好处是新增节点时改动集中、不易遗漏也便于写单元测试做覆盖。3.3 顺序校验为什么不能只用时间比较一个直觉的做法是既然都有occurred_at那只要保证新节点的发生时间晚于已有节点不就行了。这个思路在演示环境能用在真实场景会翻车。原因是补录。护士可能在抢救结束后统一补录几个节点这时后端收到的事件时间是对的但到达顺序是乱的如果按到达顺序做“必须晚于上一条”的校验合法的补录会被误拒。正确的做法是双轨校验时间维度上校验业务合理性比如首次给药不能早于到院顺序维度上按order校验关键节点的先后。区分“必须严格有序”的关键节点和“允许补录”的普通节点前者用order卡死后者只做时间合法性校验。// 时间合法性校验核心片段 if (request.getOccurredAt().isAfter(LocalDateTime.now().plusMinutes(5))) { throw new IllegalArgumentException(节点时间不能晚于当前时间过多请检查终端时钟); }这行看起来简单实际很有用。终端时钟漂移是真实存在的如果客户端时间快了半小时不打点拒绝的话整条时间轴的计算结果都会失真。允许 5 分钟余量是为了容忍轻微网络延迟和时钟误差。4. 完整实战案例抢救时间轴服务4.1 添加 Maven 依赖先把依赖配好注意不需要引入额外的 JSON 库Spring Boot 已经带好了。?xml version1.0 encodingUTF-8? !-- 文件路径pom.xml -- project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent groupIdcom.example/groupId artifactIdemergency-timeline/artifactId version1.0.0/version properties java.version17/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /projectmysql-connector-j是 MySQL 8 之后的新坐标老项目里常见的mysql-connector-java已经不再推荐迁移时记得改。4.2 实体类与 DTO实体只做映射不加业务逻辑这是保持可维护性的基本原则。// 文件路径src/main/java/com/example/emergency/entity/RescueEvent.java package com.example.emergency.entity; import jakarta.persistence.*; import java.time.LocalDateTime; Entity Table(name rescue_event, uniqueConstraints UniqueConstraint(name uk_patient_node, columnNames {patient_id, node_code})) public class RescueEvent { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name patient_id, nullable false) private Long patientId; Column(name node_code, nullable false, length 32) private String nodeCode; Column(name occurred_at, nullable false, columnDefinition DATETIME(3)) private LocalDateTime occurredAt; Column(name recorded_at, nullable false, columnDefinition DATETIME(3)) private LocalDateTime recordedAt; Column(name operator_id, nullable false, length 64) private String operatorId; Column(name source, nullable false, length 32) private String source; Column(name remark, length 255) private String remark; PrePersist public void prePersist() { if (this.recordedAt null) { this.recordedAt LocalDateTime.now(); } if (this.source null) { this.source MANUAL; } } // getter / setter 省略请按 IDE 生成 public Long getId() { return id; } public void setId(Long id) { this.id id; } public Long getPatientId() { return patientId; } public void setPatientId(Long patientId) { this.patientId patientId; } public String getNodeCode() { return nodeCode; } public void setNodeCode(String nodeCode) { this.nodeCode nodeCode; } public LocalDateTime getOccurredAt() { return occurredAt; } public void setOccurredAt(LocalDateTime occurredAt) { this.occurredAt occurredAt; } public LocalDateTime getRecordedAt() { return recordedAt; } public void setRecordedAt(LocalDateTime recordedAt) { this.recordedAt recordedAt; } public String getOperatorId() { return operatorId; } public void setOperatorId(String operatorId) { this.operatorId operatorId; } public String getSource() { return source; } public void setSource(String source) { this.source source; } public String getRemark() { return remark; } public void setRemark(String remark) { this.remark remark; } }请求 DTO 里要加校验注解把参数校验前移到 Controller 层。// 文件路径src/main/java/com/example/emergency/dto/EventCreateRequest.java package com.example.emergency.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.time.LocalDateTime; public class EventCreateRequest { NotBlank(message 节点编码不能为空) private String nodeCode; NotNull(message 节点发生时间不能为空) private LocalDateTime occurredAt; NotBlank(message 操作人不能为空) private String operatorId; private String remark; private String source; // getter / setter 省略 public String getNodeCode() { return nodeCode; } public void setNodeCode(String nodeCode) { this.nodeCode nodeCode; } public LocalDateTime getOccurredAt() { return occurredAt; } public void setOccurredAt(LocalDateTime occurredAt) { this.occurredAt occurredAt; } public String getOperatorId() { return operatorId; } public void setOperatorId(String operatorId) { this.operatorId operatorId; } public String getRemark() { return remark; } public void setRemark(String remark) { this.remark remark; } public String getSource() { return source; } public void setSource(String source) { this.source source; } }4.3 Repository 层// 文件路径src/main/java/com/example/emergency/repository/RescueEventRepository.java package com.example.emergency.repository; import com.example.emergency.entity.RescueEvent; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; import java.util.Optional; public interface RescueEventRepository extends JpaRepositoryRescueEvent, Long { ListRescueEvent findByPatientIdOrderByOccurredAtAsc(Long patientId); OptionalRescueEvent findByPatientIdAndNodeCode(Long patientId, String nodeCode); }方法名派生查询在这里够用了不需要手写 JPQL。注意排序用的是occurredAt而不是id因为补录场景下自增 id 的顺序和业务时间顺序不一致按 id 排会得到错误的展示结果。4.4 Service 层幂等、校验与事务这是整个模块最有含金量的部分逐段来看。// 文件路径src/main/java/com/example/emergency/service/RescueTimelineService.java package com.example.emergency.service; import com.example.emergency.dto.EventCreateRequest; import com.example.emergency.entity.RescueEvent; import com.example.emergency.enums.NodeCode; import com.example.emergency.repository.RescueEventRepository; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; Service public class RescueTimelineService { private final RescueEventRepository repository; public RescueTimelineService(RescueEventRepository repository) { this.repository repository; } Transactional public RescueEvent record(Long patientId, EventCreateRequest request) { NodeCode node; try { node NodeCode.valueOf(request.getNodeCode()); } catch (IllegalArgumentException e) { throw new IllegalArgumentException(未知的节点编码 request.getNodeCode()); } // 1. 时间合法性校验 LocalDateTime now LocalDateTime.now(); if (request.getOccurredAt().isAfter(now.plusMinutes(5))) { throw new IllegalArgumentException(节点时间晚于当前时间请检查终端时钟); } if (request.getOccurredAt().isBefore(now.minusDays(7))) { throw new IllegalArgumentException(节点时间过于久远请确认是否为补录); } // 2. 关键节点顺序校验 validateOrder(patientId, node, request.getOccurredAt()); RescueEvent event new RescueEvent(); event.setPatientId(patientId); event.setNodeCode(node.name()); event.setOccurredAt(request.getOccurredAt()); event.setRecordedAt(now); event.setOperatorId(request.getOperatorId()); event.setSource(request.getSource() null ? MANUAL : request.getSource()); event.setRemark(request.getRemark()); try { return repository.saveAndFlush(event); } catch (DataIntegrityViolationException e) { // 唯一索引冲突视为重复打点返回已存在的记录保证接口幂等 return repository.findByPatientIdAndNodeCode(patientId, node.name()) .orElseThrow(() - new IllegalStateException(重复打点但未查询到原记录, e)); } } private void validateOrder(Long patientId, NodeCode currentNode, LocalDateTime occurredAt) { ListRescueEvent exists repository.findByPatientIdOrderByOccurredAtAsc(patientId); for (RescueEvent e : exists) { NodeCode existNode NodeCode.valueOf(e.getNodeCode()); // 当前节点排在已有节点之前但时间却更晚说明顺序与时间矛盾 if (existNode.getOrder() currentNode.getOrder() e.getOccurredAt().isBefore(occurredAt)) { throw new IllegalArgumentException( 节点顺序冲突 currentNode.name() 应早于 existNode.name()); } if (NodeCode.ARRIVE.equals(currentNode) e.getOccurredAt().isBefore(occurredAt)) { throw new IllegalArgumentException(已存在更早的时间节点到院时间不合法); } } } Transactional(readOnly true) public ListRescueEvent timeline(Long patientId) { return repository.findByPatientIdOrderByOccurredAtAsc(patientId); } }几个设计点值得展开说。第一saveAndFlush而不是save。save在事务未提交时可能只是把实体放进持久化上下文真正执行 INSERT 要等到 flush唯一约束冲突也就不会在预期位置抛出。用saveAndFlush把异常提前到能捕获的位置才能实现“冲突即返回已有记录”的幂等语义。第二捕获的是DataIntegrityViolationException而不是更宽泛的Exception。Spring 会把底层 SQL 异常翻译成这个统一的运行时异常捕获它既能覆盖唯一索引冲突也不会误吞其他业务异常。不过要注意在同一个事务里捕获数据库异常后继续查询某些数据库会因为事务已被标记为 rollback-only 而报错。生产环境的稳妥做法是把“插入 冲突后查询”拆成两个事务或者先用findByPatientIdAndNodeCode做一次前置查询把唯一索引作为并发兜底。示例为了保持代码简洁放在一个方法里真实项目建议按后者改造。第三顺序校验的复杂度是 O(n)。单患者节点数一般不会超过十几个这个量级完全可接受。如果你们的表里混存了多种业务类型导致单患者记录数很大就需要给patient_id建索引并考虑只加载关键节点不要盲目优化成复杂 SQL。4.5 耗时计算把业务公式从服务里剥出来耗时计算单独放一个类好处是纯函数、易测试、不依赖数据库。// 文件路径src/main/java/com/example/emergency/service/TimelineCalculator.java package com.example.emergency.service; import com.example.emergency.entity.RescueEvent; import org.springframework.stereotype.Component; import java.time.Duration; import java.time.LocalDateTime; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; Component public class TimelineCalculator { /** * 计算相邻节点之间的耗时单位秒 * 返回结构保持时间轴顺序便于前端直接渲染 */ public MapString, Long intervals(ListRescueEvent events) { MapString, Long result new LinkedHashMap(); for (int i 1; i events.size(); i) { RescueEvent prev events.get(i - 1); RescueEvent curr events.get(i); long seconds Duration.between(prev.getOccurredAt(), curr.getOccurredAt()).getSeconds(); result.put(prev.getNodeCode() - curr.getNodeCode(), seconds); } return result; } /** * 计算从指定起点节点到终点节点的总耗时 * 任一节点缺失时返回 null由调用方决定如何展示 */ public Long durationBetween(ListRescueEvent events, String startNode, String endNode) { LocalDateTime start null; LocalDateTime end null; for (RescueEvent e : events) { if (startNode.equals(e.getNodeCode())) { start e.getOccurredAt(); } if (endNode.equals(e.getNodeCode())) { end e.getOccurredAt(); } } if (start null || end null) { return null; } return Duration.between(start, end).getSeconds(); } }这里返回null而不是 0 是个刻意的选择。节点缺失和耗时为 0 在语义上完全不同返回 0 会让报表把“没记录”统计成“零耗时”直接污染数据。前端拿到null可以显示成“待补录”这才是正确的表达。4.6 Controller 层与统一响应// 文件路径src/main/java/com/example/emergency/controller/RescueTimelineController.java package com.example.emergency.controller; import com.example.emergency.dto.EventCreateRequest; import com.example.emergency.entity.RescueEvent; import com.example.emergency.service.RescueTimelineService; import com.example.emergency.service.TimelineCalculator; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; RestController RequestMapping(/api/rescue) public class RescueTimelineController { private final RescueTimelineService timelineService; private final TimelineCalculator calculator; public RescueTimelineController(RescueTimelineService timelineService, TimelineCalculator calculator) { this.timelineService timelineService; this.calculator calculator; } PostMapping(/{patientId}/events) public ResponseEntityRescueEvent record(PathVariable Long patientId, Valid RequestBody EventCreateRequest request) { return ResponseEntity.ok(timelineService.record(patientId, request)); } GetMapping(/{patientId}/timeline) public ResponseEntityListRescueEvent timeline(PathVariable Long patientId) { return ResponseEntity.ok(timelineService.timeline(patientId)); } GetMapping(/{patientId}/intervals) public ResponseEntityMapString, Long intervals(PathVariable Long patientId) { ListRescueEvent events timelineService.timeline(patientId); return ResponseEntity.ok(calculator.intervals(events)); } ExceptionHandler(IllegalArgumentException.class) public ResponseEntityMapString, String handleIllegalArgument(IllegalArgumentException e) { return ResponseEntity.badRequest().body(Map.of(message, e.getMessage())); } }启动类就是标准的 Spring Boot 入口没有额外配置。// 文件路径src/main/java/com/example/emergency/EmergencyTimelineApplication.java package com.example.emergency; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class EmergencyTimelineApplication { public static void main(String[] args) { SpringApplication.run(EmergencyTimelineApplication.class, args); } }4.7 运行与验证先建库建表然后启动应用。# 创建数据库表结构由 schema.sql 执行可手动执行或配置 spring.sql.init mysql -uroot -p -e CREATE DATABASE IF NOT EXISTS emergency DEFAULT CHARSET utf8mb4; mysql -uroot -p emergency src/main/resources/schema.sql # 启动应用 mvn spring-boot:run如果你希望启动时自动执行schema.sql可以在application.yml里加spring.sql.init.mode: always并把spring.jpa.defer-datasource-initialization设为true。生产环境不建议这样做容易在多实例部署时产生并发建表竞争。接下来用 curl 依次打点模拟一条完整的抢救时间轴。# 1. 到院 curl -X POST http://localhost:8080/api/rescue/1001/events \ -H Content-Type: application/json \ -d {nodeCode:ARRIVE,occurredAt:2024-06-01 09:00:00,operatorId:N001,remark:自行步入} # 2. 分诊完成 curl -X POST http://localhost:8080/api/rescue/1001/events \ -H Content-Type: application/json \ -d {nodeCode:TRIAGE_DONE,occurredAt:2024-06-01 09:03:20,operatorId:N001} # 3. 抢救开始 curl -X POST http://localhost:8080/api/rescue/1001/events \ -H Content-Type: application/json \ -d {nodeCode:RESCUE_START,occurredAt:2024-06-01 09:06:00,operatorId:D002} # 4. 首次给药 curl -X POST http://localhost:8080/api/rescue/1001/events \ -H Content-Type: application/json \ -d {nodeCode:FIRST_DRUG,occurredAt:2024-06-01 09:18:00,operatorId:D002,source:MANUAL} # 5. 查询时间轴 curl http://localhost:8080/api/rescue/1001/timeline # 6. 查询相邻节点耗时 curl http://localhost:8080/api/rescue/1001/intervals预期输出大致如下耗时单位为秒。{ ARRIVE-TRIAGE_DONE: 200, TRIAGE_DONE-RESCUE_START: 160, RESCUE_START-FIRST_DRUG: 720 }再验证一下幂等把第 1 条请求原样重发一次接口应该返回 200 和已存在的记录而不是 500。如果你看到的是唯一索引冲突的异常堆栈说明DataIntegrityViolationException没有被正确捕获或者事务边界设置有问题可以对照 4.4 节检查。再验证一下顺序校验故意先打FIRST_DRUG时间是 09:18再尝试打一个发生在 09:10 的RESCUE_START应该收到 400 和“节点顺序冲突”的提示。这个测试很有价值它能帮你确认校验逻辑真的生效了而不是只在纸面上存在。5. 常见问题与排查思路这个模块的坑相对集中我把实际排查中出现频率最高的几类整理成表后面再展开两个最典型的。问题现象常见原因解决思路接口返回的时间比数据库少 8 小时JDBC 时区、Hibernate 时区、Jackson 时区三者不一致三处统一配置为Asia/Shanghai重复提交时接口返回 500未捕获唯一索引异常或事务已标记回滚捕获DataIntegrityViolationException并前置查询时间轴顺序与预期不符排序用id而非occurred_at查询方法改为按occurredAt排序耗时为 0 或负数节点时间用手工字符串解析精度丢失统一用DATETIME(3)和LocalDateTime补录数据被拒绝顺序校验写成了“必须晚于当前最大时间”区分关键节点与可补录节点高并发下出现重复插入依赖应用层判重无唯一索引兜底加唯一索引作为最终防线5.1 时区错乱三处配置必须对齐这是最容易踩的坑因为它的表现很有迷惑性——有时候查出来正常序列化到前端就少八小时有时候写入正常查询又不对。根本原因是时间在“数据库会话 → JDBC 驱动 → Hibernate → Jackson”这条链上经过了多次时区转换任何一环没对齐就会偏。排查顺序建议这样走第一步直接连数据库执行SELECT occurred_at FROM rescue_event WHERE id ?;看数据库里存的值对不对第二步用接口查同一条记录看返回值对不对。如果第一步对、第二步不对问题在序列化如果第一步就不对问题在写入链路。这样两步就能把范围缩到一半比盲猜快得多。5.2 并发重复打点不要迷信应用层判重有人在 Service 里写一个findByPatientIdAndNodeCode判断存在就跳过看起来逻辑没问题但在并发下会失效两个请求同时查都发现不存在然后都去插入最后还是插了两条。这就是典型的检查后使用check-then-act竞态。正确的组合拳是应用层做一次前置查询提升体验数据库唯一索引做最终兜底捕获冲突后返回已有记录保证幂等。只有唯一索引是 100% 可靠的因为约束检查发生在数据库引擎内部不受应用层并发调度影响。这个思路可以迁移到所有“同一业务键只能有一条记录”的场景比如订单去重、设备上报去重。5.3 事务里捕获异常后继续查询报错这个问题在 4.4 节的代码里埋了一个伏笔这里展开说清楚。当 Hibernate 抛出DataIntegrityViolationException后当前事务通常已经被标记为 rollback-only这时候如果在同一个事务里再执行findByPatientIdAndNodeCode可能直接抛出UnexpectedRollbackException或者拿到脏数据。解决方式有两种。第一种是把写入和冲突查询拆到两个独立事务用REQUIRES_NEW传播级别或者干脆在 Controller 层捕获异常后再调用一次查询服务。第二种更简单先查询再插入唯一索引只作为极端并发下的兜底兜底路径返回一个明确的“请重试”提示让客户端重试一次即可。生产环境更推荐第二种实现简单、行为可预期。6. 最佳实践与工程建议6.1 时间基准服务端时间为准这条规则必须写进团队规范所有时间字段的最终权威来源是服务端客户端时间只作为参考值提交必须经过服务端校验。原因是客户端时钟不可控用户手动改系统时间、设备电量耗尽后重置、跨时区设备接入都会导致客户端时间失真。示例代码里那条“晚于当前时间 5 分钟就拒绝”的校验就是这个原则的落地。如果是设备自动上报的场景建议在事件表里同时保留设备时间和服务端接收时间用source字段区分来源事后分析时可以根据两者差值判断设备时钟是否需要校准。这个设计在排查数据质量问题时非常有用。6.2 审计留痕谁改了、什么时候改的医疗或工单类系统的数据往往需要长期留存所以删除操作要格外谨慎。建议的做法是逻辑删除 更正事件而不是DELETE。新增一条CORRECTION类型的事件指向被更正的原事件原数据保持不动。这样任何时候都能还原出“当时记录的是什么”和“什么时候改成了什么”。对应到表结构可以加deleted标记位和corrected_from字段但即便如此也不要在代码里随便提供物理删除接口。涉及历史数据修改的场景一定要走审批流程并且在测试环境验证脚本生产执行前做好备份。6.3 权限与数据安全时间轴数据涉及患者标识和操作人工号属于敏感信息权限控制要做三层。第一层是接口鉴权必须校验调用方身份不允许匿名访问第二层是数据权限操作人只能查询自己所在科室或自己参与的患者数据不能靠前端传patientId随便查第三层是字段脱敏对外输出的患者标识建议用脱敏后的编号不要直接暴露内部主键。日志里同样要注意log.info打印请求体时很容易把患者标识、操作人姓名一起打出来建议自定义toString或者用专门的脱敏工具处理后再输出。这个小细节在做安全评审时经常被点名。6.4 生产环境变更注意涉及表结构变更时遵循三条原则先加字段后写值、先建索引后用查询、变更前必备份。给大表加索引要评估锁表时间MySQL 8 的部分索引操作支持在线执行但仍建议在业务低峰期操作并提前在预发环境验证耗时。配置变更同理ddl-auto不要在生产开启update阈值类配置比如各环节的参考时限要放在配置中心而不是硬编码在代码里这样调整时不需要发版也能留下变更记录。6.5 可维护性让新增节点变成一次枚举修改前面把节点顺序和状态映射都塞进枚举就是为了这个目的。当业务方说“我们要新增一个‘影像检查完成’节点”时理想情况下你只需要在NodeCode里加一行配好order和目标状态其余代码零改动。如果做不到这一点说明业务规则散落得太厉害应该重构。同时建议给时间轴服务写单元测试重点覆盖三个场景重复打点的幂等性、顺序冲突的拒绝、耗时计算的边界值节点缺失、节点乱序。这三个场景覆盖住了后续改动的心智负担会小很多。7. 收尾这套设计能复用到哪些地方把上面的内容压缩成一句话凡是需要按时间顺序记录多个来源事件、并且要能回溯计算耗时的业务都可以套这套“事件表 唯一索引 状态推导 独立计算器”的结构。它不只适用于急诊场景工单系统的处理流程、物流的揽收转运签收、审批流的各个节点、甚至 CI/CD 流水线的各阶段耗时本质都是同一类问题。如果你想继续往下深挖有两个方向可以练手。一是把打点接口改造成异步用消息队列接收设备上报重点练习消息去重和顺序保证二是给时间轴加一个“基线对比”能力把实际耗时和配置的参考值做比较输出偏差报表这会涉及配置中心和多维度统计。这两个方向都不复杂但能把一个演示级模块推到接近生产可用的程度。代码建议你亲手敲一遍尤其注意时区配置和唯一索引那两处这两处是最容易复制粘贴后跑不通的地方。跑通之后可以试试把patient_id换成业务单号把这个模块改造成一个通用的“流程时间轴”组件复用价值会更高。如果本文对你有帮助可以收藏备用遇到并发幂等或者时区问题时回来对照排查清单看一眼通常能省下不少时间。
RELATED

相关推荐

PyCharm配置Python环境全攻略:从解释器到虚拟环境

PyCharm配置Python环境全攻略:从解释器到虚拟环境

简介:这份文档面向刚接触 PyCharm 的 Python 开发者与编程学习者,聚焦于在 IDE 中完成 Python 解释器选择、虚拟环境创建与第三方库管理这一入门关键环节,帮助读者解决环境配置不清、依赖冲突等常见困扰。资源包共 1 个 docx 文件&#xff0c…

📅 2026/10/27 10:44:18
Multisim 14.0安装教程:许可配置、报错排查与仿真自检

Multisim 14.0安装教程:许可配置、报错排查与仿真自检

简介:这份PDF面向需要在本机部署Multisim 14.0的电子电路学习者、实验课学生及自学者,聚焦软件安装与环境初始化中的常见疑问,帮助读者按清晰顺序完成从解压到可用的准备。压缩包内共1个PDF文件,大小约728KB,轻量便携&…

📅 2026/10/27 10:39:12
Kettle9.2实战避坑指南:MySQL→Oracle同步全链路调优

Kettle9.2实战避坑指南:MySQL→Oracle同步全链路调优

简介:本资源是一份面向1–3年经验研发人员的Kettle(Pentaho Data Integration)入门级教学PPT,聚焦ETL核心流程与工具实操,解决数据同步、清洗与集成中的基础建模与落地难题。全包仅含1个859KB的pptx文件,内…

📅 2026/10/27 10:39:12
MORE NEWS

更多资讯

📰

Win10下原生部署MSDE 2000实战指南

简介:本资源是专为Windows 10(64位)系统用户定制的MSDE 2000数据库一键安装解决方案,面向需在现代系统中复用经典轻量级SQL Server引擎的开发人员、测试工程师及遗留系统维护者,有效解决原生MSDE 2000在Win10下因权限控…

📰

自动驾驶超车场景下的变时域MPC路径规划实践

1. 项目背景与核心挑战在自动驾驶技术快速发展的今天,超车场景下的路径规划一直是行业公认的难点。传统PID控制方法在动态环境中的适应性不足,而模型预测控制(MPC)凭借其滚动优化和反馈校正的特性,成为解决这一问题的理…

📰

高效处理大CSV文件的技术与实践

1. 大文件CSV处理的痛点与挑战 作为一名长期与数据打交道的工程师,我深知处理大型CSV文件时的痛苦。当文件大小超过1GB时,常规工具如Excel、文本编辑器甚至一些编程语言的pandas库都会变得异常缓慢,有时甚至直接崩溃。更糟糕的是,…

📰

人大金仓KingbaseES V8R6 Java适配全指南:驱动、连接池与事务避坑

简介:本资源是一套面向Java开发者与国产化数据库迁移工程师的人大金仓(KingbaseES)适配实践方案,聚焦于在信创环境下完成Java应用与国产数据库的连接、建表、数据操作等关键环节的兼容性改造。资源包含4个核心文件:1个…

📰

变时域MPC在自动驾驶超车场景中的优化与应用

1. 项目概述:变时域MPC在超车场景中的创新应用 超车动作作为日常驾驶中的高风险场景,传统控制算法往往采用固定时间窗口的预测方式,难以应对突发状况。我们团队基于变时域模型预测控制(Non-uniform predictive horizon MPC&#x…

📰

插入排序、希尔排序、选择排序底层原理与工程选型

1. 为什么这三种排序必须放在初阶第一课讲? 刚带完一届算法实训班,我让所有学员在开课第一天就手写三段代码:直接插入、希尔、选择。结果近七成的人卡在“为什么希尔排序的增量序列不能随便选”上,还有人把选择排序的“找最小值”…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬