SpringBoot LIS检验系统源码:样本状态机与结果审核实践

发布时间:2026/10/30 5:30:01
SpringBoot LIS检验系统源码:样本状态机与结果审核实践 简介面向计算机专业毕业设计与Java Web入门实战的LIS检验系统项目包围绕检验业务管理场景构建适合需要完成Spring Boot课程设计、毕业设计或管理系统练手的学生与开发者参考。压缩包约30.41MB交付内容以可运行程序源代码、数据库文件和环境配置说明为主文件总数与类型明细上游暂无可靠统计未作臆测。已有97人学习说明该选题在毕设与管理类项目中具备一定参考需求。读者可据此梳理需求分析、总体设计、功能设计、总体结构设计、数据结构设计、数据库访问、功能模块实现与测试总结的完整链路并对照源代码和数据库理解检验系统的业务结构、模块划分及关键实现用于搭建运行环境、核验功能、改造模块或撰写设计文档同时体会从需求到测试的完整项目组织方式减少从零摸索的时间。1. 一次值夜班的样本对不上号让我重新翻了这个 SpringBoot LIS 检验系统去年帮人排查过一个夜班事故门诊采血窗口连着扫了二十几个条码检验科那边的仪器结果回来之后有两个样本的结果对调了医生看到肌钙蛋白“正常”就放了病人走。后来查下来不是仪器的问题是中间那层拼装样本号和检验项目的逻辑写错了把重复条码当成了同一个样本。这件事之后我对任何叫“LIS 检验系统”的东西都会多看两眼状态机和主键设计。这份 springbootLIS检验系统 的毕业设计源码说白了就是一套麻雀虽小五脏俱全的实验室检验信息系统样本登记、条码生成、检验项目字典、结果录入、参考区间判高低、报告审核发布、角色权限一条线能跑通。它适合两类人一是要交毕设、需要一套能讲清楚业务链路的底稿二是刚接手院内小系统、想看看别人怎么把“样本状态机”落到代码里的开发者。看懂它的分层和状态流转比背十个框架注解有用。2. 系统骨架与技术选型一个 SpringBoot 单体从目录结构开始拆先给结论这是一个典型的单体三层架构没有微服务、没有消息队列、没有分布式事务所有业务都在一个进程里跑完。这不是缺点毕业设计和小型检验科场景下单体反而是最省心的选择——样本量一天几百到几千一个 MySQL 实例扛得住部署就是扔一个 jar 包到内网服务器上出问题看一个日志文件就够了。真上微服务光是样本状态在服务间怎么保证一致就够写三篇论文。技术栈上常见做法是 SpringBoot 打底持久层用 MyBatis-Plus因为 LIS 里大量是“按样本号查结果列表”“按条件分页查样本”这种模板化 CRUD用 MyBatis-Plus 的 Wrapper 能省掉一半 XML数据库 MySQL 5.7/8.0前端要么 Thymeleaf Layui 服务端渲染要么前后端分离的 Vue Element UI。这份源码按目录形态看更接近前者页面和接口在同一个工程里好处是拿下来就能跑不用再开一个前端工程装 node_modules。2.1 目录结构与分层包名就是业务地图一个 LIS 工程的包结构基本能看出作者有没有真正理清业务。合格的切法是按“层 业务域”两级划分而不是把所有 Controller 堆在一个包里。下面是这类项目里我见过最清晰的一种组织方式你对照着自己下载的源码看八成能对上src/main/java/com/example/lis ├── LisApplication.java # 启动类MapperScan 在这里 ├── common/ # 公共层不含业务 │ ├── Result.java # 统一返回体 {code,msg,data} │ ├── PageResult.java # 分页返回 │ ├── BizException.java # 业务异常全局异常处理器接住 │ ├── GlobalExceptionHandler.java # RestControllerAdvice │ └── constant/ │ ├── SampleStatus.java # 样本状态枚举关键 │ └── ResultFlag.java # 结果标志枚举 H/L/N/C ├── config/ # 配置类 │ ├── WebMvcConfig.java # 注册拦截器、静态资源、日期转换器 │ ├── MybatisPlusConfig.java # 分页插件 │ └── CorsConfig.java # 前后端分离时才需要 ├── controller/ # 按业务域分不要一个大包平铺 │ ├── SampleController.java # 样本登记、接收、退检 │ ├── ResultController.java # 结果录入、批量保存 │ ├── ReportController.java # 报告审核、发布、打印 │ ├── DictController.java # 检验项目、样本类型、科室字典 │ └── SysUserController.java ├── service/ │ ├── SampleService.java │ └── impl/SampleServiceImpl.java ├── mapper/ │ ├── SampleMapper.java │ └── xml/SampleMapper.xml # 复杂联表查询才写 XML ├── entity/ # 与表一一对应 │ ├── LisSample.java │ ├── LisTestResult.java │ └── SysUser.java ├── dto/ # 入参不要拿 entity 直接接 │ ├── SampleRegisterDTO.java │ └── ResultSaveDTO.java └── interceptor/ └── AuthInterceptor.java # 登录 权限校验几个地方值得说。第一SampleStatus单独抽成枚举而不是散落在代码里的魔法值 0/1/2这是 LIS 项目的生命线样本状态一旦用数字硬编码三个月后你自己都不记得 3 是“待审核”还是“已审核”。第二DTO 和 entity 分开样本登记页面传上来的字段和数据库表字段根本不是一回事页面要传患者姓名、性别、年龄、科室、医生、项目 ID 列表而lis_sample表里存的是冗余后的快照混用会导致后期加字段时到处都是JsonIgnore。第三mapper/xml目录别急着删MyBatis-Plus 解决不了“一个样本关联 N 条结果 项目字典 审核人姓名”这种联表查询最后还是要落到 XML。2.2 依赖与配置真正需要动的只有那几行pom 里通常就这几组依赖别被一堆 starter 吓到LIS 项目用不上响应式、用不上网关dependencies !-- Web 层提供 Controller 和内置 Tomcat -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis-Plus注意 SpringBoot 2.x 和 3.x 用的 starter 不一样 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId /dependency !-- MySQL 驱动8.0 之后是 com.mysql.cj.jdbc.Driver -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId /dependency !-- 如果页面是 Thymeleaf 渲染的这个必须有前后端分离则可删 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency !-- lombokData 少写几百行 getter -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies配置文件里真正要改的其实只有四类数据源、端口、MyBatis 的下划线转驼峰、日志级别。下面这份application-dev.yml是我一般会给学生项目配的最小集注释里写清楚了每一项改错的后果server: port: 8080 # 被占用就改别硬重启电脑 servlet: context-path: /lis # 加个前缀部署到内网后好做反向代理 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/lis?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 你的密码 hikari: maximum-pool-size: 10 # LIS 并发不高10 足够别开 100 connection-timeout: 3000 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 # 少了这句页面时间会差 8 小时 mybatis-plus: mapper-locations: classpath*:mapper/xml/*.xml configuration: map-underscore-to-camel-case: true # sample_no - sampleNo缺了它字段全是 null log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发期打 SQL上线关掉 logging: level: com.example.lis: debugserverTimezoneAsia/Shanghai和 Jackson 的time-zone这两处是血泪经验MySQL 8 默认拿 UTC 解释datetimeJava 端又按 JVM 默认时区序列化两边一错位样本采集时间就变成 8 小时前报告上的采样时间和实际采血时间对不上检验科是会找你麻烦的。开发期把 SQL 打出来也很有必要因为 LIS 里最多的 bug 不是逻辑错是条件拼错导致查出来一堆空数据。2.3 表结构设计样本、项目、结果三张主表怎么挂钩LIS 的数据模型有一条铁律样本和检验结果是一对多结果和项目字典是多对一但报告上要展示的是“结果发生那一刻的项目参考区间快照”。很多毕设把参考区间只存在字典表里报告打印时再 join 回去取结果三个月后科室调整了参考值历史报告全变了——这在真实检验科是绝对不能接受的。核心三张表通常是这么设计的-- 样本主表一条记录代表一管血/一份尿 CREATE TABLE lis_sample ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sample_no VARCHAR(32) NOT NULL COMMENT 样本号通常是 日期流水如 20240512001, barcode VARCHAR(64) NOT NULL COMMENT 条码号仪器回传时靠它对账, patient_name VARCHAR(64) NOT NULL COMMENT 患者姓名冗余快照, gender CHAR(1) DEFAULT 1 COMMENT 1男2女, age INT DEFAULT NULL, dept_name VARCHAR(64) COMMENT 送检科室快照不存 dept_id 是为了历史不漂移, doctor_name VARCHAR(64) COMMENT 送检医生, sample_type VARCHAR(32) COMMENT 血清/全血/尿液, status TINYINT NOT NULL DEFAULT 0 COMMENT 见 SampleStatus 枚举, collect_time DATETIME COMMENT 采样时间, receive_time DATETIME COMMENT 接收时间, create_by BIGINT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, deleted TINYINT DEFAULT 0 COMMENT 逻辑删除MyBatis-Plus 自动拼条件, UNIQUE KEY uk_sample_no (sample_no), UNIQUE KEY uk_barcode (barcode), KEY idx_status_time (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT样本主表; -- 检验结果表一条记录 一个样本的一个项目 CREATE TABLE lis_test_result ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sample_id BIGINT NOT NULL, item_code VARCHAR(32) NOT NULL COMMENT 项目代号如 GLU、ALT, item_name VARCHAR(64) NOT NULL COMMENT 项目名称快照, result_value VARCHAR(64) COMMENT 结果值用字符串存因为可能是 0.05 这种, unit VARCHAR(32) COMMENT 单位快照, ref_low DECIMAL(18,4) COMMENT 参考下限快照, ref_high DECIMAL(18,4) COMMENT 参考上限快照, flag CHAR(1) DEFAULT N COMMENT N正常 H偏高 L偏低 C危急值, critical_flag TINYINT DEFAULT 0 COMMENT 是否危急值1 需要弹窗提醒, tester_id BIGINT, test_time DATETIME, reviewer_id BIGINT, review_time DATETIME, KEY idx_sample (sample_id), KEY idx_critical (critical_flag, review_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT检验结果明细;这里有两处刻意的冗余item_name/unit/ref_low/ref_high直接快照到结果行上dept_name/doctor_name直接快照到样本行上。新人常问“为什么不建外键关联”答案是检验系统最看重的不是存储节省而是报告可追溯——五年后有人拿着纸质报告回来问“当时这个值为什么标红”你得能还原出当时的判定依据。参考区间快照就是那个依据。同理result_value用 VARCHAR 而不是 DECIMAL是因为质控和手工录入里存在0.05、阴性、这类非数值结果用数值类型会在插入时直接报错。索引也要留意idx_status_time是给“待审核列表按时间倒序”这个最高频查询准备的idx_critical是给危急值提醒面板准备的。这两个索引不加样本量上万之后列表页要转两三秒演示的时候很尴尬。3. 样本流转主链路状态机、登记、结果录入与审核的一段完整代码LIS 系统的复杂不在单点技术而在状态。一管血从门诊采血到报告发出要经过登记、采样、运输接收、上机、结果回传、人工审核、发布、打印中间还可能退检、重采、追加项目。这些状态如果有任何一步可以被跳过就会出现“报告发出来了但样本还没接收”这种鬼故事。所以这一章先讲状态机怎么定再讲三段最核心的代码。3.1 状态机定义一个样本从登记到发布要过几道闸我一般会把状态收敛成六个多了管不住少了说不清public enum SampleStatus { REGISTERED(0, 已登记), // 门诊/住院开出申请条码已生成 COLLECTED(1, 已采样), // 护士采完血扫了条码 RECEIVED(2, 已接收), // 检验科签收样本进实验室 TESTING(3, 检验中), // 已上机等待结果 TO_AUDIT(4, 待审核), // 仪器结果回传完毕或手工录入完毕 PUBLISHED(5, 已发布), // 审核通过报告可打印 REJECTED(-1, 已退检); // 标本溶血、量不足等终止流转 private final int code; private final String desc; SampleStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } /** 允许的状态迁移表只允许这里列出的迁移其余一律拒绝 */ private static final MapSampleStatus, SetSampleStatus ALLOW Map.of( REGISTERED, Set.of(COLLECTED, REJECTED), COLLECTED, Set.of(RECEIVED, REJECTED), RECEIVED, Set.of(TESTING, REJECTED), TESTING, Set.of(TO_AUDIT, REJECTED), TO_AUDIT, Set.of(PUBLISHED, TESTING), // 审核驳回可退回检验中 PUBLISHED, Set.of() // 终态只能走冲红流程 ); public static boolean canTransfer(SampleStatus from, SampleStatus to) { return ALLOW.getOrDefault(from, Set.of()).contains(to); } }这个枚举是整个项目里我最愿意多写几行的类。canTransfer把“哪些迁移合法”收敛成一张表Service 层所有改状态的地方都先过一遍这个方法非法迁移直接抛BizException。这样做的好处是前端的按钮该不该置灰、后端的接口该不该放行判断依据是同一份数据不会出现前端允许点、后端拒绝或者后端允许改、前端没入口的情况。提示状态迁移表用Map.of在 Java 9 以上才可用。如果工程是 JDK 8换成静态代码块里putCollections.unmodifiableMap即可别为了一个枚举去改整个项目的 JDK 版本。3.2 样本登记与条码Controller 到 Mapper 的完整一段登记是入口讲究两件事条码号不能重复、样本号和检验项目要在同一个事务里落库。下面这段 Service 是从 Controller 下来的第二站前后我都标了注释Service RequiredArgsConstructor public class SampleServiceImpl implements SampleService { private final LisSampleMapper sampleMapper; private final LisTestResultMapper resultMapper; private final DictItemService dictItemService; // 检验项目字典 /** * 样本登记生成样本号 条码 一次性写入本次申请的检验项目 * param dto 前端传的患者信息 itemCodes检验项目代号列表 */ Override Transactional(rollbackFor Exception.class) // 必须写 Exception否则受检异常不回滚 public String register(SampleRegisterDTO dto) { // 1. 条码唯一性前置校验。数据库唯一索引是最后一道防线 // 但这里先查一次能给前端返回友好提示而不是 500 堆栈 Long dup sampleMapper.selectCount( new LambdaQueryWrapperLisSample().eq(LisSample::getBarcode, dto.getBarcode())); if (dup ! null dup 0) { throw new BizException(条码 dto.getBarcode() 已被登记请勿重复扫码); } // 2. 生成样本号yyyyMMdd 3 位当日流水。 // 真实场景流水号要用数据库序号表或 Redis 自增这里用当日最大号 1 演示 String today LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); String maxNo sampleMapper.selectTodayMaxNo(today); // XML 里写 SELECT MAX(sample_no) ... int seq (maxNo null) ? 1 : Integer.parseInt(maxNo.substring(8)) 1; String sampleNo today String.format(%03d, seq); // 3. 样本主体落库患者信息在这里做快照 LisSample sample new LisSample(); BeanUtils.copyProperties(dto, sample); sample.setSampleNo(sampleNo); sample.setStatus(SampleStatus.REGISTERED.getCode()); sample.setCollectTime(LocalDateTime.now()); sampleMapper.insert(sample); // 4. 按申请项目生成结果占位行。注意这里就把参考区间快照进去 // 而不是等录入结果时再查字典表 ListDictItem items dictItemService.listByCodes(dto.getItemCodes()); if (items.isEmpty()) { throw new BizException(申请项目为空或项目代号不存在); } for (DictItem item : items) { LisTestResult r new LisTestResult(); r.setSampleId(sample.getId()); r.setItemCode(item.getItemCode()); r.setItemName(item.getItemName()); r.setUnit(item.getUnit()); r.setRefLow(item.getRefLow()); r.setRefHigh(item.getRefHigh()); r.setFlag(N); resultMapper.insert(r); } return sampleNo; } }逻辑上就四步查重、生成号、写样本、写结果占位行。有几个参数值得单独说。sampleNo的生成规则里today用LocalDate而不是new Date()格式化是为了避免服务器时区不同导致日期串天流水号用MAX(sample_no)1在单机内网低并发下够用但如果检验科有两个登记窗口同时扫码理论上会撞号——撞了会被唯一索引拦住用户看到报错重扫一次即可这是可接受的降级真要严谨就建一张lis_seq表用UPDATE ... SET val val 1加行锁。再就是Transactional(rollbackFor Exception.class)这个细节我见过太多项目只写Transactional然后BizException继承的是RuntimeException还好一旦有人把它改成受检异常样本写进去了结果行没写进去就出现了“报告单上一个项目都没有”的鬼报告。Controller 那一层反而薄接参数、做校验、调 Service、统一包装返回RestController RequestMapping(/sample) RequiredArgsConstructor public class SampleController { private final SampleService sampleService; /** 登记接口Valid 触发 DTO 上的 NotBlank 等校验 */ PostMapping(/register) public ResultString register(RequestBody Valid SampleRegisterDTO dto) { return Result.ok(sampleService.register(dto)); } /** 分页查样本status 传 -2 或不传代表全部 */ GetMapping(/page) public ResultPageResultLisSample page(RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize, RequestParam(required false) Integer status, RequestParam(required false) String keyword) { return Result.ok(sampleService.page(pageNum, pageSize, status, keyword)); } }keyword一般同时匹配样本号和患者姓名别只匹配一个检验科的人记不住样本号往往是拿名字搜的。3.3 结果录入与危急值判定最该加事务的地方结果录入是整个系统里唯一“批量 判定 改状态”三件事挤在一起的操作也是最容易翻车的地方。仪器回传也好手工录入也好落库逻辑是同一套Override Transactional(rollbackFor Exception.class) public void saveResults(ResultSaveDTO dto, Long testerId) { // 1. 先锁样本行防止两个人同时对同一个样本录入/审核 LisSample sample sampleMapper.selectByIdForUpdate(dto.getSampleId()); if (sample null) { throw new BizException(样本不存在); } // 2. 状态闸门只有检验中和待审核允许录入结果 if (!SampleStatus.canTransfer(SampleStatus.valueOf(sample.getStatus()), SampleStatus.TO_AUDIT) sample.getStatus() ! SampleStatus.TESTING.getCode() sample.getStatus() ! SampleStatus.TO_AUDIT.getCode()) { throw new BizException(当前状态[ sample.getStatus() ]不允许录入结果); } boolean hasCritical false; for (ResultItemDTO item : dto.getItems()) { LisTestResult r resultMapper.selectById(item.getResultId()); if (r null || !r.getSampleId().equals(sample.getId())) { throw new BizException(结果行与样本不匹配疑似参数被篡改); } r.setResultValue(item.getResultValue()); r.setTestTime(LocalDateTime.now()); r.setTesterId(testerId); // 3. 判高低 判危急值。数值型才判文本型阴性/阳性跳过 if (isNumeric(item.getResultValue())) { BigDecimal v new BigDecimal(item.getResultValue()); if (r.getRefLow() ! null v.compareTo(r.getRefLow()) 0) r.setFlag(L); else if (r.getRefHigh() ! null v.compareTo(r.getRefHigh()) 0) r.setFlag(H); else r.setFlag(N); // 危急值参考区间外再叠加一个绝对阈值任一命中即算 if (isCritical(r.getItemCode(), v)) { r.setFlag(C); r.setCriticalFlag(1); hasCritical true; } } resultMapper.updateById(r); } // 4. 全部录入完成状态推进到待审核 sampleMapper.updateStatus(sample.getId(), SampleStatus.TO_AUDIT.getCode()); if (hasCritical) { // 这里触发弹窗提醒/消息推送常见做法是写一条通知记录让前端轮询 notifyService.pushCritical(sample, dto.getItems()); } } /** 数值判定排除 0.05、阴性 这类 */ private boolean isNumeric(String s) { return s ! null s.matches(-?\\d(\\.\\d)?); }这段代码有四个点值得抠。第一selectByIdForUpdate走的是SELECT ... FOR UPDATE在事务里锁住样本行两个人同时点“提交结果”时第二个人会阻塞到第一个人提交完再看到状态已经是待审核直接被闸门拦下。没有这把锁并发下会出现同一批结果被覆盖两次。第二!r.getSampleId().equals(sample.getId())这个校验看起来啰嗦但它是防“结果串样本”的关键。前端传上来的resultId是可以在浏览器里改的如果不校验归属改个 ID 就能把 A 样本的血糖写到 B 样本的报告上——前面说的夜班事故就是这么来的。第三危急值判定用两层先算 H/L 标识再用独立阈值判 C。原因很简单有些项目参考区间很宽比如某些酶稍微超标不算危急但肌钙蛋白只要阳性就必须立刻打电话通知医生。所以isCritical里一般维护一张项目代号到阈值的映射表而不是简单用参考区间乘以系数。第四notifyService.pushCritical写在了事务里。严格说通知应该在事务提交后发否则通知发出去了事务回滚了医生接到电话却查不到结果。改造方式是用TransactionSynchronizationManager.registerSynchronization注册 afterCommit 回调或者干脆拆成两个方法由外层编排。这个小细节在毕设答辩里说出来基本能加分。3.4 报告审核与回写为什么审核不能直接 update审核是发布前的最后一道闸。它的逻辑比录入简单但责任最重——审核人签字之后报告就具备法律意义上的结果了。所以审核接口必须做三件事校验状态、记录审核人和时间、把样本推进到已发布并且整个过程不能允许“审核完还能改结果”。Override Transactional(rollbackFor Exception.class) public void audit(Long sampleId, Long reviewerId, String opinion) { LisSample sample sampleMapper.selectByIdForUpdate(sampleId); if (sample null) throw new BizException(样本不存在); // 只有待审核状态可以审核重复点提交第二次会落到这里 if (sample.getStatus() ! SampleStatus.TO_AUDIT.getCode()) { throw new BizException(该样本不处于待审核状态无法审核); } // 二次校验所有结果行都必须有值不能有漏项 Long empty resultMapper.selectCount(new LambdaQueryWrapperLisTestResult() .eq(LisTestResult::getSampleId, sampleId) .and(w - w.isNull(LisTestResult::getResultValue).or().eq(LisTestResult::getResultValue, ))); if (empty ! null empty 0) { throw new BizException(存在 empty 个未录入结果的项目不能审核发布); } // 写审核痕迹审核人、时间、意见落到结果行上 resultMapper.markReviewed(sampleId, reviewerId, LocalDateTime.now()); // 样本终态 sampleMapper.updateStatus(sampleId, SampleStatus.PUBLISHED.getCode()); }为什么强调“不能直接 update 状态”因为审核之后如果有人绕过接口、直接用 SQL 改lis_test_result报告内容和审核痕迹就对不上了。稳妥的做法是在结果表上加一个reviewed标记任何修改结果的 Mapper 方法都带WHERE reviewed 0条件改不动就说明已经审核过了。这是成本极低的一道保险比事后查日志强得多。4. 权限与仪器接口两个最容易被答辩追问的模块业务主链路跑通之后项目里真正体现“这是个 LIS 而不是个增删改查 Demo”的是权限和仪器对接。这两块也是答辩现场最爱问的权限问的是“检验技师能不能删报告”仪器接口问的是“结果是怎么从机器进到系统里的”。这一章把这两块拆开讲。4.1 权限模型用户-角色-菜单 注解式拦截LIS 的角色划分通常不复杂四五种就够管理员、登记员门诊窗口、检验技师录入和上机、审核医师审核发布、临床医生只看自己科室的报告。所以权限模型用最经典的五表就够了sys_user、sys_role、sys_user_role、sys_menu或sys_permission、sys_role_menu。实现上有两条路Shiro/Spring Security或者自己写拦截器。毕设项目我一般推荐后者因为逻辑透明、调试方便而且 LIS 的权限粒度就是“接口级”用不上 Security 那一整套过滤器链。核心是一个自定义注解加一个拦截器/** 标在 Controller 方法上value 是权限编码如 sample:audit */ Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequirePerm { String value(); }Component RequiredArgsConstructor public class AuthInterceptor implements HandlerInterceptor { private final PermissionService permissionService; /** 本地缓存避免每次请求都查库权限变更时手动清一下 */ private final CacheLong, SetString permCache Caffeine.newBuilder() .expireAfterWrite(Duration.ofMinutes(10)).build(); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 放行静态资源和登录接口 if (!(handler instanceof HandlerMethod)) return true; HandlerMethod hm (HandlerMethod) handler; if (hm.hasMethodAnnotation(LoginIgnore.class)) return true; // 2. 从 session / token 里取当前用户 SysUser user UserContext.get(); if (user null) { writeJson(response, 401, 未登录或登录已过期); return false; } // 3. 方法上没有权限注解 登录即可访问 RequirePerm perm hm.getMethodAnnotation(RequirePerm.class); if (perm null) return true; // 4. 取该用户的权限码集合判断是否包含所需权限 SetString codes permCache.get(user.getId(), id - permissionService.listPermCodesByUserId(id)); if (codes null || !codes.contains(perm.value())) { writeJson(response, 403, 无权限 perm.value()); return false; } return true; } private void writeJson(HttpServletResponse resp, int code, String msg) throws IOException { resp.setContentType(application/json;charsetUTF-8); resp.getWriter().write({\code\: code ,\msg\:\ msg \}); } }拦截器注册在WebMvcConfig#addInterceptors里addPathPatterns(/**)加上excludePathPatterns(/login, /css/**, /js/**)。这里有两个坑一是别忘了if (!(handler instanceof HandlerMethod)) return true;否则访问静态资源时强转会抛异常二是权限缓存要有失效机制管理员给某个人加了“审核”权限之后如果缓存十分钟不刷新用户会觉得系统坏了。最简单的做法是在角色授权接口里主动permCache.invalidate(userId)。权限编码的命名建议统一成模块:动作比如sample:register、sample:receive、result:save、report:audit、report:print、dict:edit。这套编码跟菜单表里的perms字段对上前端用同一份数据控制按钮显隐前后端就不会打架。4.2 仪器接口ASTM 报文接收与解析落库检验科的生化仪、血球仪大多支持 ASTM E1394 协议走串口或者 TCP。毕设项目一般做 TCP 这一侧仪器作为客户端连过来或者系统作为客户端去连仪器。核心是把下面这种报文解析成结构化的结果H|\^||| analyzer^1.0 ||||| P|1|20240512103000 P|1||||张三||19900101|M O|1|20240512001||^^^GLU|R||||||N||||1 R|1|^^^GLU|5.62|mmol/L||3.90-6.10||N||F||||20240512103000 R|2|^^^ALT|78.5|U/L||9.0-50.0||H||F||||20240512103000 L|1|N解析思路是按行切分字段用|分隔然后按首字母判断段类型H是头P是患者信息O是申请单R是结果L是结束。下面是一个够用的解析器public class AstmParser { /** * 解析 ASTM 报文返回 样本条码 - 结果列表 * 报文里 O 段的第 3 个字段是样本号R 段第 3 个字段里 ^^^ 后面是项目代号 */ public static MapString, ListAstmResult parse(String raw) { MapString, ListAstmResult map new LinkedHashMap(); String currentSample null; for (String line : raw.split(\\r?\\n)) { if (line null || line.length() 2) continue; String[] f line.split(\\|, -1); char type f[0].charAt(0); switch (type) { case O: // O|序号|样本号|... 注意不同仪器字段位置有差异要按机型配置 if (f.length 2) { currentSample f[2].trim(); map.computeIfAbsent(currentSample, k - new ArrayList()); } break; case R: // R|序号|^^^项目代号|结果值|单位|参考范围|标志 if (currentSample ! null f.length 4) { String code f[2].replace(^, ).trim(); // 去掉 ^^^ 前缀 AstmResult r new AstmResult(); r.setItemCode(code); r.setValue(f[3].trim()); r.setUnit(f[4].trim()); r.setRawFlag(f.length 8 ? f[8].trim() : ); map.get(currentSample).add(r); } break; case L: // 结束段一个报文可能包含多个样本这里不 break 循环继续下一个 currentSample null; break; default: break; } } return map; } }解析出来只是第一步落库要处理三件事。其一条码匹配不上样本怎么办——常见做法是写进lis_instrument_data原始报文表标记为“未匹配”界面上给个待认领列表别直接丢掉丢了就是一桩悬案。其二结果值里可能带仪器自己的标志位H/L/正常但仪器阈值和系统参考区间不一定一致最终标志位要以系统判定为准仪器标志只作为参考存起来。其三报文里的项目代号是仪器侧配置的跟系统字典的item_code往往对不上中间需要一张映射表lis_item_mapping(instrument_id, instrument_code, item_code)这个表不加换个仪器就得改代码。/** 仪器报文入库原始报文先落档再逐条匹配样本 */ Transactional(rollbackFor Exception.class) public int receive(String raw, Long instrumentId) { // 1. 原始报文无条件落档出问题可回溯 LisInstrumentData data new LisInstrumentData(); data.setInstrumentId(instrumentId); data.setRawText(raw); data.setReceiveTime(LocalDateTime.now()); instrumentDataMapper.insert(data); // 2. 解析并按条码匹配 MapString, ListAstmResult parsed AstmParser.parse(raw); int matched 0; for (Map.EntryString, ListAstmResult e : parsed.entrySet()) { LisSample sample sampleMapper.selectByBarcode(e.getKey()); if (sample null) { // 匹配不上不报错留给人工认领避免整批结果全丢 data.setStatus(1); // 1部分未匹配 continue; } resultService.fillByInstrument(sample.getId(), e.getValue(), instrumentId); matched; } return matched; }这段代码体现的思路是“宁可留痕不要丢数据”。仪器报文是外部输入格式不可控、时机不可控任何一处异常都不该让整批结果回滚消失。原始报文先落库解析失败也能人工看原文补录这是我在真实场景里被教育出来的习惯。4.3 字典与参考区间缓存与快照的边界检验项目字典lis_dict_item被读得非常频繁登记时要查项目、录入时要查参考区间、打印时要查单位。这类读多写少的数据适合上本地缓存Service public class DictItemService { private final LisDictItemMapper mapper; private final CacheString, DictItem cache Caffeine.newBuilder() .maximumSize(2000).expireAfterWrite(Duration.ofHours(1)).build(); public DictItem getByCode(String code) { return cache.get(code, c - mapper.selectOne( new LambdaQueryWrapperLisDictItem().eq(LisDictItem::getItemCode, c))); } /** 字典维护页面保存后必须调用否则缓存里的旧区间会一直生效 */ public void evict(String code) { cache.invalidate(code); } }但缓存只解决“查得快”不解决“改字典影响历史报告”的问题。这就是前面强调快照的原因缓存可以随时失效历史结果行上的参考区间不能动。判断标准很清楚——用于当前判断的走缓存用于展示历史结论的走快照两者不要混。5. 避坑与常见问题排查跑不起来和跑起来不对是两类问题接手这类源码第一道坎往往不是看懂代码是让它跑起来。第二道坎是跑起来之后发现数据不对。下面这几条是我在不同项目里反复遇到的按“现象 → 原因 → 解决”记录你可以当成一份排查清单。5.1 现象启动直接报错日志里全是 HikariPool 拿不到连接原因通常是三种数据库根本没建、建了但字符集不是 utf8mb4、或者 SQL 脚本导入到一半报错停了导致部分表缺失。还有一种隐蔽的连接串里没加serverTimezoneMySQL 8 驱动会抛The server time zone value is unrecognized。解决顺序是先把数据库这一层单独验证不要一上来就怀疑代码# 1. 建库字符集一步到位别用默认的 latin1 mysql -uroot -p -e DROP DATABASE IF EXISTS lis; CREATE DATABASE lis DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 2. 导入脚本注意看有没有 ERROR脚本里有外键顺序问题时先关检查 mysql -uroot -p --default-character-setutf8mb4 lis -e SET FOREIGN_KEY_CHECKS0; source sql/lis.sql; SET FOREIGN_KEY_CHECKS1; # 3. 确认表数量和编码 mysql -uroot -p -e USE lis; SHOW TABLES; SHOW CREATE TABLE lis_sample\G | head -40表数量对不上就重新导别硬启动。启动类上的MapperScan(com.example.lis.mapper)也要确认包路径和实际一致路径写错时 Spring 起得来但注入 Mapper 会失败报错信息跟数据库一点关系都没有很容易误判。5.2 现象报告上的结果跑到了别的患者名下这是最严重的一类 bug。原因通常有两个一是结果表更新时只用了resultId没有校验resultId是否属于当前sampleId3.3 节里那段归属校验就是为这个加的二是“一键复制上次结果”这类功能做成了按项目代号批量更新条件里漏了样本 ID。还有一种更隐蔽的前端表格的行号和数据行对不上用户改了第三行实际提交的是第四行的 ID。解决上服务端必须做归属校验这是底线。排查时可以直接查库确认-- 找出同一 result_id 被多个样本引用的情况正常情况下不可能出现 SELECT result_id, COUNT(DISTINCT sample_id) c FROM lis_test_result GROUP BY result_id HAVING c 1; -- 核对某个患者的结果是否与样本时间严重不匹配 SELECT s.sample_no, s.patient_name, r.item_name, r.result_value, r.test_time FROM lis_sample s JOIN lis_test_result r ON r.sample_id s.id WHERE s.patient_name 张三 ORDER BY r.test_time;5.3 现象页面时间差 8 小时或者中文全是问号时间问题九成出在时区链条上MySQL 服务端时区、JDBC 连接参数serverTimezone、JVM 默认时区、Jackson 序列化时区四处任意一处不一致就会偏。最省事的做法是四处统一成Asia/Shanghai并且在WebMvcConfig里注册一个全局的日期转换器让RequestParam接字符串日期时也走同一套格式。中文乱码则分两层数据库层看SHOW CREATE TABLE里的 CHARSET 是不是 utf8mb4Web 层看server.servlet.encoding.charsetUTF-8和forcetrue有没有配。还有一种情况是导出 Excel/PDF 时文件名乱码那是Content-Disposition没做 URL 编码跟数据库无关。5.4 现象加了 Transactional出错了数据还是写进去了事务不生效有几个经典原因按出现频率排方法不是 publicSpring AOP 代理不到同类内部方法直接调用this.saveXxx()绕过了代理异常被 catch 住了没往外抛抛的是受检异常但注解没写rollbackFor。这四个里前两个最常见尤其是“在一个 Service 方法里调用本类的另一个带事务的方法”看起来天衣无缝实际完全不生效。排查手法很土但有效临时把logging.level.org.springframework.jdbcdebug打开看日志里有没有Creating new transaction和Rolling back。没有Creating就说明事务压根没开别再去怀疑 SQL 了。库表引擎也要确认是 InnoDBMyISAM 不支持事务导脚本时如果建表语句漏了ENGINEInnoDB会得到一张“怎么都不回滚”的表。注意逻辑删除字段deleted配上 MyBatis-Plus 的TableLogic之后所有selectById都会自动带deleted0。如果某天发现“明明库里有这条数据但查不出来”先去看这个字段是不是被置成 1 了。6. 二次开发把这套毕设改成能交付的小系统我是这么排改造顺序的拿到这套源码最忌讳的是上来就加功能。我见过有人第一周就往里塞“微信通知”“AI 辅助判读”结果基础链路的并发问题还没解决新功能一上线就把老问题放大了十倍。改造要按“稳 → 通 → 扩”的顺序来具体我一般排成四步。第一步是把状态机彻底管住。检查所有改status的地方是不是都走了canTransfer有没有哪个 Controller 直接updateById改状态。这一步不加新功能纯加固但它是后面所有扩展的地基。第二步是补审计。样本表、结果表、报告表加create_by/create_time/update_by/update_time关键操作审核、退检、修改已发布结果单独写一张lis_oper_log。有了这张表任何“谁把结果改了”的争论都能五分钟内结束。第三步才是功能扩展。常见的三个方向难度和收益我列成表改造方向改动点难度建议危急值闭环通知表 前端轮询 医生确认回执中优先做业务价值最高报告 PDF 导出模板引擎 字体嵌入低注意中文字体Linux 服务器常缺字体仪器多机型适配映射表 解析策略模式中高先抽象接口别写 if-else 堆机型质控图Westgard质控数据表 规则引擎高毕设加分项但别在交付前夜做第四步是加一层接口测试。LIS 这种系统最怕回归样本登记 → 结果录入 → 审核发布这条主链路至少要有一个能一键跑的脚本改完代码跑一遍绿了再提交。拿“危急值闭环”举例改造点其实很小关键是把 3.3 节里那个事务内推送挪到提交之后// 在 saveResults 里替换原来的 notifyService.pushCritical(...) if (hasCritical) { // 事务提交后再推送避免回滚了却已经通知了医生 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { criticalNoticeService.createAndPush(sample.getId(), dto.getItems()); } }); }验证方法也简单故意录入一个远超阈值的肌钙蛋白看通知表有没有记录、前端轮询能不能弹出来然后把事务人为抛异常确认通知表里没有脏数据。这两个用例跑通这个改造才算完成。最后说个习惯。这套源码里我最看重的不是功能多少而是它把样本状态、参考区间快照、审核痕迹这三件事做对了——这三件事做对项目就有继续长的基础做错功能加得越多越难救。从那以后我每次接手这类系统第一件事不是看页面而是先把状态枚举和结果表的 DDL 翻出来读一遍读完心里就有数了。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询