简介:一份基于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(List<Long> 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 id="selectSampleList" resultType="com.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 test="query.sampleNo != null and query.sampleNo != ''"> AND s.sample_no LIKE CONCAT('%', #{query.sampleNo}, '%') </if> <choose> <when test="currentUserId != null"> AND s.create_by = #{currentUserId} </when> <when test="currentDeptId != 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 的连接地址没有指定serverTimezone,JDBC 使用 JVM 默认时区解析,而服务器时区可能是 UTC。另外 Spring Boot 默认序列化LocalDateTime时使用 ISO 格式,不是yyyy-MM-dd HH:mm:ss。
解决:在application.yml里明确时区,并在 VO 字段上做格式化。
spring: datasource: url: jdbc:mysql://localhost:3306/lims?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jackson: time-zone: Asia/Shanghai对于LocalDateTime字段,spring.jackson.date-format不一定生效,最靠谱还是在 VO 的返回字段上加@JsonFormat:
public class SampleVO { private Long id; private String sampleNo; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime receiveTime; }注意:实体里的LocalDateTime不要用@JsonFormat,因为实体还承担数据库映射;给 VO 加就行,避免把格式化逻辑写进持久层。修复后一定要重启服务,并检查数据库连接池是否缓存了旧连接,否则现象会“诡异”地反复出现。
4.2 逻辑删除和唯一索引冲突:同一个样本编号为什么重复入库报错
现象:删除一个样本后,重新导入同一样本编号,数据库报Duplicate entry,但界面上明明已经看到记录被删掉了。
原因:MyBatis-Plus 的@TableLogic逻辑删除只是把deleted字段从 0 改成 1,数据行还留在表里。如果唯一索引建在(sample_no, deleted)上,第一次删除后旧记录的deleted=1;如果再删除一次,新插入的记录也是deleted=1,唯一索引直接冲突。如果唯一索引只建在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 或 11,Spring Boot 3 用 JDK 17。这一步能避开 4.3 说过的 javax/jakarta 混淆。IDE 用 IntelliJ IDEA 也可以,社区版导入 Maven 项目就能跑,不需要付费版。
第二步,用测试类把状态机测一遍。改任何 LIMS 源码前,先写一个纯单元测试覆盖SampleStatus.canTransit:
@SpringBootTest 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-sql=true观察 JPA 执行情况,但 MyBatis 项目还是看 mapper 日志更直接。
我早年改一套 LIMS 源码时跳过这一步,结果上线后才发现前端传什么状态后端就存什么状态,复核人员一次误点击让整批样本状态错乱。后来我给自己定了规矩:状态机、权限、审计日志这三个位置不通过测试,绝不提交。这套验证流程也是从那之后沉淀下来的。希望帮到你。
本文还有配套的精品资源,点击获取