
简介面向计算机专业毕业设计的SpringBoot LIS检验系统完整项目包适合需要独立完成一套医疗检验业务管理系统的高校毕业生也可作为Java Web综合实践与课程设计的练手素材。项目围绕检验申请、样本处理、结果录入与报告管理等典型LIS流程展开设计文档覆盖需求分析、总体设计功能结构、数据结构与安全设计以及数据库访问和核心功能模块的实现思路与关键代码并附功能测试与结果分析可直接支撑毕业设计论文的章节撰写与答辩准备。压缩包约30.41MB包含可运行的程序源代码与配套数据库脚本环境配置与运行说明已随包提供按说明即可完成部署。目前已有97人学习下载适合希望拿到完整毕业设计方案、参考模块划分与排错思路的读者使用。1. 从一张贴错条码的化验单说起LIS 检验系统这个词很多做毕设的同学是在选题表上第一次见到的。它不像电商、博客那类项目有一堆现成教程可抄业务链条长开单、采样、签收、上机、录结果、审核、发报告每一步都有状态和角色环环相扣。这个基于 SpringBoot 的 LIS 检验系统压缩包把这条链路做成了能直接跑起来的单体应用——患者建档、检验申请、样本条码、项目维护、结果录入与参考范围判读、报告审核发布、角色权限模块基本齐了。它适合两类人一类要拿毕设交差需要一个业务完整度像样的后端项目另一类是想练手「状态机 事务 报表打印」这类真实业务的后端新手。跑通它不难难的是看懂它为什么在每个环节都要卡一次状态。2. 工程结构与技术选型一个 SpringBoot 单体项目怎么分层2.1 技术栈盘点为什么是 SpringBoot MyBatis MySQL 这套组合毕设类 LIS 项目绝大多数是单体架构这套选型不是偷懒是业务量决定的。一家中小型检验科室的日样本量撑死几千条没有微服务拆分的必要拆了反而增加部署门槛。常见的搭配大概是这样的层次常见选型在这个项目里承担什么框架SpringBoot 2.x自动装配、内嵌 Tomcat、统一配置持久层MyBatis / MyBatis-Plus手写 SQL 处理多表关联和统计数据库MySQL 5.7 / 8.0样本、结果、报告的主存储连接池Druid连接监控毕设演示时能拿来说事权限Shiro 或自定义拦截器医生、检验技师、审核医师的角色隔离模板Thymeleaf 或 Layui 静态页报告打印页、后台管理页分页PageHelper样本列表、结果列表导出Apache POI检验结果导出 Excel这里重点说持久层。LIS 里最典型的查询是「一张报告单带出患者信息 样本信息 十几条结果明细」这种一对多嵌套用 MyBatis 的collection一次映射出来比 JPA 拼实体关系省心得多。常见做法是在TestResultMapper.xml里写一个selectReportDetail用resultMap把三张表的结果一次性组装成ReportVO避免在 Service 里循环查库。再说权限。检验系统的角色边界很硬开单医生只能看自己开的申请检验技师能录结果但不能审核审核医师能审核但不能改结果。用 Shiro 的RequiresRoles注解贴在 Controller 方法上是最省事的做法如果项目里没用 Shiro一般会在拦截器里解析 token 后塞一个UserContextThreadLocal在 Service 层手动判断。两种都能用前者的坑在于注解在同类内部调用时不生效后者的坑在于 ThreadLocal 忘了remove()导致线程池串号。2.2 目录结构逐层拆每个包到底管什么解压之后大概率是标准的 Maven 结构.zip里通常还附带一份sql/lis.sql初始化脚本和一份README。核心的 Java 目录长这样lis/ ├── src/main/java/com/example/lis/ │ ├── LisApplication.java 启动类 │ ├── config/ 配置类WebMvc、MyBatis、Shiro、跨域 │ ├── controller/ 接口层按业务域拆SampleController、ResultController │ ├── service/ 接口定义 │ │ └── impl/ 实现类事务基本都加在这一层 │ ├── mapper/ MyBatis 接口 │ ├── entity/ 与表一一对应的实体 │ ├── dto/ 入参对象比如 ResultSaveDTO │ ├── vo/ 出参对象比如 ReportVO │ ├── common/ Result、ResultCode、BizException、全局异常处理 │ └── util/ 条码生成、日期工具、Excel 导出 ├── src/main/resources/ │ ├── application.yml │ ├── mapper/ SQL 的 XML │ ├── templates/ Thymeleaf 页面报告打印页在这 │ └── static/ js、css、图片 └── sql/lis.sql 建表 初始数据这个分层里最容易出问题的是entity和dto/vo混用。很多毕设为了让代码少一点Controller 直接接收Sample实体结果前端传了个status5就把已审核的样本改回去了,这类越权赋值在演示时老师一眼就能看出来。稳妥的做法是凡是「新增/修改」接口一律用 DTO 接参Service 里手动把允许修改的字段搬到实体上createTime、status、auditorId这类字段永远由后端决定前端传了也不认。2.3 启动配置application.yml 里那几个必须改的键拿到项目第一步不是点运行是先打开application.yml对一遍。下面这份是这类项目里最常见的配置形态我按「哪些必须改、哪些可以留」标注server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver # MySQL 8 必须带 cj5.x 可写 com.mysql.jdbc.Driver url: jdbc:mysql://localhost:3306/lis?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 # 改成你本机的密码 type: com.alibaba.druid.pool.DruidDataSource thymeleaf: cache: false # 开发期关缓存改页面不用重启 jackson: date-format: yyyy-MM-dd HH:mm:ss # 全局日期序列化格式 time-zone: GMT8 mybatis: mapper-locations: classpath:mapper/*.xml # XML 路径写错是最常见的启动失败原因 type-aliases-package: com.example.lis.entity configuration: map-underscore-to-camel-case: true # 不开这个create_time 映射不到 createTime pagehelper: helper-dialect: mysql reasonable: true # 页码越界时返回第一页而不是空逐项解释一下。serverTimezoneAsia/Shanghai必须加否则 MySQL 8 驱动在写DATETIME时会按 UTC 存你本地看着是下午三点数据库里躺着早上七点报告上的采样时间全错。allowPublicKeyRetrievaltrue是 MySQL 8 默认caching_sha2_password认证方式下必须带的参数不带就报Public Key Retrieval is not allowed这个报错信息很误导人看着像权限问题其实是连接参数问题。map-underscore-to-camel-case这个开关特别值得说。不开的话SELECT create_time FROM t_sample查出来的createTime字段永远是 null但控制台又不报错新手会以为是实体类没写 getter一查查半天。我的习惯是新项目一建好就先写一个最小查询验证这个开关跑通了再往下写业务。reasonable: true是 PageHelper 的一个小甜点。默认情况下前端传pageNum0或超出总页数返回的是空列表前端表格就一片空白用户以为系统挂了。开了这个开关越界页码会自动纠正到合法范围。3. 核心业务链路从开单到报告审核的代码实现3.1 样本签收一个方法里塞了三件事签收是整个 LIS 里最值得反复看的一段代码因为它同时涉及状态校验、并发控制和事务边界是面试和答辩时最容易被追问的地方。先看实现Service public class SampleServiceImpl implements SampleService { Resource private SampleMapper sampleMapper; /** * 样本签收 * param sampleId 样本主键 * param operatorId 签收人从登录态取绝不由前端传 */ Override Transactional(rollbackFor Exception.class) public void signIn(Long sampleId, Long operatorId) { Sample sample sampleMapper.selectById(sampleId); if (sample null) { throw new BizException(样本不存在); } // 只有「已采集」状态才允许签收防止重复签收把签收时间覆盖掉 if (!SampleStatus.COLLECTED.getCode().equals(sample.getStatus())) { throw new BizException(当前状态不允许签收 SampleStatus.descOf(sample.getStatus())); } // 关键把「期望状态」作为 update 的 where 条件等价于一次乐观锁 int rows sampleMapper.updateStatus( sampleId, SampleStatus.COLLECTED.getCode(), // 期望的原状态 SampleStatus.SIGNED.getCode(), // 目标状态 operatorId); if (rows 0) { // 走到这里说明在查询和更新之间别人抢先签收了 throw new BizException(样本已被其他人签收请刷新后重试); } } }对应的 Mapper XMLupdate idupdateStatus UPDATE t_sample SET status #{targetStatus}, sign_user_id #{operatorId}, sign_time NOW() WHERE id #{sampleId} AND status #{expectStatus} /update这段代码为什么这么写值得展开讲三点。第一where id ? and status ?才是这段代码的灵魂。如果只写where id ?两个技师同时点签收两条 update 都会成功后一条把前一条的sign_time覆盖业务上不报错但数据已经错了——这在检验科是要被追责的。加了状态条件后第二条 update 影响行数为 0代码能明确感知到冲突并抛给用户。这就是所谓的「用状态字段做乐观锁」不需要额外加 version 列简单直接。第二Transactional(rollbackFor Exception.class)的rollbackFor不能省。Spring 默认只对RuntimeException和Error回滚BizException如果继承的是Exception而不是RuntimeException抛出去事务不回滚前面已经写库的日志、流水全留着。我一般让BizException extends RuntimeException同时把rollbackFor写上双保险。第三operatorId绝对不能从请求参数拿。前端完全可以传一个别人的 id 进来这就是最典型的越权漏洞。正确姿势是在拦截器里解析登录态塞进UserContextService 里UserContext.getUserId()取。答辩时被问到「你怎么防止用户伪造签收人」能答出这一条就够用了。3.2 结果录入与参考范围判读BigDecimal 的坑在这结果录入看起来简单其实藏着这个项目里最细的一段逻辑。检验项目分两类数值型血糖 5.6 mmol/L和文本型乙肝表面抗原 阴性。数值型要和参考范围比对打高低标志还要判断危急值文本型要走字典映射。核心代码如下Override public TestResult saveResult(ResultSaveDTO dto) { TestItem item itemMapper.selectById(dto.getItemId()); if (item null) { throw new BizException(检验项目不存在 dto.getItemId()); } TestResult result new TestResult(); result.setSampleId(dto.getSampleId()); result.setItemId(dto.getItemId()); result.setResultValue(dto.getValue().trim()); // 数值型项目才做范围比对文本型走字典 if (ValueType.NUMBER.getCode().equals(item.getValueType())) { BigDecimal val; try { val new BigDecimal(dto.getValue().trim()); } catch (NumberFormatException e) { throw new BizException(该项目只接受数值结果); } BigDecimal low item.getRefLow(); BigDecimal high item.getRefHigh(); // compareTo 才是比大小equals 比的是精度0.10 和 0.1 不相等 if (low ! null val.compareTo(low) 0) { result.setFlag(L); } else if (high ! null val.compareTo(high) 0) { result.setFlag(H); } else { result.setFlag(N); } // 危急值低于危急下限或高于危急上限必须触发提醒 if (item.getCriticalLow() ! null val.compareTo(item.getCriticalLow()) 0) { result.setCritical(1); } else if (item.getCriticalHigh() ! null val.compareTo(item.getCriticalHigh()) 0) { result.setCritical(1); } } else { result.setFlag(textFlag(item, dto.getValue())); } result.setCreateTime(LocalDateTime.now()); resultMapper.insert(result); return result; }参数和逻辑说明参数 / 字段含义注意事项dto.itemId检验项目主键必须校验存在性防止脏数据dto.getValue结果原文先trim()很多粘贴带空格item.valueType1数值 2文本决定走哪条判读分支refLow / refHigh参考范围上下限为 null 表示该侧不设限criticalLow / criticalHigh危急值阈值命中后置critical1result.flagH/L/N报告上打印箭头用三个容易被忽略的点。第一BigDecimal比大小一定用compareTo用equals会把0.10和0.1判成不等这个坑我在真实项目里踩过结果是一个本该正常的结果被打上了高标志。第二参考范围本身可能是分段的——成年男性、成年女性、儿童各一套严谨的做法是t_item_ref_range单独一张表按性别和年龄段查毕设版本通常简化为一条范围写在t_item上我在改造章节会说怎么补。第三文本型结果的判读不能硬编码阴性/阳性/弱阳性应该来自字典表否则换个项目比如大小便常规就得改代码。3.3 报告审核的状态流转与并发控制审核是整条链路的收口也是最容易出事故的地方。一份报告审核通过就代表结果对外发布了改一次就要留痕。所以审核方法里至少要挡住三件事状态不对不能审、有未处理的危急值不能审、并发重复审核要拦住。Override Transactional(rollbackFor Exception.class) public void audit(Long reportId, Long auditorId) { Report report reportMapper.selectById(reportId); if (report null) { throw new BizException(报告不存在); } if (!ReportStatus.WAIT_AUDIT.getCode().equals(report.getStatus())) { throw new BizException(报告不在待审核状态当前 ReportStatus.descOf(report.getStatus())); } // 危急值未处理说明还没通知临床不允许发布 int unhandled resultMapper.countCriticalUnhandled(report.getSampleId()); if (unhandled 0) { throw new BizException(存在 unhandled 条未处理的危急值不能审核); } // version 乐观锁where id ? and version ? int rows reportMapper.auditWithVersion(reportId, auditorId, report.getVersion()); if (rows 0) { throw new BizException(该报告已被审核请刷新列表); } }update idauditWithVersion UPDATE t_report SET status 2, auditor_id #{auditorId}, audit_time NOW(), version version 1 WHERE id #{reportId} AND version #{version} AND status 1 /update这里的version和样本签收里的status是同一个思路的两种实现。样本表因为本来就有状态字段顺手拿来当条件就行报告表如果状态变化比较简单加一列version更通用。注意 update 语句里version version 1和where version #{version}必须成对出现漏掉任何一个乐观锁就退化成摆设。再补一句关于「审核不通过」的设计。很多毕设版本只有通过与不通过两个按钮不通过就直接改状态回退。真实场景里退回必须带原因t_report上要留reject_reason和reject_user_id否则技师收到一份被打回的报告却不知道为什么只能一条条翻。这个字段在改造章节可以顺手加上。4. 数据模型与外围能力表设计、条码、仪器对接4.1 三张主表的字段与索引设计LIS 的数据模型不复杂但字段选型和索引设计直接决定了后面的查询顺不顺手。核心是样本、申请、结果这三张表围绕它们再挂患者、项目、报告。表名作用关键字段建议索引t_patient患者主档姓名、性别、出生日期、门诊号uk_outpatient_not_test_order检验申请单申请号、患者 ID、开单医生、开单时间idx_patient、idx_create_timet_sample样本条码号、样本类型、采集/签收时间、状态uk_barcode、idx_status_timet_test_item检验项目字典项目编码、名称、单位、参考范围、危急值uk_item_codet_result结果明细样本 ID、项目 ID、结果值、标志、危急标记idx_sample、idx_sample_itemt_report报告样本 ID、状态、审核人、审核时间、versionidx_sample、idx_status建表语句里最需要讲究的是样本表CREATE TABLE t_sample ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, barcode VARCHAR(32) NOT NULL COMMENT 样本条码全局唯一, patient_id BIGINT NOT NULL COMMENT 患者ID, order_id BIGINT NOT NULL COMMENT 申请单ID, sample_type TINYINT NOT NULL COMMENT 1血清 2全血 3尿液 4粪便, collect_time DATETIME DEFAULT NULL COMMENT 采集时间, sign_time DATETIME DEFAULT NULL COMMENT 签收时间, sign_user_id BIGINT DEFAULT NULL COMMENT 签收人, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待采集 1已采集 2已签收 3检验中 4已出结果 5已审核, version INT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_barcode (barcode), KEY idx_patient (patient_id), KEY idx_status_time (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT样本表;uk_barcode这个唯一索引是硬性要求。条码是样本的物理身份重复了就意味着两份样本共用一个身份结果串号这在检验科属于严重差错。数据库层面加唯一约束比在 Service 里select count再 insert 可靠得多——后者在并发下照样能插重。idx_status_time这个联合索引对应的是「查今天所有待签收的样本」这类最高频的列表查询where status 1 and create_time ?能直接走索引。索引列顺序不能反(create_time, status)的话范围条件在前status 就用不上了。字段类型上有个小选择状态用TINYINT还是VARCHAR。我倾向TINYINT配枚举存储省、比较快但可读性差直接看库不知道 3 是什么。折中方案是建一张t_dict字典表前端渲染时翻译后端枚举里维护一份descOf方法给日志和异常信息用。4.2 条码生成与报告打印Code128 和模板渲染样本条码一般用 Code128因为它能编码数字加字母而且密度高热敏标签机上打出来扫得动。用 ZXing 生成public class BarcodeUtil { /** * 生成 Code128 条码图片 * param content 条码内容一般是 年月日 6 位流水如 20240512000031 * param width 图片宽像素标签纸一般 300 左右够扫 * param height 图片高太小扫不出来建议不低于 60 */ public static void writeCode128(String content, int width, int height, OutputStream out) throws Exception { MapEncodeHintType, Object hints new HashMap(); hints.put(EncodeHintType.MARGIN, 1); // 白边留 1 即可太大浪费纸 hints.put(EncodeHintType.CHARACTER_SET, UTF-8); BitMatrix matrix new MultiFormatWriter() .encode(content, BarcodeFormat.CODE_128, width, height, hints); MatrixToImageWriter.writeToStream(matrix, PNG, out); } }条码内容的生成规则建议是「日期 定长流水」比如20240512000031长度固定 14 位。为什么不用 UUID因为护士站手工补录的时候要念出来UUID 念不了。流水号的生成用 Redis 的INCR加日期前缀没有 Redis 就用数据库一张t_seq表加行锁毕设环境用后者就够了。报告打印页放在resources/templates/report/下用 Thymeleaf 渲染div classreport-header h2检验报告单/h2 span th:text样本号 ${report.barcode}/span /div table tr th:eachr : ${report.items} td th:text${r.itemName}/td td th:text${r.resultValue}/td td th:text${r.unit}/td td th:text${r.refRange}/td td th:text${r.flag}/td /tr /table打印页有两个细节要注意。一是 CSS 里必须写media print把导航栏、按钮这些元素display: none掉否则打印出来带一堆操作按钮。二是表格的page-break-inside: avoid一个项目的名称和结果不能被分页切成两半这个在结果条目多的时候特别明显。4.3 仪器对接ASTM 协议解析的模拟实现LIS 最唬人的一块是仪器对接实际上毕设项目里基本都是模拟。真实场景里生化仪通过 RS232 串口或 TCP 把结果推过来协议多为 ASTM E1394 或 HL7 v2一条结果记录长这样R|1|^^^GLU|5.6|mmol/L|3.9-6.1|N||F|||20240512103000字段用|分隔第 3 段是项目编码带^^^前缀第 4 段是结果值第 5 段单位第 6 段参考范围第 7 段异常标志。解析代码public TestResult parseAstmLine(String line) { String[] f line.split(\\|, -1); // -1 保留末尾空串否则下标会错位 if (!R.equals(f[0])) { return null; // 只处理结果记录H/P/O 等头尾记录跳过 } TestResult r new TestResult(); // ^^^GLU 这种格式里项目编码在最后一段 String itemCode f[2].replace(^, ).trim(); r.setItemCode(itemCode); r.setResultValue(f[3].trim()); r.setUnit(f[4].trim()); r.setFlag(mapAstmFlag(f[6].trim())); r.setInstrumentTime(LocalDateTime.parse(f[11].trim(), DateTimeFormatter.ofPattern(yyyyMMddHHmmss))); return r; }用split(\\|, -1)而不是split(\\|)是因为 Java 默认会丢弃末尾的空字符串导致字段下标错位——ASTM 记录末尾经常是空的这个坑很隐蔽解析出来的instrumentTime会莫名其妙变成上一行的值。另外项目编码要做一次「仪器编码 → 系统项目编码」的映射不同厂家的仪器对同一个血糖项目可能叫GLU、GLU-HK、BG硬编码对不上映射关系放t_instrument_item_map表里。没有真实仪器的环境用 Socket 起一个模拟服务端就够了ServerSocket监听 9000 端口收到客户端连接后按行发上面那种字符串LIS 端用BufferedReader.readLine()读读到L|1|N表示传输结束。这套模拟链路答辩时演示效果不错而且不用碰串口。5. 避坑与排查跑不起来时先看这几处5.1 环境与启动类问题现象启动报Public Key Retrieval is not allowed。原因MySQL 8 默认认证插件是caching_sha2_passwordJDBC 驱动在非 SSL 连接下拿不到公钥。 解决在 url 后面加allowPublicKeyRetrievaltrueuseSSLfalse。如果还不行把账号认证方式改回mysql_native_password但生产环境不建议这么干。现象控制台一切正常访问接口 404。原因启动类的SpringBootApplication在com.example.lis包下而 Controller 写在了com.example.controller包扫描扫不到。 解决要么把启动类上移到公共父包要么显式写ComponentScan(basePackages {com.example.lis, com.example.controller})。这个坑在把别人的模块拷进来时最常见。现象页面能打开但样式全丢F12 看 js/css 全 404。原因自定义拦截器里写了registry.addInterceptor(loginInterceptor).addPathPatterns(/**)把static和templates目录也拦了未登录状态直接被重定向到登录页。 解决excludePathPatterns(/static/**, /css/**, /js/**, /login, /error)一并排除。顺便说一句/error不排除的话真实错误会被二次跳转掩盖掉排查时看到的永远是登录页。5.2 数据与事务类问题现象接口返回的实体里所有下划线字段都是 null比如createTime、signTime。原因map-underscore-to-camel-case没开或者 Mapper XML 里手写了resultMap但没写全字段映射。 解决先确认 yml 里mybatis.configuration.map-underscore-to-camel-case: true如果是 XML 里用了自定义resultMap那这个全局开关对它不生效得逐字段写result columncreate_time propertycreateTime/。现象Service 方法里抛了异常数据库却已经写进去了。原因三种可能。一是异常被try-catch吞了没重新抛二是方法不是publicSpring AOP 代理不到三是同类内部方法直接this.xxx()调用走的是原始对象而不是代理对象事务注解失效。 解决第一条改成catch后包一层throw new BizException(...)第二条把方法改成 public第三条把内部方法抽到另一个 Service或者用AopContext.currentProxy()。我一般直接在代码评审时把这三种写法列为红线。现象时间显示比实际早 8 小时。原因JDBC url 没带serverTimezone驱动默认按 UTC 处理。 解决url 加serverTimezoneAsia/Shanghai同时spring.jackson.time-zone: GMT8两头对齐。5.3 前后端交互类问题现象前端拿到的 id 和数据库里的对不上末尾几位变成 0。原因主键用了雪花算法生成 19 位 LongJavaScript 的Number精度只有 53 位超过就丢精度。 解决后端序列化时把 Long 转成 String用 Jackson 的JsonSerialize(using ToStringSerializer.class)打在字段上或者在全局配置里注册SimpleModule统一处理。这个坑在前后端分离版本里几乎必踩。现象新增检验项目时前端不传status后端插入后状态是 null列表查不出来。原因实体字段没给默认值数据库列虽然有DEFAULT 0但 MyBatis 的 insert 把 null 显式写进去了默认值不生效。 解决在 Service 里item.setStatus(ItemStatus.ENABLED.getCode())显式赋值别指望数据库默认值兜底因为insert into ... values (null)和「不写这一列」是两回事。现象打印页中文乱码条码图糊成一片。原因response.setContentType(image/png)没设或者response输出流和 Thymeleaf 渲染的 writer 混用了。 解决图片接口单独一个GetMapping直接写HttpServletResponse的OutputStream不要和页面渲染共用一个方法HTML 页面在head里加meta charsetUTF-8Thymeleaf 的spring.thymeleaf.encoding也确认一下。6. 二次开发加一个项目分类和可配置报告模板拿到这套代码之后最值得动手改的两处一个是把检验项目按分类管起来另一个是把写死的报告模板做成可配置的。前者是数据结构的扩展后者是展示层的解耦正好覆盖二次开发的两类典型场景。6.1 检验项目分类从表到页面的四步改造原来的t_test_item是一条条平铺的血糖、肝功能的项目混在一起维护页面一拉就是几百行。加分类的改造路径很清晰第一步建表和加外键字段。CREATE TABLE t_item_category ( id BIGINT NOT NULL AUTO_INCREMENT, parent_id BIGINT NOT NULL DEFAULT 0 COMMENT 父分类0为顶级, name VARCHAR(64) NOT NULL COMMENT 分类名如生化、免疫、临检, sort INT NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_parent (parent_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; ALTER TABLE t_test_item ADD COLUMN category_id BIGINT NOT NULL DEFAULT 0 AFTER item_code;第二步实体和 Mapper 照葫芦画瓢ItemCategoryMapper里除了常规 CRUD额外加一个递归查询子分类的方法MySQL 8 可以用 CTE5.7 就在 Service 里递归。第三步删除分类时做一次校验。这是最容易被跳过、但必须写的一步Override public void deleteCategory(Long categoryId) { // 有子分类不能删 int childCount categoryMapper.countByParentId(categoryId); if (childCount 0) { throw new BizException(该分类下还有 childCount 个子分类请先处理); } // 有项目挂在这个分类下不能删否则项目变成孤儿 int itemCount itemMapper.countByCategoryId(categoryId); if (itemCount 0) { throw new BizException(该分类下还有 itemCount 个检验项目不能删除); } categoryMapper.deleteById(categoryId); }第四步项目列表页的查询改成带分类条件的联表查前端加一个左侧树。改造完成后argas 页面从「一拉到底」变成「左边点分类、右边出项目」演示效果差别很大。6.2 报告模板可配置化把写死的 HTML 挪出去原来的报告页是templates/report/detail.html一个文件写死所有字段想换个医院抬头就得改代码重新打包。改造思路是把「模板」变成一个可管理的实体改造点涉及文件工作量风险新建模板表t_report_templatesql、entity、mapper0.5 天低模板内容存 HTML用 ThymeleafStringTemplateResolver渲染config、service1 天中注意 XSS报告生成时按template_code选模板ReportService0.5 天低模板编辑器页面前端12 天中可先用纯文本域代替核心渲染代码大致长这样把数据库里存的 HTML 字符串交给 Thymeleaf 的StringTemplateResolverConfiguration public class TemplateConfig { Bean public SpringTemplateEngine stringTemplateEngine() { SpringTemplateEngine engine new SpringTemplateEngine(); StringTemplateResolver resolver new StringTemplateResolver(); resolver.setTemplateMode(TemplateMode.HTML); resolver.setCacheable(false); // 模板改了要即时生效 engine.setTemplateResolver(resolver); return engine; } }渲染时把ReportVO作为上下文变量塞进去engine.process(htmlContent, context)就能出结果。这里有一个必须提醒的点模板内容来自数据库如果后台允许任意用户编辑就等于开放了 XSS 和模板注入的入口。稳妥做法是只允许管理员角色编辑并且在保存前过滤script标签或者干脆只开放有限的占位符替换${patientName}、${items}而不是整段 HTML。再往下走一步可以把t_report_template和科室关联起来生化室用 A 模板、免疫室用 B 模板报告生成时按样本所属科室自动选。这套结构搭好之后再加一个「报告样式预览」功能就很容易了前端把模板内容和一份模拟数据拼起来渲染所见即所得。改造完这两块这套 LIS 的「可维护性」会上一个台阶答辩时讲起来也不再是「我照着一个模板做了个增删改查」而是「我把它从一个演示项目往能用的方向推了一步」。最后说个习惯。我每次拿到这种别人写好的毕设项目第一件事不是急着跑而是先把application.yml、pom.xml和建表脚本这三样东西通读一遍把版本号、端口、库名、初始账号记在一张便签上然后再启动。这个动作看着慢实际上省掉了后面反复「这是哪个库」「为什么连不上」的血泪经验。希望帮到你。本文还有配套的精品资源点击获取