简介一份基于Java与Spring Boot技术栈实现的样本库实验室管理系统LIMS完整源码面向Java后端开发、实验室信息化建设者以及毕业设计人员。系统覆盖样本登记与分类、实验过程记录、用户角色权限、报表统计和外部系统集成等核心模块展示了Spring Boot结合Spring Data JPA/MyBatis、Spring Security、Spring MVC实现数据持久化、RESTful API与安全控制的企业级实践。资源共966个文件压缩包9.83MB其中234个Java源码构成后端业务逻辑54个Vue组件与181个JavaScript脚本共同支撑前端交互CSS及SVG/PNG图标完成界面展示另含SQL初始化脚本、yml/properties配置和Dockerfile便于本地部署与二次开发。目前已有432人学习下载。借助该套源码可深入理解LIMS系统的分层目录结构、数据表设计与权限拦截流程适合用于课程设计、项目实训或快速搭建实验室管理原型。1. 样本库为什么要上 LIMS一个 Spring Boot 项目能解决的三件事生物样本库的管理有一个很现实的痛点样本数量一旦过千样本在哪、状态如何、做过哪些检测就变成三笔糊涂账。冰箱里的冻存管标签会冻脆脱落Excel 里的位置登记会记错行两个项目组同时申请一管样本也时有发生。LIMS实验室信息管理系统把台账线上化让每一管样本从采集、入库、分装到出库报告都有电子轨迹。用 Java Spring Boot 实现 LIMS 是当前最顺手的技术选型Spring 家族的事务、依赖注入、安全框架直接覆盖这个场景的大部分需求。这篇笔记按我做样本库 LIMS 的落地思路展开从数据模型讲到核心接口实现再把并发和追溯几个坑单独拉出来说适合正在设计这类系统或刚接手 LIMS 源码的开发者。2. 用 Spring Boot 搭 LIMS 骨架先把六张核心表和事务边界定下来LIMS 这类系统业务规则多但技术难点分散真正的复杂度全在数据模型上。表关系没定好后面每加一个需求都要动表结构迁移脚本写到怀疑人生。所以动手写 Controller 之前先把核心表结构定下来再决定 Service 层怎么切事务。2.1 六张必须一次建对的表样本、冻存盒、位置、批次、检测、报告样本库 LIMS 的管理对象是样本但真正约束日常操作的是位置。你每天要回答的问题是某一管样本放在哪个冻存盒的哪一格这一格现在有没有被别的样本占着。围绕这个核心我建议第一版就建这六张表表名职责关键字段与其他表的关系sample样本主记录sample_code、sample_type、status、quantity、batch_id、box_cell_id一对一到 box_cellfreezer_box冻存盒/冻存架box_code、box_type、row_count、col_count一对多到 box_cellbox_cell具体存放位置box_id、row_no、col_no、status一对一到 samplebatch入库批次batch_code、source、received_at、operator一对多到 sampletest_result检测结果sample_id、project_name、result_value、unit、test_time多对一到 samplereport报告记录report_no、sample_id、file_path、status一对一到 sample这里最容易忽略的是box_cell.status它既要表示空闲/占用又会在样本出库后变回空闲这时样本和位置的一对一关系不能丢。我的做法是让sample.box_cell_id带上唯一约束一个位置只能被一个样本占用这样即使并发分配也不会出现两个样本共用一个坐标。sample_code必须唯一它是冻存管上的条码内容查询、导出、审计全部依赖这个字段。sample_type建议用字符串枚举而不是数字后面第 4 章会专门讲为什么。2.2 用 Spring Data JPA 落地 Sample 实体字段类型与约束选择JPA 在这个场景最大的好处是让你把表结构和 Java 对象放在一处维护建表脚本由ddl-auto控制。生产环境我会把ddl-auto设为validate表结构用 Flyway 管理但开发期用update快速迭代没问题。Sample 实体的核心写法如下package com.example.lims.sample; import jakarta.persistence.*; import java.math.BigDecimal; import java.time.LocalDateTime; Entity Table(name sample, indexes { Index(name idx_sample_status, columnList status), Index(name idx_sample_batch, columnList batch_id) }) public class Sample { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name sample_code, nullable false, unique true, length 64) private String sampleCode; Column(name sample_type, nullable false, length 32) private String sampleType; Column(name status, nullable false, length 20) Enumerated(EnumType.STRING) private SampleStatus status; Column(name quantity, nullable false, precision 10, scale 2) private BigDecimal quantity; Column(name unit, length 10) private String unit; Column(name batch_id) private Long batchId; Column(name box_cell_id, unique true) private Long boxCellId; Column(name created_by, length 32) private String createdBy; Column(name created_at, nullable false, updatable false) private LocalDateTime createdAt; /** 分配位置后由 Service 层调用不开放 setter 给外部随意改 */ public void occupyCell(Long cellId) { this.boxCellId cellId; this.status SampleStatus.STORED; } PrePersist public void prePersist() { if (createdAt null) { createdAt LocalDateTime.now(); } if (status null) { status SampleStatus.REGISTERED; } } }关键参数说明如下。Enumerated(EnumType.STRING)让数据库存的是状态名而不是数字查询时一眼能看懂数据后期加状态也不用改存量数据代价是字段会略大对 LIMS 这种数据量级别完全可接受。box_cell_id上的unique true是并发防线比任何应用层判断都可靠。quantity用BigDecimal而不是double样本量可能精确到 0.01 毫升浮点误差在财务和实验数据上都不能容忍。PrePersist里做默认值填充省去每个 Service 方法里重复写setCreatedAt的麻烦。2.3 Service 层为什么比 Controller 重要事务与业务规则Spring Boot 的 Controller 通常很薄业务规则集中在 Service。原因有两个一是事务注解Transactional必须落在 Service 方法上Controller 里写业务会让事务边界模糊二是 LIMS 的业务规则经常跨多张表例如样本出库要同时改sample.status、释放box_cell.status并写一条审计日志这些必须在一个事务里完成。package com.example.lims.sample; import com.example.lims.exception.BusinessException; import com.example.lims.box.BoxCellRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; Service public class SampleService { private final SampleRepository sampleRepository; private final BoxCellRepository boxCellRepository; public SampleService(SampleRepository sampleRepository, BoxCellRepository boxCellRepository) { this.sampleRepository sampleRepository; this.boxCellRepository boxCellRepository; } Transactional public Sample registerSample(String sampleCode, String sampleType, BigDecimal quantity) { if (sampleRepository.findBySampleCode(sampleCode).isPresent()) { throw new BusinessException(样本条码已存在: sampleCode); } Sample sample new Sample(); sample.setSampleCode(sampleCode); sample.setSampleType(sampleType); sample.setQuantity(quantity); return sampleRepository.save(sample); } Transactional public void discardSample(Long sampleId) { Sample sample sampleRepository.findById(sampleId) .orElseThrow(() - new BusinessException(样本不存在)); // 只有已入库的样本才能销毁避免跨状态流转 if (sample.getStatus() ! SampleStatus.STORED) { throw new BusinessException(当前状态不允许销毁: sample.getStatus()); } // 释放位置 if (sample.getBoxCellId() ! null) { boxCellRepository.releaseCell(sample.getBoxCellId()); } sample.setStatus(SampleStatus.DISCARDED); sample.setBoxCellId(null); // 审计日志记录 auditLogService.record(SAMPLE_DISCARD, sampleId, 样本销毁); sampleRepository.save(sample); } Transactional(readOnly true) public Sample getSampleByCode(String sampleCode) { return sampleRepository.findBySampleCode(sampleCode) .orElseThrow(() - new BusinessException(样本不存在)); } }这段代码值得注意的点registerSample在保存前自己查了一次重码这只是友好的前置校验真正的唯一性由数据库唯一索引兜底discardSample里先校验状态再释放位置再做状态变更三个操作在一个事务里中途任何一步抛异常整体回滚不会出现位置释放了但样本状态还是已入库这种数据不一致。Transactional(readOnly true)加在只读查询上数据库连接会走只读路由对 MySQL 也能让优化器做一些只读相关的优化。把这些规则收在 Service 层Controller 里就不会出现散落的if (status 2)判断后续加新业务规则时改动点很集中。3. 样本从登记到报告的核心流程批量导入、位置分配与 Word 报告骨架搭好后接下来是 LIMS 里最常被问到的三段流程批量导入样本、冻存盒位置分配、检测报告生成。这三段恰好覆盖了进、存、出的主链路写清楚它们的代码和参数基本就拿到了这套源码的钥匙。3.1 批量导入 Excel 样本清单EasyExcel 读取与分批落库样本库首次上线或批量接收新样本时用户手里通常是一张 Excel几十到几千行不等。用 EasyExcel 读取比 POI 手动解析省事得多它按行映射 DTO内存占用也低。我的做法是读取后均匀切片每批 200 条单独开事务保存避免一个大事务锁住太多行。package com.example.lims.sample; import com.alibaba.excel.EasyExcel; import com.alibaba.excel.annotation.ExcelProperty; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.math.BigDecimal; import java.util.List; Service public class SampleImportService { /** Excel 行与 Java 字段的映射 */ public static class SampleImportRow { ExcelProperty(样本条码) private String sampleCode; ExcelProperty(样本类型) private String sampleType; ExcelProperty(体积) private BigDecimal quantity; ExcelProperty(单位) private String unit; public String getSampleCode() { return sampleCode; } public void setSampleCode(String sampleCode) { this.sampleCode sampleCode; } public String getSampleType() { return sampleType; } public void setSampleType(String sampleType) { this.sampleType sampleType; } public BigDecimal getQuantity() { return quantity; } public void setQuantity(BigDecimal quantity) { this.quantity quantity; } public String getUnit() { return unit; } public void setUnit(String unit) { this.unit unit; } } private final SampleService sampleService; public SampleImportService(SampleService sampleService) { this.sampleService sampleService; } public int importExcel(MultipartFile file) throws IOException { ListSampleImportRow rows EasyExcel.read(file.getInputStream()) .head(SampleImportRow.class) .sheet(0) .doReadSync(); final int BATCH_SIZE 200; for (int i 0; i rows.size(); i BATCH_SIZE) { ListSampleImportRow batch rows.subList(i, Math.min(i BATCH_SIZE, rows.size())); int imported batch.size(); // 每条数据单独 try-catch失败记录但不中断整体导入 for (SampleImportRow row : batch) { try { sampleService.registerSample(row.sampleCode, row.sampleType, row.quantity); } catch (Exception ex) { // 记录到导入错误清单最终返回给前端 importErrors.add(row.sampleCode : ex.getMessage()); } } } return importedCount; } }参数说明doReadSync()同步读取全部行适合几千行的场景如果文件达到几十万行需要用invokeHeadRow监听器做流式读取但样本库 LIMS 一般到不了这个量级。批次大小 200 是我常用的值太大容易触发 MySQL 锁等待和 undo log 膨胀太小又浪费事务开销。每条数据单独 try-catch 是导入功能必须做的防御——一行条码重复不应该让后面 500 行全部失败用户需要的是成功了多少、失败清单是什么。3.2 冻存盒位置分配并发安全才是最关键的位置分配是 LIMS 最容易被并发击穿的地方。两个操作员同时导入样本程序同时查到同一个空闲坐标各自执行更新就会产生脏数据。解决思路是让数据库来仲裁box_cell上建唯一索引查询时用悲观锁把候选行锁住MySQL 8.0 还支持SKIP LOCKED让并发事务直接跳过被锁住的行去选下一格。package com.example.lims.box; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; public interface BoxCellRepository extends JpaRepositoryBoxCell, Long { /** 锁住一个空闲位置并返回SKIP LOCKED 让其他事务跳过被锁行 */ Lock(LockModeType.PESSIMISTIC_WRITE) Query(value SELECT * FROM box_cell WHERE box_id :boxId AND status 0 ORDER BY row_no, col_no LIMIT 1 FOR UPDATE SKIP LOCKED, nativeQuery true) ListBoxCell findFirstIdleCellForUpdate(Param(boxId) Long boxId); /** 分配后立即置为占用 */ Query(UPDATE BoxCell c SET c.status 1 WHERE c.id :cellId AND c.status 0) int occupyCell(Param(cellId) Long cellId); }这段 SQL 里FOR UPDATE SKIP LOCKED是关键没有SKIP LOCKED时一行被锁其他事务的FOR UPDATE会一直等待造成不必要的阻塞加上之后并发分配会各自拿到不同的空闲位置吞吐量明显提升。findFirstIdleCellForUpdate的返回是List而不是Optional因为 JPA 对LIMIT 1的原生查询返回列表更可靠方法内取第一条即可。occupyCell用的是条件更新WHERE c.status 0如果并发下状态已被其他事务改成占用影响行数为 0可以据此判断需要重新分配。注意Lock配合nativeQuery true时部分数据库方言不支持自动拼接FOR UPDATE所以我把FOR UPDATE SKIP LOCKED直接写进了 SQL。这种方式依赖 MySQL 8.0 以上版本如果是 MySQL 5.7备选方案是把box_cell_id的唯一索引作为兜底发生唯一约束冲突时捕获异常重试。JPA 遇到这种并发冲突会抛出DataIntegrityViolationException重试逻辑放在 Service 层最多重试三次。3.3 检测结果录入与报告生成用 POI 输出可存档的 Word 报告检测结果这块相对直接一个样本做多个项目每个项目一条test_result记录通过sample_id关联。报告生成则是把结果汇总成一份正式文档。用 Apache POI 的XWPFDocument操作 Word 模板是成本最低的方案实验室主任审核通过后直接导出 PDF 归档。package com.example.lims.report; import org.apache.poi.xwpf.usermodel.*; import java.io.FileOutputStream; import java.time.LocalDate; import java.util.List; public class ReportGenerator { public void generate(String reportNo, String sampleCode, String sampleType, ListTestResult results, String outputPath) throws Exception { try (XWPFDocument doc new XWPFDocument()) { // 标题段落 XWPFParagraph title doc.createParagraph(); title.setAlignment(ParagraphAlignment.CENTER); XWPFRun titleRun title.createRun(); titleRun.setText(样本检测报告); titleRun.setBold(true); titleRun.setFontSize(18); // 样本信息段落 XWPFParagraph info doc.createParagraph(); info.createRun().setText(报告编号: reportNo); info.createRun().setText( 样本条码: sampleCode); info.createRun().setText( 样本类型: sampleType); info.createRun().setText( 报告日期: LocalDate.now()); // 结果表格 XWPFTable table doc.createTable(1, 4); table.getRow(0).getCell(0).setText(检测项目); table.getRow(0).getCell(1).setText(结果值); table.getRow(0).getCell(2).setText(单位); table.getRow(0).getCell(3).setText(检测时间); for (TestResult r : results) { XWPFTableRow row table.createRow(); row.getCell(0).setText(r.getProjectName()); row.getCell(1).setText(r.getResultValue().toPlainString()); row.getCell(2).setText(r.getUnit()); row.getCell(3).setText(r.getTestTime().toString()); } // 落款 XWPFParagraph sign doc.createParagraph(); sign.createRun().setText(检测人: results.get(0).getOperator()); sign.createRun().setText( 审核人: ____________); try (FileOutputStream out new FileOutputStream(outputPath)) { doc.write(out); } } } }POI 操作 Word 参数上最常踩的是文字和表格的样式设置setFontSize单位是磅值18 对应小初号标题够醒目。表格默认宽度偏窄需要时可以table.setWidth(100%)或者用CTTblWidth设置百分比否则导出到 Windows 上打开会出现表格挤压。落款处留审核人签名位是行业惯例报告没有审核签名不能归档。至于 PDF 转换常见做法是 LibreOffice 命令行无头转换也可以走iText直接生成 PDF但 Word 模板改动更灵活推荐先用 POI 出 Word 再转 PDF。4. 样本库 LIMS 避坑指南并发、追溯、批量导入和状态设计的 5 个真实问题这一章把我在实际开发和排查中遇到的高频问题整理出来每条按现象 → 原因 → 解决来写。这些问题不解决系统在小规模时看着正常样本量一涨就会集中爆发。4.1 现象一两个样本抢到了同一个冻存盒坐标现象是线上数据库出现两条 sample 记录指向同一个box_cell_id冻存盒台账显示该位置被两个样本同时占用后续出库时完全无法判断谁该拿这一管。原因是分配位置的代码没有做并发保护。常见写法是先查空闲位置再 update 状态两个事务同时查到同一个status 0的行各自执行 update 互相覆盖。如果sample.box_cell_id上没建唯一索引这个问题在数据库层就不会报错。解决分两层。第一层是sample.box_cell_id加唯一索引这是最后的保险。第二层是位置查询用FOR UPDATE SKIP LOCKED见第 3.2 节代码。两层都做了之后并发冲突要么拿不到位置要么在唯一索引处抛出异常绝不会静默产生错误数据。4.2 现象二样本误删后追溯链断裂复查无法解释现象是质量复查时需要核对某批样本的完整操作记录发现关键样本的数据已经不在库里检测结果和报告成了孤儿数据。原因是样本记录被物理删除了调用的是sampleRepository.deleteById(...)。JPA 的delete会直接执行DELETE FROM sample而test_result表没有外键级联约束结果留在原地样本主表没了。解决是全面禁止物理删除。sample表加deleted字段默认 0所有查询默认过滤deleted 0删除操作只是把deleted改成 1。同时在SampleService里加审计日志discardSample、updateSample这类方法写入audit_log表记录操作人、操作时间、变更前后内容。这样无论谁做了什么都能从审计日志还原现场。我一般还会在application.yml里配一次spring.jpa.properties.hibernate.jdbc.batch_size把审计日志的批量写入打开否则日志量大时性能会掉得很难看。4.3 现象三5000 条样本批量导入直接超时现象是导入 Excel 时请求执行了 30 多秒还没返回最后报Lock wait timeout exceeded后台日志能看到大量InnoDB锁等待记录。原因是整个导入过程放在一个大事务里5000 条样本逐条insert加上审计日志和位置预分配事务执行时间远超innodb_lock_wait_timeout的默认值 50 秒。更糟的是长事务会阻塞其他正常业务请求比如操作员在界面上做单条登记也会卡住。解决是把导入改成拆批提交。我在 3.1 节代码里用BATCH_SIZE 200切分每个批次调用一次带Transactional的 Service 方法批次之间提交间隔短锁持有时间大幅缩短。同时把每条数据单独 try-catch失败行单独记录不让脏数据影响整个批次。导入功能完成后要在界面上明确提示成功 4800 条、失败 200 条、失败原因见下载文件用户才敢信任这个功能。4.4 现象四状态字段用魔法数字加需求后改动失控现象是系统运行一年后要加已销毁状态发现 Java 代码里散落大量if (status 1)、where status 2数据库里存的是 0 和 1新状态只能接在 2 后面但有的地方用status 1判断已废弃样本新状态把逻辑全打乱了。原因是 DB 字段用了tinyintJava 里直接用数字比较没有统一的状态建模。短期看着省事长期维护成本极高。解决是状态用枚举并在数据库存字符串。Enumerated(EnumType.STRING)映射后数据库值变成了REGISTERED、STORED、DISCARDED这种可读文本查询条件写where status STORED一目了然。Java 侧比较用枚举常量SampleStatus.STORED而不是数字。另外推荐给状态流转加校验规则——比如从已出库不能直接跳到已销毁必须经过在库状态这层校验写在 Service 层的transitionTo方法里让状态流转有据可查。4.5 现象五Controller 返回 JSON 时炸出 LazyInitializationException现象是访问样本列表接口时部分带关联字段的请求报了LazyInitializationException控制台提示could not initialize proxy - no Session偶尔还伴随failed to lazily initialize a collection。原因是实体关联了FetchType.LAZY的字段比如sample.batch或sample.box在 Service 层的Transactional结束后Controller 层组装 JSON 时 JPA 的 Session 已经关闭懒加载无法执行。Spring Boot 2 之后的open-in-view默认开启反而掩盖了这类问题等到生产环境并发一高连接被长期占用问题更隐蔽也更危险。解决是项目中约定 Controller 只返回 DTO不让实体直接进入 JSON 序列化。如果暂时用实体返回就在 Service 查询时用EntityGraph(attributePaths {batch, box})主动抓取关联。第三种方法是Transactional在 Service 方法上一直保持到 JSON 序列化完成但这是把数据库连接绑到了网络 IO 上并发上来必出事我不建议。处理这类问题最好的时机就是在创建实体时就把FetchType.LAZY和 DTO 转换作为默认约定写进团队规范。5. 上线前这样验证Actuator 健康检查、最小链路测试和造数脚本系统开发完到上线之间我会固定做三件事确认健康检查端点可用、用 MockMvc 把主链路接口完整跑一遍、批量造一份模拟数据验证查询性能。这套验证做完心里才有底。5.1 用 Actuator 暴露健康端点management: endpoints: web: exposure: include: health,info,metrics endpoint: health: show-details: always这个配置让/actuator/health返回服务状态和数据库连接状态show-details: always是关键默认never时看不到数据库是否正常。上线后配监控系统定时轮询这个端点服务是否健康一目了然。5.2 MockMvc 跑通一条最小链路SpringBootTest AutoConfigureMockMvc class SampleFlowTest { Autowired MockMvc mockMvc; Test void fullFlow() throws Exception { // 登记样本 mockMvc.perform(post(/api/samples) .contentType(application/json) .content({\sampleCode\:\S-TEST-001\,\sampleType\:\BLOOD\,\quantity\:1.5})) .andExpect(status().isOk()) .andExpect(jsonPath($.sampleCode).value(S-TEST-001)); // 分配位置 mockMvc.perform(post(/api/samples/S-TEST-001/allocate) .param(boxId, 1)) .andExpect(status().isOk()); // 录入检测结果并生成报告 mockMvc.perform(post(/api/samples/S-TEST-001/results) .contentType(application/json) .content({\projectName\:\HBV-DNA\,\resultValue\:12.3,\unit\:\IU/mL\})) .andExpect(status().isOk()); } }这条测试把登记 → 分配 → 录结果走了一遍接口层面验证 Spring 的依赖注入、事务注解和数据库连接都正常。jsonPath断言可以顺带验证字段名有没有改错尤其是把实体改成 DTO 之后字段名不一致会在这里立刻暴露。5.3 造数脚本用循环快速生成测试样本IntStream.range(1, 1001).forEach(i - { String code String.format(S-TEST-%04d, i); sampleService.registerSample(code, BLOOD, new BigDecimal(1.0)); });造数时注意条码格式要符合实际规则否则后续导出的条码贴纸无法扫描。我用过之后发现造数脚本跑完要执行一次ANALYZE TABLE sample让 MySQL 更新统计信息否则查询优化器可能选了错的索引。最后说个习惯我每次拿到新接手的 LIMS 源码第一件事不是跑起来而是先看sample表有没有唯一索引、有没有deleted字段、Service 层有没有Transactional。这三个点能看出这个系统在并发和追溯上有没有认真做过也能快速判断后续要补多少课。样本库系统的核心资产是数据的可追溯性代码能跑只是起点出事能查才是真本事。希望帮到你。本文还有配套的精品资源点击获取