Spring Boot LIS检验系统源码拆解:标本状态机与审核规则
简介:面向Java Web与Spring Boot方向的毕业设计学习者,LIS检验系统项目提供了一套较完整的管理系统实现参考,适合需要完成选题、系统设计、编码实现与测试总结的学生对照学习。压缩包约30.41MB,文件总数与类型明细在上游清单中未提供,包内核心包括程序源代码、数据库及环境配置说明,可按说明完成部署运行。已有97人学习,说明该选题在毕设场景中具有一定参考需求。内容覆盖需求分析、系统功能设计、总体结构设计、数据结构与安全设计,并落到数据库访问、主要功能模块及关键代码实现,最后附功能测试与结果分析,便于读者理解检验业务管理系统的模块划分、开发流程和论文式设计表达,也可用于查漏补缺与二次开发参考。
1. 一份 Spring Boot 的 LIS 检验系统源码,先看它能不能跑通一条标本链路
「LIS 检验系统」在毕设选题里出现频率不低,但真正能演示的不多:很多包打开只有病人增删改查,没有标本状态流转,也没有结果审核。这份基于 Spring Boot 的 LIS 检验系统源码包,价值在于它把检验科最核心的一条链路搭了出来——开申请单、采样贴条码、检验科签收、录入结果、审核发布、打印报告,每个环节都有对应的表结构和状态字段,答辩时你能指着状态机讲清楚业务约束加在哪一层。它适合两类人:毕设要交付一个带业务纵深的后端系统,以及写惯了商品订单、想找一个有真实行业规则的对象拆一拆的后端。下面按数据模型、本地跑通、业务规则、排错避坑、验收演示的顺序走。
2. 拆开 LIS 的数据模型:五张表撑起一条标本状态机
拿到一个陌生源码包,很多人的第一反应是打开 Controller 顺着接口往下读,读三小时还是不知道系统在干嘛。血泪经验是反着来:先看resources下的建表脚本,把表名和状态字段抄在白板上,业务轮廓十分钟就出来了。LIS 这类系统看着功能一大堆,真正撑住业务的往往就五张表,其余都是字典和维护表。
2.1 核心五张表:表关系比接口数量更能说明问题
先看下面这段精简后的建表语句,是从常见 LIS 项目里提炼出来的主干结构。字段名各套源码可能略有差异,但字段语义基本跑不出这个范围。
-- 患者主索引:一次建档,终身复用 CREATE TABLE patient ( id BIGINT PRIMARY KEY AUTO_INCREMENT, patient_no VARCHAR(32) NOT NULL COMMENT '院内患者唯一号', name VARCHAR(64) NOT NULL, gender TINYINT NOT NULL DEFAULT 0 COMMENT '0未知 1男 2女', birth_date DATE NULL, id_card VARCHAR(32) NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_patient_no (patient_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='患者主索引'; -- 标本:条码是它的身份证,status 是它的生命线 CREATE TABLE specimen ( id BIGINT PRIMARY KEY AUTO_INCREMENT, barcode VARCHAR(32) NOT NULL COMMENT '标本条码,全局唯一', patient_id BIGINT NOT NULL, specimen_type VARCHAR(32) NOT NULL COMMENT '血清/全血/尿液等', status TINYINT NOT NULL DEFAULT 0 COMMENT '0已申请 1已采样 2已签收 3检验中 4已出结果 5已审核 6已发布 9已退回', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', collect_time DATETIME NULL, receive_time DATETIME NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_barcode (barcode), KEY idx_patient (patient_id), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='标本主表'; -- 标本明细:一个标本拆成多条项目,结果挂在明细行上 CREATE TABLE specimen_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, specimen_id BIGINT NOT NULL, item_code VARCHAR(32) NOT NULL COMMENT '检验项目编码', item_name VARCHAR(128) NOT NULL COMMENT '项目名称快照', result_value VARCHAR(64) NULL COMMENT '结果值,定性与定量共用', unit VARCHAR(32) NULL COMMENT '单位快照', ref_low DECIMAL(18,4) NULL COMMENT '参考下限快照', ref_high DECIMAL(18,4) NULL COMMENT '参考上限快照', abnormal_flag CHAR(1) NULL COMMENT 'N正常 H偏高 L偏低 C危急', audit_status TINYINT NOT NULL DEFAULT 0 COMMENT '0待录入 1已录入 2已审核', audit_user BIGINT NULL, audit_time DATETIME NULL, KEY idx_specimen (specimen_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='标本检验明细';这三张表加上项目字典表、报告表,就是整套系统的骨架。这里有几个选型细节值得说清楚,因为答辩时评委大概率会追着问。
status用TINYINT而不是VARCHAR,是为了后续做状态区间查询(比如"查所有未审核的标本"直接status < 5),字符串状态在索引上吃亏,而且拼错一个字母就是一个脏数据。version字段是乐观锁,签收、审核这类并发敏感操作靠它兜底,后面第 5 章会展开。
结果值统一用VARCHAR(64)存,不用DECIMAL。原因很实在:检验结果不全是数字,"阴性""阳性""+""++"这类定性结果和数值结果共用一个字段,强行拆两个字段会让上层代码到处判断。真正的数值比较放在应用层做,先把字符串转BigDecimal,转不动就按定性结果处理。
specimen_item里把unit、ref_low、ref_high都存了一份快照,这不是冗余设计,是必须。参考区间会随试剂批次、仪器、人群调整,如果每次出报告都去字典表现查,三个月前的老报告重新打印时就会显示新参考区间,那是要出事的。快照字段在录入结果那一刻锁定,之后字典怎么改都不影响历史数据。
提示:拿到源码包先执行
grep -r "CREATE TABLE"找建表脚本,如果脚本里没有COMMENT,说明作者没打算让别人看懂,后面读代码要更依赖字段名猜语义。
2.2 标本状态机:把散落的 if-else 收进一处
状态字段刚看的时候觉得简单,真写起来最容易失控。常见翻车写法是每个 Service 方法里各写一段if (specimen.getStatus() == 2) { ... },三个月后没人说得清"已退回"能不能直接跳到"已审核"。合格的做法是把状态定义和允许的流转关系收进一个枚举加一张流转表,所有变更只走一个入口。
public enum SpecimenStatus { APPLIED(0, "已申请"), COLLECTED(1, "已采样"), RECEIVED(2, "已签收"), TESTING(3, "检验中"), RESULTED(4, "已出结果"), AUDITED(5, "已审核"), PUBLISHED(6, "已发布"), RETURNED(9, "已退回"); private final int code; private final String label; SpecimenStatus(int code, String label) { this.code = code; this.label = label; } public int getCode() { return code; } public String getLabel() { return label; } private static final Map<SpecimenStatus, Set<SpecimenStatus>> FLOW = new EnumMap<>(SpecimenStatus.class); static { // 只允许正向流转 + 一条退回分支,其它组合一律拒绝 FLOW.put(APPLIED, EnumSet.of(COLLECTED, RETURNED)); FLOW.put(COLLECTED, EnumSet.of(RECEIVED, RETURNED)); FLOW.put(RECEIVED, EnumSet.of(TESTING, RETURNED)); FLOW.put(TESTING, EnumSet.of(RESULTED)); FLOW.put(RESULTED, EnumSet.of(AUDITED, TESTING)); FLOW.put(AUDITED, EnumSet.of(PUBLISHED, RESULTED)); FLOW.put(PUBLISHED, EnumSet.noneOf(SpecimenStatus.class)); FLOW.put(RETURNED, EnumSet.of(COLLECTED)); } /** 判断当前状态能否流转到目标状态 */ public static boolean canTransfer(SpecimenStatus from, SpecimenStatus to) { return FLOW.getOrDefault(from, Collections.emptySet()).contains(to); } }这段代码的逻辑很简单:FLOW是一张邻接表,canTransfer只回答"能不能",不负责改数据。真正落库的地方必须只有一个方法,通常是SpecimenService.transition(barcode, targetStatus, operator),内部按顺序做四件事:查当前状态、比对canTransfer、带version条件更新、写一条流转日志。这里的关键约束是处理器收敛到唯一入口,谁都不许绕过去直接setStatus,否则状态机就退化成了摆设。
参数上有两个点容易调错。第一,RESULTED允许回到TESTING,因为结果录错了要重录,但不允许直接跳回RECEIVED,签收动作的重做必须走退单流程留痕。第二,AUDITED允许回退到RESULTED,也就是"反审核",这个动作在真实业务里权限要求很高,代码里必须单独判权限,不能只判状态。
2.3 组合项展开与快照:下单那一刻就定死
检验科开单很少一项一项点,护士选的是"肝功能八项""血常规"这种组合。字典层通常两张表:test_package(组合)和test_package_item(组合明细),下单时把组合展开成specimen_item的多条记录。展开逻辑看着像纯 CRUD,实际藏着本项目里最容易被忽略的一个设计点——快照。
@Transactional(rollbackFor = Exception.class) public void apply(ApplyRequest req) { // 1. 生成条码:日期 + 6 位序列,业务上要求当天可读、跨天不重复 String barcode = barcodeGenerator.next(req.getSpecimenType()); Specimen sp = new Specimen(); sp.setBarcode(barcode); sp.setPatientId(req.getPatientId()); sp.setStatus(SpecimenStatus.APPLIED.getCode()); specimenMapper.insert(sp); // 2. 组合展开成明细,同时把项目名称/单位/参考区间快照下来 List<TestPackageItem> items = packageItemMapper.selectByPackageId(req.getPackageId()); for (TestPackageItem it : items) { SpecimenItem si = new SpecimenItem(); si.setSpecimenId(sp.getId()); si.setItemCode(it.getItemCode()); si.setItemName(it.getItemName()); // 快照 si.setUnit(it.getUnit()); // 快照 si.setRefLow(it.getRefLow()); // 快照 si.setRefHigh(it.getRefHigh()); // 快照 si.setAuditStatus(0); specimenItemMapper.insert(si); } }逻辑上分两步:先落主表拿到自增 id,再落明细。顺序不能反,明细需要specimen_id外键。参数上,条码生成器要保证线程安全,常见实现是数据库序列表加行锁,或者 Redis 自增加日期前缀,用System.currentTimeMillis()拼接的写法在并发下一定出重,这是新手高频翻车点。
快照这步最容易被偷懒省掉,理由是"字典里都有,查一下不就完了"。问题是字典会变:某项目参考上限从 40 调到 50,历史报告重新打印就变成了按 50 判定,医生看到的是和当初不一样的结论。所以item_name、unit、ref_low、ref_high在开单瞬间抄一份到明细行,字典后续怎么维护都不影响存量单据。这条设计讲给评委听,比讲十个增删改查都管用。
3. 本地跑通这套源码:建库、改配置、三条命令验证链路
数据模型看明白了,下一步是把它跑起来。这一步看起来最没技术含量,实际是劝退率最高的环节,尤其是毕设包,作者本机环境和你的机器往往差好几层。我一般的顺序是:先对齐 JDK 和数据库大版本,再按脚本顺序建库,最后用接口验证而不是看启动日志——"启动成功"和"能用"是两回事。
3.1 环境对齐:三处最容易版本打架的地方
第一处是 JDK。Spring Boot 2.x 用javax.*包名,3.x 换成了jakarta.*,这不是改个依赖版本就能糊过去的,源码里所有import javax.servlet.*、import javax.validation.*在 3.x 下全部编译不过。判断方法很简单:打开pom.xml看<parent>里的版本大号,2 开头就装 JDK 8 或 11,3 开头必须 JDK 17 以上,别硬升。
第二处是 MySQL 驱动。5.7 时代常用com.mysql.jdbc.Driver,8.x 是com.mysql.cj.jdbc.Driver,老驱动连 8.x 服务端会报时区或 SSL 相关的错。驱动坐标也跟着变,mysql:mysql-connector-java在新版里换成了com.mysql:mysql-connector-j,两个坐标混着写会出现"类找不到"。
第三处是字符集。建库时如果用了utf8而不是utf8mb4,患者姓名里的生僻字、检验项目里的特殊符号会变成问号,而且这种脏数据写进去之后改字符集也救不回来,只能重新导。建库语句直接写死:
CREATE DATABASE lis DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;注意:不要图省事用 root 账号连应用。建一个只对
lis库有权限的账号,跑起来之后再想加权限很麻烦,而且很多毕设包在初始化脚本里写了DROP TABLE IF EXISTS,用 root 跑错库就是灾难。
3.2 配置文件与脚本执行顺序
配置文件里真正需要改的就那么几行,其余保持原样,改多了反而引入新问题。典型的application-dev.yml长这样:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/lis?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: lis_app password: 换成你自己的密码 hikari: maximum-pool-size: 10 connection-timeout: 30000 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl连接串里的参数不是凑数的。serverTimezone=Asia/Shanghai不写,MySQL 8 驱动会按 UTC 解释时间,报告时间整体偏 8 小时,这是第 5 章要展开的第一个坑。characterEncoding=utf8mb4保证中文和特殊符号过链路不丢。useSSL=false是本地开发省掉证书握手,生产环境该开还是要开。连接池maximum-pool-size设 10 对毕设够用了,调太大反而在低配机器上起不来。
SQL 脚本的执行顺序有讲究,一般包里会有schema.sql(建表)和data.sql(字典数据),先建表再灌数据,倒过来会因外键约束失败。有的包把两者合并成一个lis.sql,那就一次性执行完。执行完做一次自检:
# 确认表数量和字典数据都进去了 mysql -u lis_app -p lis -e "SHOW TABLES;" | wc -l mysql -u lis_app -p lis -e "SELECT COUNT(*) FROM test_item;"第一条命令看表数量,如果只有三四张,说明脚本只执行了一半,常见原因是中途报错但客户端没停下来。第二条看字典条数,为 0 说明data.sql没跑或者被注释掉了,这时候系统能启动,但开单页面下拉框是空的,看起来像前端 bug,实际是数据没进去。
3.3 启动后用三条命令验证链路
启动日志出现Started ... in x seconds只能说明容器起来了,不能说明链路通。我习惯用三条命令按依赖顺序验:字典、患者、标本。
# 1. 字典是否可读——验证数据库连接和 MyBatis 映射 curl -s http://127.0.0.1:8080/api/test-item/list?page=1&size=5 # 2. 建一个患者——验证写链路和自增主键 curl -s -X POST http://127.0.0.1:8080/api/patient \ -H "Content-Type: application/json" \ -d '{"patientNo":"P0001","name":"测试患者","gender":1,"birthDate":"1990-01-01"}' # 3. 按条码查标本全链路——验证关联查询和状态字段 curl -s http://127.0.0.1:8080/api/specimen/detail?barcode=20240520000001第一条返回空数组[]是正常的(字典可能还没建),返回 500 说明连接或映射有问题,去看控制台第一条异常堆栈。第二条返回体里应该带新生成的id,如果返回 200 但id为空,多半是插入后没回填主键,检查 Mapper 里有没有useGeneratedKeys。第三条是端到端验证,返回体里应能看到status字段和明细列表,缺明细说明组合展开那步没走通。
把这三条跑通,再打开前端页面点一遍,基本就能确认这套源码是"能跑"而不是"能启动"。很多毕设包卡在这一步,作者本机能跑是因为数据库里已经有他调试时留下的数据,你从零建库就会暴露初始化脚本不全的问题。
4. 结果录入与审核:参考区间、异常标记和危急值的代码落点
链路跑通之后,最有含金量的部分是业务规则。LIS 和普通管理系统的分水岭就在这三个地方:结果值怎么判异常、谁能审核谁能反审核、危急值怎么形成闭环。这三件事如果只写在前端,答辩演示时换个接口调用就露馅了,规则必须落在服务端。
4.1 参考区间判定:H/L 标记不能交给前端算
异常标记(偏高 H、偏低 L、正常 N、危急 C)看起来是纯展示逻辑,很多包直接在前端拿参考区间比一下就渲染颜色。这种做法的问题在于,接口返回的原始数据没有标记,导出报告、第三方调阅、统计报表全都要各算一遍,算法一不一致就出现"页面显示偏高、报告打印正常"的诡异现象。
正确做法是在结果保存时由服务端判定并落库。判定本身要考虑三件事:定量与定性的区分、参考区间的方向性(有些项目只有上限,比如某些抗体,低于下限没有临床意义)、以及年龄性别差异。
public String judgeFlag(String rawValue, SpecimenItem item) { if (rawValue == null || rawValue.trim().isEmpty()) { return null; // 未出结果,不判定 } BigDecimal value; try { value = new BigDecimal(rawValue.trim()); } catch (NumberFormatException e) { return "N"; // 定性结果(阴性/阳性)统一按 N 处理,异常靠危急值规则兜 } BigDecimal low = item.getRefLow(); BigDecimal high = item.getRefHigh(); // 只有上限的项目,低于下限不判 L if (low != null && value.compareTo(low) < 0) return "L"; if (high != null && value.compareTo(high) > 0) return "H"; return "N"; }逻辑说明:先尝试把结果解析成数值,解析失败就走定性分支,避免"阴性"这种值把整个录入接口打挂。区间比较用BigDecimal.compareTo而不是equals,后者会比较精度,1.0和1.00会被判不相等,这是个经典坑。
参数上要注意ref_low和ref_high允许为空,空表示该侧不设限。代码里必须先判空再比较,直接value.compareTo(null)会抛空指针。另外,如果项目字典里存的是"性别年龄分段参考区间"(比如儿童和成人血红蛋白区间不同),判定逻辑要按患者年龄查对应区间,这个扩展点多项目里没做,讲的时候可以提一句"扩展方向",比硬说做了更可信。
4.2 审核与反审核:状态校验比权限校验更容易漏
审核这个动作,新人往往只加一个@RequiresPermissions("result:audit")就认为完事了。真正的约束在状态上:只有"已录入未审核"的明细才能被审核,已审核的不能重复审核,已发布的不能直接改。这三条如果不在服务端卡死,两个检验师同时点审核,或者一个人点两次,就会产生重复审核记录,报告上审核人一栏出现两条。
@Transactional(rollbackFor = Exception.class) public void audit(Long itemId, Long auditorId) { SpecimenItem item = specimenItemMapper.selectById(itemId); if (item == null) throw new BizException("明细不存在"); if (item.getAuditStatus() != 1) { // 1 = 已录入待审核 throw new BizException("当前状态不允许审核:" + item.getAuditStatus()); } // 带状态条件更新,靠数据库行锁兜住并发 int rows = specimenItemMapper.auditById(itemId, auditorId, new Date()); if (rows == 0) { throw new BizException("该结果已被他人审核,请刷新后重试"); } // 全部明细审核完毕后,标本主表才能推进状态 if (specimenItemMapper.countUnaudited(item.getSpecimenId()) == 0) { specimenService.transition(item.getSpecimenId(), SpecimenStatus.AUDITED, auditorId); } }这段的关键在第 12 行那条带条件的UPDATE:
UPDATE specimen_item SET audit_status = 2, audit_user = #{auditorId}, audit_time = #{now} WHERE id = #{itemId} AND audit_status = 1把状态判断放进WHERE,靠UPDATE返回的影响行数判断成败,这是并发场景下最省事也最可靠的做法。先查后改的写法(查出来判断再更新)在两次操作之间有空窗,两个人同时进来都能通过检查。
反审核则是另一套规则。反审核通常要求:操作人有更高权限、必须填理由、必须留痕、已发布报告必须先撤回。代码里最少要写三件事——状态从2回到1、写一条audit_log记录原审核人和反审核人、标本主表从5退回4。少任何一件,后面追溯就断链了。
4.3 危急值闭环:判定、提醒、记录三件事缺一不可
危急值是 LIS 里最有行业特色的一块:某项结果超出危及生命的阈值,必须第一时间通知临床。毕设包能做到"弹个红框"就算不错了,但完整闭环至少包含四步——判定、提醒、接收确认、留痕归档。
判定依赖一张配置表,而不是把阈值写死在代码里:
CREATE TABLE critical_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, item_code VARCHAR(32) NOT NULL, low_limit DECIMAL(18,4) NULL COMMENT '危急低值,空表示不设下限', high_limit DECIMAL(18,4) NULL COMMENT '危急高值,空表示不设上限', level TINYINT NOT NULL DEFAULT 1 COMMENT '1一般 2严重', enabled TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_item (item_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='危急值规则';判定顺序要注意:先判危急再判偏高偏低。一个值同时高于参考上限和危急上限时,标H是错的,应该标C,因为临床上处理方式完全不同。很多包把顺序写反了,结果危急值被普通异常覆盖,提醒根本弹不出来。
提醒和确认这一步,最少要有critical_record表记下:标本 id、项目、结果值、触发时间、通知方式、接收人、接收时间。没有接收确认环节,出了事无法证明"系统提醒过",这个闭环就是断的。演示时把这条记录查出来给评委看,比讲一堆技术架构有说服力得多。
5. 排错避坑:这份源码里最容易翻车的五个地方
前面讲的都是顺利路径,真到自己跑的时候,卡住人的往往是些不起眼的小地方。下面五条是我在这类项目上反复踩过的,每条按现象、原因、解决写,照着对一遍能省不少时间。
5.1 时间与字符集:两类一改就好、一错就懵的问题
坑一:报告时间总是差 8 小时。
现象是数据库里存的时间对,页面显示的时间少 8 小时,或者反过来夜里跑批时日期整体偏一天。原因通常不在 Java 这边,而在 JDBC 连接串少了serverTimezone参数。MySQL 8 的驱动默认按服务器时区解释DATETIME,如果容器时区是 UTC 而业务按东八区理解,就会整体偏移。
解决分两步:连接串补serverTimezone=Asia/Shanghai,同时确认数据库服务端SELECT @@global.time_zone, @@session.time_zone;返回的不是SYSTEM这种含糊值。另外,实体类里的时间字段统一用LocalDateTime,别一半Date一半LocalDateTime混用,Date的toString依赖 JVM 默认时区,换台机器结果又变了。
坑二:仪器接口报文解析出乱码。
现象是手工录入的结果正常,通过接口导入的结果里中文项目名变成问号,或者整条报文解析失败。原因是 ASTM 或 HL7 报文常按 GBK 编码发送,而接收端默认按 UTF-8 读字节数组。设备侧不会替你改编码,只能在接收端显式指定。
解决是接收字节流时不要用无参的new String(bytes),明确写new String(bytes, Charset.forName("GBK")),解析完再统一转成内部 UTF-8 存储。同时检查报文分隔符,有些设备用\r\n,有些只用\r,按\n切分会把整条报文当成一行,解析出来全是脏数据。
提示:仪器接口这块,先在接收端把原始字节 dump 成文件再看,别一上来就调解析逻辑。乱码问题九成能在原始文件里看出来。
5.2 并发与事务:单机测试永远发现不了的问题
坑三:两个人同时签收,同一条标本被签了两次。
现象是签收日志里有两条记录,签收时间相差毫秒级。原因是"先查状态再更新"的写法有空窗,两个请求都查到"已采样",都通过了校验,然后都执行了更新。
解决是让数据库来仲裁:UPDATE specimen SET status = 2, receive_time = ?, version = version + 1 WHERE id = ? AND status = 1 AND version = ?,用影响行数判断成败,返回 0 就提示"该标本已被处理,请刷新"。别指望synchronized,多实例部署时它一点用没有,单机加锁也只是把问题藏起来。
坑四:@Transactional加了但异常不回滚。
现象是审核过程中抛了业务异常,结果表更新了但日志表没写进去,数据不一致。原因是异常被catch住之后没有重新抛出,或者调用方和被调用方在同一个类里、事务注解走的是自调用,代理根本没生效。
解决两条:捕获异常后要么throw,要么显式TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();跨方法的事务调用确保是外部调用(注入自身代理或拆到另一个 Service)。另外,@Transactional默认只对RuntimeException回滚,抛Exception时要写rollbackFor = Exception.class。
5.3 权限与数据范围:能登录不等于能看到全部
坑五:换个 id 就能看别人的报告。
现象是 A 科室的账号把 URL 里的报告 id 改成 B 科室的,照样能查出内容。原因是接口只校验了"已登录",没校验"这条数据是不是你的"。这类问题在毕设包里极常见,因为作者本地测试只有一个账号。
解决是在查询条件里强制拼接数据范围,比如按科室、按开单医生过滤,查询单条记录时也要带上范围条件,而不是查出来之后在代码里比对(比对容易被后续改动绕过)。更彻底的做法是在 Mapper 层统一加数据权限拦截,把所有涉及患者数据的查询都过一遍。演示时可以故意换 id 试一次,能拦住说明这块是真做了。
注意:权限校验放在 Controller 只能挡住直接调接口,放在 Service 才能挡住内部调用。这类系统里内部调用比外部调用多得多。
6. 报告导出与验收动作:把"能跑"变成"能演示"
6.1 报告导出:模板引擎和 PDF 库的取舍
报告导出是演示环节的门面,方案基本两条路。第一条是 HTML 转 PDF:用模板引擎渲染出报告页面,再交给 PDF 渲染组件转成文件,优点是样式用 CSS 调、改版快,缺点是中文字体要显式嵌入,服务器上没装字体就是一堆方块。第二条是直接用 PDF 库按坐标画,优点是可控、无外部依赖,缺点是表格一变就要改代码。
我的习惯是第一条,因为检验报告版式固定但项目行数不固定,模板引擎处理循环表格比手写坐标省事。落地时把字体文件打进resources,代码里用ClassPathResource加载,别依赖操作系统字体,否则换台机器就翻车。
// 关键点:字体从 classpath 加载,避免依赖服务器环境 try (InputStream fontStream = new ClassPathResource("fonts/simsun.ttf").getInputStream()) { byte[] fontBytes = fontStream.readAllBytes(); // 交给 PDF 渲染组件注册字体,后续模板里用该字体名 renderer.registerFont("SimSun", fontBytes); }参数上要留意:报告文件建议按"条码 + 版本号"命名并落盘到独立目录,不要直接塞数据库 BLOB。重打报告时版本号加一,历史版本保留,这既是业务要求(报告不能覆盖),也省了后面查问题的麻烦。
6.2 答辩前的六步验收
演示前我会固定走一遍这六步,任何一步过不去都算没做完:
| 步骤 | 动作 | 通过标准 |
|---|---|---|
| 1 | 清库重建 | 空库执行脚本后能启动,无报错 |
| 2 | 新建患者并开单 | 生成条码,明细行数等于组合项数 |
| 3 | 模拟采样签收 | 状态依次流转,日志有记录 |
| 4 | 录入超阈值结果 | 标记为 C,危急记录生成 |
| 5 | 审核并发布 | 报告可查,审核人时间齐全 |
| 6 | 换账号访问他人数据 | 被拒绝,返回明确提示 |
第三步和第六步最容易出问题,前者考验状态机是否收敛,后者考验数据范围校验。这两步过了,整套系统的说服力就立住了。
从那以后我每次拿到这类带业务状态的包,都会先清库重建一遍再演示,本地留的那点测试数据是最会骗人的东西,清一次库什么问题都浮出来了。希望帮到你。
本文还有配套的精品资源,点击获取