简介一份基于Java和Spring Boot构建的样本库实验室管理系统LIMS完整项目源码覆盖样本登记、分类编码、存储定位、实验记录、权限管理及报表统计等典型业务模块适用于医疗检验、生物样本库及科研机构等需要信息化管理的场景。资源包共966个文件压缩后仅9.83MB代码组织兼顾前后端与部署234个Java文件承担后端逻辑54个Vue组件配合181个JavaScript文件实现交互界面并配套CSS样式、HTML静态页、SQL库表脚本、XML/Properties/YAML多格式配置以及Dockerfile与监控依赖等基础设施配置目录条理清晰方便按层阅读和二次开发。该资源已有432人参与学习适合具备Spring Boot基础、想借鉴完整企业级项目经验的初中级开发者。源码基于Spring Security实现认证授权采用Spring Data JPA完成持久化映射集成Actuator进行运行监控同时内置前端构建配置如Babel、ESLint和常用图标字体资源可直接导入IDE运行也能作为扩展实验室自动化流程的起点。1. 样本库 LIMS 到底该管什么别让一本 Excel 台账拖垮整个实验室一个靠 Excel 台账管样本的实验室最容易出现三类事故样本编号重复、出入库记录对不上、原始报告找不回。LIMS实验室信息管理系统在样本库场景里就是把样本从登记、入库、检验、报告到归档的这一整条链路由黑匣子变成可查询、可追溯、可审计的闭环。基于 Java Spring Boot 实现的样本库实验室管理系统 LIMS 源码通常是一套可二次开发的单体应用业务集中在样本状态流转、库存位置、检验任务和报告管理。适合正在做信息化选型或者已经拿到一套 LIMS 源码准备改造的从业者。这里不空谈“万能平台”先把一条样本的生命周期讲清楚你会知道这套系统到底解决什么。2. 从样本登记到报告归档先跑通闭合流程再谈其他功能不管是第三方检测机构还是生物样本库样本的流转路径都是先登记、再入库、然后检验、复核、出报告最后归档或销毁。拿到 LIMS 源码后第一件事不是改菜单而是把这条链路对应的状态定义和接口找出来。否则后面加任何功能都可能因为状态没约束而全线失控。2.1 样本状态机先定义“能不能做下一步”再写业务代码样本从采样管进入实验室开始状态应该有已登记、已入库、检验中、待复核、已归档、已处置。很多 LIMS 源码里状态只是字符串字段前端传什么后端存什么这种设计最危险。我一般会在最底层写一个状态流转校验把“谁能从 A 到 B”用代码锁死。public enum SampleStatus { REGISTERED(已登记), IN_STOCK(已入库), TESTING(检验中), REVIEWED(待复核), ARCHIVED(已归档), DISPOSED(已处置); private final String desc; SampleStatus(String desc) { this.desc desc; } public String getDesc() { return desc; } public static boolean canTransit(SampleStatus from, SampleStatus to) { if (from null || to null) { return false; } if (from REGISTERED) { return to IN_STOCK; } if (from IN_STOCK) { return to TESTING || to DISPOSED; } if (from TESTING) { return to REVIEWED || to IN_STOCK; } if (from REVIEWED) { return to ARCHIVED || to TESTING; } if (from ARCHIVED) { return to DISPOSED; } return false; } }这个枚举的优点是状态流转规则集中在一处任何 Service 方法要改变样本状态之前都先调用canTransit判断。比如“已登记”样本不能直接跳转到“检验中”必须先入库检验不合格的样本可以退回已入库但复核通过后只能归档。参数说明上枚举里的desc是给前端展示用的中文名数据库里存的是name()这样即使页面调整文案也不影响存储。状态机的另一个重点是禁止前端直接改status字段。我在 Controller 层只暴露“入库、发起检验、复核通过、归档”这类业务动作目标是传入具体的下一个状态。源码里如果发现更新接口直接接收Sample实体并调用updateById那就是个风险点要尽快改成动作式接口。2.2 用 MyBatis-Plus 把登记入库接口落到一张 sample 表国内很多 Java LIMS 源码选型是 Spring Boot MyBatis-Plus因为单表 CRUD 不需要手写大量 XML。样本主表一般叫sample核心字段包括样本编号、样本名称、来源类型、状态、接收时间、存储位置、创建人。TableName(sample) public class Sample { TableId(type IdType.AUTO) private Long id; private String sampleNo; private String sampleName; private String sourceType; private String status; private LocalDateTime receiveTime; private String storeLocation; private Long createBy; // getter / setter 省略 }TableName(sample)把实体映射到表TableId(type IdType.AUTO)表示主键自增。如果源码要支持 Oracle 或分布式部署可以把IdType.AUTO换成IdType.ASSIGN_ID用雪花 ID。字段上的status存的是枚举的name()这样查询时容易看懂也方便后续做统计报表。登记接口的 Service 层常见写法是这样Autowired private SampleMapper sampleMapper; Transactional public SampleVO register(SampleRegisterDTO dto) { Sample sample new Sample(); sample.setSampleNo(generateSampleNo()); sample.setSampleName(dto.getSampleName()); sample.setSourceType(dto.getSourceType()); sample.setStatus(SampleStatus.REGISTERED.name()); sample.setReceiveTime(LocalDateTime.now()); sample.setCreateBy(SecurityUtils.getUserId()); sampleMapper.insert(sample); return SampleVO.from(sample); }generateSampleNo()不能只取System.currentTimeMillis()后面避坑章节会具体展开。这里关键是Transactional登记操作只涉及一张表时事务作用不明显但后续要同时插库存记录、写日志时少了这个注解就容易出现半成功状态。SecurityUtils.getUserId()是从登录上下文取当前操作人为审计日志做准备。Controller 层只接收 DTO不要直接接收实体。这么做的好处是前端传过来的字段永远不会越界修改createBy、status。可以说这是源码改造中性价比最高的一条规范。2.3 批量入库与库存流水一个事务里保证账实一致样本登记后要入库到某个冰箱的某个盒子。比单样本入库更常见的是批量入库一批样本分发到多个位置任何一个位置更新失败整批状态就不能保持一致。所以批量入库必须在一个事务里完成。Transactional public void stockInBatch(ListLong sampleIds, String shelfCode, String boxNo) { for (Long sampleId : sampleIds) { Sample sample sampleMapper.selectById(sampleId); if (sample null) { throw new IllegalArgumentException(样本不存在 sampleId); } if (!SampleStatus.canTransit(SampleStatus.valueOf(sample.getStatus()), SampleStatus.IN_STOCK)) { throw new IllegalStateException(样本 sample.getSampleNo() 当前状态不允许入库); } sample.setStatus(SampleStatus.IN_STOCK.name()); sample.setStoreLocation(shelfCode / boxNo); sampleMapper.updateById(sample); StockRecord record new StockRecord(); record.setSampleId(sampleId); record.setOperateType(STOCK_IN); record.setLocation(shelfCode / boxNo); record.setOperateTime(LocalDateTime.now()); stockRecordMapper.insert(record); } }这里把状态更新和库存流水写入放在同一个事务里任何一条样本失败整批回滚避免出现“样本状态显示已入库但库存流水缺失”的脏数据。参数上shelfCode和boxNo建议拆成两个字段不要只存一个storeLocation。因为后续扫码盘点、按位置检索时拆开的字段可以直接写 SQL不用在应用层解析字符串。另一个经验批量操作不要一次性for循环几千条MySQL 连接和事务日志会吃紧。一般每批控制在 500 条以内超了就拆批。遇到耗时的库存流水写入可以批量 insert但事务边界不要跨太长时间否则锁竞争严重。3. 行级权限与样本追溯LIMS 源码最值得复用的两块基建很多 LIMS 源码把权限做成菜单权限登录用户看到同一个页面只是按钮显隐不同。但样本库场景里最尴尬的是两个检测人员都在“样本列表”里A 能看到 B 全部样本。这里需要的是行级权限而不是菜单权限。行级权限是 Java 后端常年被问到的设计点在 LIMS 里尤其典型。3.1 为什么菜单权限拦不住“样本列表”的数据泄露Spring Security 能控制“谁能调用这个接口”但控制不了接口返回哪些行。样本库里的样本可能属于不同科室、不同项目、不同客户普通检测员应该只能看到自己创建或自己参与的样本。菜单权限默认把所有样本都查出来只是发到前端再隐藏数据已经到达浏览器等于没拦。常见做法是在查询链路里增加数据范围条件。首先把用户类型区分开超级管理员、科室管理员、普通检测员。超级管理员不过滤科室管理员只看本部门普通检测员只看自己创建的样本。这个规则不能完全靠if散落在 Service 里最好统一处理。3.2 自定义 DataScope Mapper 注入把行级权限做成默认约束我常用的一种实现是自定义注解 AOP 切面。先定义注解标记哪些查询方法需要做行级数据过滤Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface DataScope { String alias() default s; String column() default create_by; }然后在切面里把当前用户的数据范围写入一个线程本地变量在 Mapper 查询时读取。如果当前用户是管理员就不写入条件普通用户则强制拼上create_by userId而不是让每个 Service 自己记得拼。Aspect Component public class DataScopeAspect { Before(annotation(dataScope)) public void handle(JoinPoint joinPoint, DataScope dataScope) { LoginUser user SecurityUtils.getLoginUser(); if (user null) { throw new IllegalStateException(未登录用户无法访问样本数据); } if (user.isAdmin()) { DataScopeContext.clear(); return; } DataScopeContext.setUserId(user.getUserId()); DataScopeContext.setDeptId(user.getDeptId()); } }这里把数据权限条件挂在线程上下文中而不是直接拼到 SQL 里。因为 MyBatis-Plus 的LambdaQueryWrapper在 Service 层拼条件比较容易漏尤其在分页查询、导出查询里各写各的。统一用线程变量后Mapper XML 里始终读取这个条件避免因为某个方法忘了调用而把全库样本暴露出去。对应的 Mapper 查询可以这样写select idselectSampleList resultTypecom.lims.entity.Sample SELECT s.id, s.sample_no, s.sample_name, s.status, s.store_location, s.create_by FROM sample s where if testquery.sampleNo ! null and query.sampleNo ! AND s.sample_no LIKE CONCAT(%, #{query.sampleNo}, %) /if choose when testcurrentUserId ! null AND s.create_by #{currentUserId} /when when testcurrentDeptId ! null AND s.dept_id #{currentDeptId} /when /choose /where ORDER BY s.receive_time DESC /select注意currentUserId和currentDeptId都要从 Service 参数里显式传入不要依赖前端。choose的优先级是“本人数据”优先于“本部门数据”这样普通员工不会因为部门维度被无限放大权限。管理员场景下这两个参数都为 null查询会返回全部样本。3.3 样本审计日志状态变更和日志必须同一事务样本追溯的核心是“每一个状态变化都能回答谁在什么时间把样本从什么状态改成了什么状态”。我见到不少 LIMS 源码把日志写在业务代码外面或者用定时任务扫变更结果就是状态变了日志没变。正确做法是在状态变更的同一个事务里强制写一条操作日志。Transactional public void changeStatus(Long sampleId, SampleStatus targetStatus, String remark) { Sample sample sampleMapper.selectById(sampleId); if (sample null) { throw new IllegalArgumentException(样本不存在); } SampleStatus oldStatus SampleStatus.valueOf(sample.getStatus()); if (!SampleStatus.canTransit(oldStatus, targetStatus)) { throw new IllegalStateException(样本状态不能从 oldStatus 流转到 targetStatus); } sample.setStatus(targetStatus.name()); sampleMapper.updateById(sample); SampleLog log new SampleLog(); log.setSampleId(sampleId); log.setSampleNo(sample.getSampleNo()); log.setOldStatus(oldStatus.name()); log.setNewStatus(targetStatus.name()); log.setOperatorId(SecurityUtils.getUserId()); log.setOperateTime(LocalDateTime.now()); log.setRemark(remark); sampleLogMapper.insert(log); }这段代码的逻辑是先校验状态再更新样本再写日志。因为三条操作都在changeStatus方法里Transactional保证要么全成功要么全回滚。这样从日志表里只要查到sample_no就能完整还原该样本的流转过程不会出现“样本已经归档日志里还停在待复核”的情况。日志表只增不改不设update_time也不要提供修改接口。如果需要撤销误操作应该新增一条“状态调整”日志而不是删掉旧日志。这个设计思路很多新人一开始不理解但等遇到客户审计质疑“为什么这条记录不见了”时就知道坚持它有多重要了。4. 参数配置与部署避坑5 个让 LIMS 源码翻车的常见问题这章是改源码时一定会遇到的“血泪经验”。有些问题在本地 IDEA 里跑不出来上服务器才暴露有些问题看着是代码 bug实际是配置项没调好。每一条我都按“现象 → 原因 → 解决”来讲。4.1 时间差八小时数据库时区与 LocalDateTime 序列化问题现象本地测试时插入一条样本数据库里存的时间比页面传入时间早 8 小时接口返回的receiveTime又带着字母T前端格式化后和预期不一致。原因MySQL 5.7 和 8.0 的连接地址没有指定serverTimezoneJDBC 使用 JVM 默认时区解析而服务器时区可能是 UTC。另外 Spring Boot 默认序列化LocalDateTime时使用 ISO 格式不是yyyy-MM-dd HH:mm:ss。解决在application.yml里明确时区并在 VO 字段上做格式化。spring: datasource: url: jdbc:mysql://localhost:3306/lims?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jackson: time-zone: Asia/Shanghai对于LocalDateTime字段spring.jackson.date-format不一定生效最靠谱还是在 VO 的返回字段上加JsonFormatpublic class SampleVO { private Long id; private String sampleNo; JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime receiveTime; }注意实体里的LocalDateTime不要用JsonFormat因为实体还承担数据库映射给 VO 加就行避免把格式化逻辑写进持久层。修复后一定要重启服务并检查数据库连接池是否缓存了旧连接否则现象会“诡异”地反复出现。4.2 逻辑删除和唯一索引冲突同一个样本编号为什么重复入库报错现象删除一个样本后重新导入同一样本编号数据库报Duplicate entry但界面上明明已经看到记录被删掉了。原因MyBatis-Plus 的TableLogic逻辑删除只是把deleted字段从 0 改成 1数据行还留在表里。如果唯一索引建在(sample_no, deleted)上第一次删除后旧记录的deleted1如果再删除一次新插入的记录也是deleted1唯一索引直接冲突。如果唯一索引只建在sample_no上那删除之后永远无法重建同编号样本。解决不要用(sample_no, deleted)这种组合做唯一业务键。要么把sample_no在物理上做到全局唯一要么用deleted_at存空值。MySQL 唯一索引允许多个NULL所以新记录deleted_at NULL删除记录写入删除时间这样同编号样本既能被逻辑删除也能重新入库。另一个更简单的方法是在生成样本编号时加入批次号或随机串让编号本身天然不重复。具体编号生成问题在 4.5 里展开。4.3 Spring Boot 2.x 源码强行跑在 Boot 3 上的兼容性坑现象拿到一份源码pom.xml里是 Spring Boot 2.3.x 或 2.6.x却用 JDK 17 和 Spring Boot 3 启动编译报ClassNotFoundException: javax.servlet.Filter或SecurityConfig里的antMatchers方法直接报错。原因Spring Boot 3 从 Java EE 迁移到 Jakarta EE源码里的javax.servlet、javax.validation都换成了jakarta.servlet、jakarta.validation。Spring Security 的配置方法名也从antMatchers改成了requestMatchers。版本大跨度升级不是改一个版本号就能跑通的。解决先看pom.xml的spring-boot-starter-parent再决定运行环境。如果是 Spring Boot 2.x优先用 JDK 8 或 11如果是 Spring Boot 3必须 JDK 17。不要在新项目里直接把旧 LIMS 源码升 Boot 3除非你能把所有第三方依赖都排查一遍。全局替换javax.*后还要检查 MyBatis 等集成包版本工作量大。稳妥做法是保持源码原有大版本先用低版本环境跑通。4.4 上传附件报“临时目录无效”Linux 定时清理把 multipart 目录删了现象实验室上传 PDF 报告连续稳定运行几天后突然报The temporary upload location ... is not valid或No space left on device但磁盘空间还是够的。原因Spring Boot 默认使用系统临时目录/tmp作为 multipart 临时目录。Linux 的 systemd-tmpfiles 会周期清理/tmp下的旧文件目录被删除后上传请求找不到临时目录。这个问题在本地 Windows 测试时基本不会出现上服务器才暴露。解决配置一个持久化临时目录spring: servlet: multipart: max-file-size: 50MB max-request-size: 100MB location: /data/lims/tmp然后执行mkdir -p /data/lims/tmp chown lims:lims /data/lims/tmp chmod 750 /data/lims/tmp注意location是临时文件目录不是最终附件存储目录。上传完成后业务代码要把文件复制到/data/lims/storage或对象存储。如果只配置临时目录系统重启后临时文件仍会丢失所以上传接口的落盘逻辑不能省。4.5 并发生成样本编号Java 面试题里的场景就在这现象单点导入样本一切正常但多部门同时批量录入时偶发Duplicate entry 202506160001 for key uk_sample_no且不是每次必现。原因样本编号生成用yyyyMMddHHmmss 随机数或者System.currentTimeMillis()在并发量上来后重复概率被放大。这个问题经常出现在 Java 开发工程师面试题里但代码里照样有人踩。更隐蔽的是多实例部署两台应用同时生成编号synchronized也只能锁住单台 JVM换一台照样重复。解决用数据库序列或 Redis INCR 生成全局自增段再拼日期前缀。最简单可靠的是工作表CREATE TABLE sequence_table ( seq_name VARCHAR(50) PRIMARY KEY, current_value BIGINT NOT NULL ); UPDATE sequence_table SET current_value LAST_INSERT_ID(current_value 1) WHERE seq_name SAMPLE_NO; SELECT LAST_INSERT_ID();Java 里取到序号后拼日期public String generateSampleNo() { long seq sequenceMapper.nextValue(SAMPLE_NO); return LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) String.format(%06d, seq % 1000000); }这里有个细节seq % 1000000只适用于单日量级不超过百万的场景。如果要绝对唯一直接用seq 增量拼到编号里或者用雪花 ID 的十进制字符串。无论哪种方式sample_no字段都保留唯一索引作为最后一道兜底这才是最稳的安全网。5. 用四步验证把 LIMS 源码跑透从 Maven 启动到 SQL 日志接手一套 LIMS 源码最怕直接启动看一眼页面就说“跑通了”。我自己的习惯是先做四步验证任何一步不过绝不继续改功能。第一步锁定运行版本。先看pom.xml里 Spring Boot 的版本再执行java -version。Spring Boot 2.x 用 JDK 8 或 11Spring Boot 3 用 JDK 17。这一步能避开 4.3 说过的 javax/jakarta 混淆。IDE 用 IntelliJ IDEA 也可以社区版导入 Maven 项目就能跑不需要付费版。第二步用测试类把状态机测一遍。改任何 LIMS 源码前先写一个纯单元测试覆盖SampleStatus.canTransitSpringBootTest class SampleStatusTest { Test void registeredSampleCanStockIn() { assertTrue(SampleStatus.canTransit( SampleStatus.REGISTERED, SampleStatus.IN_STOCK)); } Test void registeredSampleCannotJumpToTesting() { assertFalse(SampleStatus.canTransit( SampleStatus.REGISTERED, SampleStatus.TESTING)); } }不要只写乐观路径要写上“不允许的跳转”。这一步能在五分钟内告诉你状态机有没有被人改穿。第三步用 MockMvc 打一个最小请求。验证 Controller 和参数校验是否通mockMvc.perform(post(/api/samples) .contentType(MediaType.APPLICATION_JSON) .content({\sampleName\:\全血\})) .andExpect(status().isCreated());如果源码返回的不是 201而是 400优先看 DTO 里的校验注解以及全局异常处理器是否吞掉了错误信息。这一步能把“接口入口是否健康”快速测出来。第四步开 MyBatis 的 SQL 日志确认行级权限真的生效。在application.yml里加logging: level: com.lims.mapper: debug然后登录普通检测员账号查样本列表控制台输出的 SQL 必须带有create_by ?或dept_id ?。如果 SQL 是裸查全表说明行级权限并没有真正绑到查询上。虚拟库或临时库跑测试时还可以用--spring.jpa.show-sqltrue观察 JPA 执行情况但 MyBatis 项目还是看 mapper 日志更直接。我早年改一套 LIMS 源码时跳过这一步结果上线后才发现前端传什么状态后端就存什么状态复核人员一次误点击让整批样本状态错乱。后来我给自己定了规矩状态机、权限、审计日志这三个位置不通过测试绝不提交。这套验证流程也是从那之后沉淀下来的。希望帮到你。本文还有配套的精品资源点击获取