From 59b54db9f634485395b368ef75649cb572291ab4 Mon Sep 17 00:00:00 2001 From: wxl Date: Thu, 28 May 2026 10:06:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD=EF=BC=9A?= =?UTF-8?q?=E8=AF=95=E5=89=82=E5=85=AC=E5=BC=8F=E7=BB=B4=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lims/entry/ReagentFormula.java | 37 +++ lab-service/lab-lims/pom.xml | 7 + .../controller/ExamineResultController.java | 12 + .../controller/ReagentFormulaController.java | 255 ++++++++++++++++++ .../lims/mapper/ReagentFormulaMapper.java | 13 + .../lims/service/IExamineResultService.java | 17 ++ .../lims/service/IReagentFormulaService.java | 21 ++ .../impl/ExamineResultServiceImpl.java | 74 +++++ .../impl/ReagentFormulaServiceImpl.java | 35 +++ .../lims/utils/DataStructureAdapter.java | 239 ++++++++++++++++ .../lims/utils/FormulaValidationTool.java | 234 ++++++++++++++++ 11 files changed, 944 insertions(+) create mode 100644 lab-service-api/lab-lims-api/src/main/java/org/springblade/lims/entry/ReagentFormula.java create mode 100644 lab-service/lab-lims/src/main/java/org/springblade/lims/controller/ReagentFormulaController.java create mode 100644 lab-service/lab-lims/src/main/java/org/springblade/lims/mapper/ReagentFormulaMapper.java create mode 100644 lab-service/lab-lims/src/main/java/org/springblade/lims/service/IReagentFormulaService.java create mode 100644 lab-service/lab-lims/src/main/java/org/springblade/lims/service/impl/ReagentFormulaServiceImpl.java create mode 100644 lab-service/lab-lims/src/main/java/org/springblade/lims/utils/DataStructureAdapter.java create mode 100644 lab-service/lab-lims/src/main/java/org/springblade/lims/utils/FormulaValidationTool.java diff --git a/lab-service-api/lab-lims-api/src/main/java/org/springblade/lims/entry/ReagentFormula.java b/lab-service-api/lab-lims-api/src/main/java/org/springblade/lims/entry/ReagentFormula.java new file mode 100644 index 0000000..93a0220 --- /dev/null +++ b/lab-service-api/lab-lims-api/src/main/java/org/springblade/lims/entry/ReagentFormula.java @@ -0,0 +1,37 @@ +package org.springblade.lims.entry; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import org.springblade.core.mp.base.BaseEntity; + +import java.io.Serializable; + +/** + * 试剂公式 + * + * @author blade + * @since 2026-05-27 + */ +@Data +@SuppressWarnings("all") +@TableName("f_reagent_formula") +public class ReagentFormula extends BaseEntity implements Serializable { + + private Long id; + + private Long reagentId; + + private String name; + + private String expression; + + private String description; + + private Integer enabled; + + @TableField(exist = false) + private String reagentName; + + private static final long serialVersionUID = 1L; +} diff --git a/lab-service/lab-lims/pom.xml b/lab-service/lab-lims/pom.xml index f1d939c..c34a01e 100644 --- a/lab-service/lab-lims/pom.xml +++ b/lab-service/lab-lims/pom.xml @@ -28,6 +28,13 @@ 3.2.0 + + + com.googlecode.aviator + aviator + 5.3.3 + + org.drools diff --git a/lab-service/lab-lims/src/main/java/org/springblade/lims/controller/ExamineResultController.java b/lab-service/lab-lims/src/main/java/org/springblade/lims/controller/ExamineResultController.java index 7fbe4c5..bac4c26 100644 --- a/lab-service/lab-lims/src/main/java/org/springblade/lims/controller/ExamineResultController.java +++ b/lab-service/lab-lims/src/main/java/org/springblade/lims/controller/ExamineResultController.java @@ -79,6 +79,18 @@ public class ExamineResultController extends BladeController { return service.excel(file, examineId, reagentId); } + /** + * 解析数据 + 试剂公式自动计算结果 + *

+ * 与 /excel 接口参数一致,但会根据 reagentId 查找已启用的 ReagentFormula, + * 使用 Aviator 引擎逐样品计算结果,覆盖原始 result 字段。 + * 未配置公式时降级为 /excel 的原始计算逻辑。 + */ + @PostMapping("/excelWithFormula") + public R excelWithFormula(MultipartFile file, String examineId, String reagentId) throws Exception { + return service.excelWithFormula(file, examineId, reagentId); + } + /** * PCRExcel解析数据 */ diff --git a/lab-service/lab-lims/src/main/java/org/springblade/lims/controller/ReagentFormulaController.java b/lab-service/lab-lims/src/main/java/org/springblade/lims/controller/ReagentFormulaController.java new file mode 100644 index 0000000..589fbda --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/lims/controller/ReagentFormulaController.java @@ -0,0 +1,255 @@ +package org.springblade.lims.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.AllArgsConstructor; +import org.springblade.core.boot.ctrl.BladeController; +import org.springblade.core.mp.support.Condition; +import org.springblade.core.mp.support.Query; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.Func; +import org.springblade.lims.entry.Reagent; +import org.springblade.lims.entry.ReagentFormula; +import org.springblade.lims.service.IReagentFormulaService; +import org.springblade.lims.service.IReagentService; +import org.springblade.lims.utils.FormulaValidationTool; +import org.springframework.web.bind.annotation.*; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static java.util.stream.Collectors.toMap; + +/** + * 试剂公式维护控制器 + * + * @author blade + * @since 2026-05-27 + */ +@RestController +@AllArgsConstructor +@RequestMapping("/reagentFormula") +@Api(value = "试剂公式维护", tags = "试剂公式维护") +public class ReagentFormulaController extends BladeController { + + private final IReagentFormulaService service; + private final IReagentService reagentService; + + /** + * 分页查询 + */ + @GetMapping("/list") + @ApiOperation(value = "分页查询", notes = "分页查询试剂公式") + public R> list(ReagentFormula entry, Query query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (entry.getName() != null && !entry.getName().isEmpty()) { + wrapper.like(ReagentFormula::getName, entry.getName()); + } + wrapper.orderByDesc(ReagentFormula::getCreateTime); + IPage page = service.page(Condition.getPage(query), wrapper); + + // populate reagent names + List records = page.getRecords(); + if (records != null && !records.isEmpty()) { + Set reagentIds = records.stream() + .map(ReagentFormula::getReagentId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (!reagentIds.isEmpty()) { + Map nameMap = reagentService.listByIds(reagentIds).stream() + .collect(toMap(Reagent::getId, Reagent::getName, (a, b) -> a)); + records.forEach(f -> f.setReagentName(nameMap.get(f.getReagentId()))); + } + } + + return R.data(page); + } + + /** + * 新增 + */ + @PostMapping("/insert") + @ApiOperation(value = "新增", notes = "新增试剂公式") + public R insert(@RequestBody ReagentFormula entry) { + entry.setCreateTime(new Date()); + entry.setUpdateTime(new Date()); + return R.data(service.save(entry)); + } + + /** + * 修改 + */ + @PostMapping("/update") + @ApiOperation(value = "修改", notes = "修改试剂公式") + public R update(@RequestBody ReagentFormula entry) { + entry.setUpdateTime(new Date()); + return R.data(service.updateById(entry)); + } + + /** + * 逻辑删除 + */ + @PostMapping("/deleteByIds") + @ApiOperation(value = "逻辑删除", notes = "传入ids逻辑删除") + public R deleteByIds(@ApiParam(value = "主键集合", required = true) @RequestParam String ids) { + return R.status(service.deleteLogic(Func.toLongList(ids))); + } + + /** + * 公式校验 + */ + @PostMapping("/validate") + @ApiOperation(value = "公式校验", notes = "使用Aviator引擎校验公式语法和安全") + public R validateFormula(@RequestBody Map body) { + String expression = body.get("expression"); + if (expression == null || expression.trim().isEmpty()) { + return R.fail("公式表达式不能为空"); + } + FormulaValidationTool.ValidationResult result = FormulaValidationTool.validateSafe(expression); + Map data = new HashMap<>(3); + data.put("valid", result.isValid()); + data.put("message", result.getMessage()); + data.put("variables", result.getVariables()); + return R.data(data); + } + + /** + * 获取试剂关联的可用变量 + * 从 Reagent.resultDeterminationMethod 中语义提取 + */ + @GetMapping("/variables") + @ApiOperation(value = "获取可用变量", notes = "根据试剂ID获取公式可用变量") + public R getVariables(@ApiParam(value = "试剂ID") @RequestParam Long reagentId) { + Reagent reagent = reagentService.getById(reagentId); + if (reagent == null) { + return R.data(new ArrayList<>()); + } + + String method = reagent.getResultDeterminationMethod(); + List> variables = extractVariables(method); + + return R.data(variables); + } + + /** + * 获取试剂列表(下拉选择用) + */ + @GetMapping("/getReagentList") + @ApiOperation(value = "获取试剂列表", notes = "获取全部试剂用于下拉选择") + public R> getReagentList() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Reagent::getIsDeleted, 0); + wrapper.orderByAsc(Reagent::getName); + List list = reagentService.list(wrapper); + return R.data(list); + } + + /** + * 从结果判定方法文本中提取变量 + */ + private List> extractVariables(String methodText) { + List> variables = new ArrayList<>(); + if (methodText == null || methodText.isEmpty()) { + // 返回默认的常用变量 + return getDefaultVariables(); + } + + // 尝试解析为JSON + try { + com.alibaba.fastjson.JSONObject json = com.alibaba.fastjson.JSON.parseObject(methodText); + if (json.containsKey("variables")) { + return json.getJSONArray("variables").stream() + .map(obj -> { + com.alibaba.fastjson.JSONObject v = (com.alibaba.fastjson.JSONObject) obj; + Map map = new HashMap<>(3); + map.put("name", v.getString("name")); + map.put("description", v.getString("description")); + map.put("type", v.getString("type")); + return map; + }) + .collect(Collectors.toList()); + } + } catch (Exception ignored) { + // 不是JSON格式,继续文本解析 + } + + // 从文本中提取常见参数名 + Set foundNames = new LinkedHashSet<>(); + for (String[] var : COMMON_METHOD_VARIABLES) { + if (methodText.contains(var[0]) || methodText.contains(var[1])) { + foundNames.add(var[0]); + } + } + + // 额外用正则匹配变量模式 + Pattern pattern = Pattern.compile("([ODSPKQ]+[值率比]?|阻断率|[^,。;:\n\r]+?值)"); + Matcher matcher = pattern.matcher(methodText); + while (matcher.find()) { + String match = matcher.group().trim(); + if (match.length() <= 15 && !match.matches(".*[0-9]{2,}.*")) { + foundNames.add(match); + } + } + + if (!foundNames.isEmpty()) { + for (String name : foundNames) { + Map v = new HashMap<>(3); + v.put("name", name); + v.put("description", name); + v.put("type", "number"); + variables.add(v); + } + return variables; + } + + return getDefaultVariables(); + } + + /** + * 常用方法参数对照表 + */ + private static final String[][] COMMON_METHOD_VARIABLES = { + {"OD值", "OD450"}, + {"S_P值", "S/P"}, + {"PI值", "阻断率"}, + {"阳性数", "阳性"}, + {"检测总数", "总数"}, + {"浓度", "浓度"}, + {"稀释倍数", "稀释"}, + {"阈值", "阈值"}, + {"S_N值", "S/N"}, + {"KQ值", "KQ"}, + {"阴性对照OD", "阴性对照"}, + {"阳性对照OD", "阳性对照"}, + }; + + /** + * 默认变量列表 + */ + private List> getDefaultVariables() { + List> variables = new ArrayList<>(); + String[][] defaults = { + {"OD值", "光密度值", "number"}, + {"S_P值", "S/P比值", "number"}, + {"PI值", "抑制百分比", "number"}, + {"阳性数", "阳性样品数", "number"}, + {"检测总数", "检测样品总数", "number"}, + {"浓度", "样品浓度", "number"}, + {"稀释倍数", "样品稀释倍数", "number"}, + {"阈值", "自定义阈值", "number"}, + }; + for (String[] def : defaults) { + Map v = new HashMap<>(3); + v.put("name", def[0]); + v.put("description", def[1]); + v.put("type", def[2]); + variables.add(v); + } + return variables; + } +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/lims/mapper/ReagentFormulaMapper.java b/lab-service/lab-lims/src/main/java/org/springblade/lims/mapper/ReagentFormulaMapper.java new file mode 100644 index 0000000..cb3654d --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/lims/mapper/ReagentFormulaMapper.java @@ -0,0 +1,13 @@ +package org.springblade.lims.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.springblade.lims.entry.ReagentFormula; + +/** + * 试剂公式 Mapper + * + * @author blade + * @since 2026-05-27 + */ +public interface ReagentFormulaMapper extends BaseMapper { +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/lims/service/IExamineResultService.java b/lab-service/lab-lims/src/main/java/org/springblade/lims/service/IExamineResultService.java index 2b64241..bbb9382 100644 --- a/lab-service/lab-lims/src/main/java/org/springblade/lims/service/IExamineResultService.java +++ b/lab-service/lab-lims/src/main/java/org/springblade/lims/service/IExamineResultService.java @@ -28,6 +28,23 @@ public interface IExamineResultService extends BaseService { R excel(MultipartFile file, String examineId, String reagentId) throws Exception; + /** + * 解析Excel并执行试剂公式计算结果(替换 /excel 的逻辑) + *

+ * 解析流程: + * 1. 调用对应 parser 解析 Excel 原始数据 + * 2. 根据 reagentId 查询启用的 ReagentFormula + * 3. 有公式 → 用 Aviator 逐样品计算结果,覆盖 result 字段 + * 4. 无公式 → 保留 parser 原始计算结果(降级兼容) + * 5. 将最终结果存入 f_examine_result + * + * @param file 上传的 Excel 文件 + * @param examineId 检验ID + * @param reagentId 试剂ID(用于查找公式) + * @return 解析结果(与 /excel 返回结构一致) + */ + R excelWithFormula(MultipartFile file, String examineId, String reagentId) throws Exception; + void exportCurrExamineTemplate(HttpServletResponse response, String id); String handleNum(String id, Integer number); diff --git a/lab-service/lab-lims/src/main/java/org/springblade/lims/service/IReagentFormulaService.java b/lab-service/lab-lims/src/main/java/org/springblade/lims/service/IReagentFormulaService.java new file mode 100644 index 0000000..2835db6 --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/lims/service/IReagentFormulaService.java @@ -0,0 +1,21 @@ +package org.springblade.lims.service; + +import org.springblade.core.mp.base.BaseService; +import org.springblade.lims.entry.ReagentFormula; + +/** + * 试剂公式 Service 接口 + * + * @author blade + * @since 2026-05-27 + */ +public interface IReagentFormulaService extends BaseService { + + /** + * 根据试剂ID查找已启用的公式 + * + * @param reagentId 试剂ID + * @return 启用的公式,不存在返回 null + */ + ReagentFormula findEnabledByReagentId(Long reagentId); +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/lims/service/impl/ExamineResultServiceImpl.java b/lab-service/lab-lims/src/main/java/org/springblade/lims/service/impl/ExamineResultServiceImpl.java index 6ce9ef6..a895779 100644 --- a/lab-service/lab-lims/src/main/java/org/springblade/lims/service/impl/ExamineResultServiceImpl.java +++ b/lab-service/lab-lims/src/main/java/org/springblade/lims/service/impl/ExamineResultServiceImpl.java @@ -17,6 +17,8 @@ import org.springblade.lims.entry.*; import org.springblade.lims.excel.*; import org.springblade.lims.mapper.ExamineResultMapper; import org.springblade.lims.service.*; +import org.springblade.lims.utils.DataStructureAdapter; +import org.springblade.lims.utils.FormulaValidationTool; import org.springblade.resource.enums.SysTypeEnum; import org.springblade.resource.feign.IMessageClient; import org.springblade.system.feign.ISysClient; @@ -32,8 +34,11 @@ import java.text.DecimalFormat; import java.util.*; import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; + @Service @AllArgsConstructor +@Slf4j public class ExamineResultServiceImpl extends BaseServiceImpl implements IExamineResultService { private final IExamineService examineService; @@ -64,6 +69,8 @@ public class ExamineResultServiceImpl extends BaseServiceImpl { + try { + Object result = FormulaValidationTool.executeSafe(expression, env); + return result != null ? result.toString() : null; + } catch (Exception e) { + log.error("公式执行失败: expr={}, value={}, env={}", + expression, value, env, e); + return null; // 单个样品失败 → 保留原值 + } + }); + formulaApplied = true; + } catch (Exception e) { + log.error("公式遍历处理异常: examineId={}, reagentId={}", examineId, reagentId, e); + } + + // 5. 保存最终结果(含公式计算后的数据)到 f_examine_result + if (formulaApplied) { + String jsonData = JSON.toJSONString(data); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ExamineResult::getExamineId, examineId); + ExamineResult er = getOne(wrapper); + if (er != null) { + er.setOriginRecordData(jsonData); + er.setOriginRecordResult(jsonData); + er.setExamineDataArr(jsonData); + this.updateById(er); + } else { + ExamineResult newEr = new ExamineResult(); + newEr.setExamineId(Long.valueOf(examineId)); + newEr.setOriginRecordData(jsonData); + newEr.setOriginRecordResult(jsonData); + newEr.setExamineDataArr(jsonData); + this.save(newEr); + } + } + + return parseResult; + } + @Override public void exportCurrExamineTemplate(HttpServletResponse response, String id) { Examine examine = examineService.getById(id); diff --git a/lab-service/lab-lims/src/main/java/org/springblade/lims/service/impl/ReagentFormulaServiceImpl.java b/lab-service/lab-lims/src/main/java/org/springblade/lims/service/impl/ReagentFormulaServiceImpl.java new file mode 100644 index 0000000..3927e64 --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/lims/service/impl/ReagentFormulaServiceImpl.java @@ -0,0 +1,35 @@ +package org.springblade.lims.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.AllArgsConstructor; +import org.springblade.core.mp.base.BaseServiceImpl; +import org.springblade.lims.entry.ReagentFormula; +import org.springblade.lims.mapper.ReagentFormulaMapper; +import org.springblade.lims.service.IReagentFormulaService; +import org.springframework.stereotype.Service; + +/** + * 试剂公式 Service 实现 + * + * @author blade + * @since 2026-05-27 + */ +@Service +@AllArgsConstructor +public class ReagentFormulaServiceImpl extends BaseServiceImpl implements IReagentFormulaService { + + private final ReagentFormulaMapper mapper; + + @Override + public ReagentFormula findEnabledByReagentId(Long reagentId) { + if (reagentId == null) { + return null; + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ReagentFormula::getReagentId, reagentId); + wrapper.eq(ReagentFormula::getEnabled, 1); + wrapper.eq(ReagentFormula::getIsDeleted, 0); + wrapper.last("LIMIT 1"); + return mapper.selectOne(wrapper); + } +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/lims/utils/DataStructureAdapter.java b/lab-service/lab-lims/src/main/java/org/springblade/lims/utils/DataStructureAdapter.java new file mode 100644 index 0000000..f02a60d --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/lims/utils/DataStructureAdapter.java @@ -0,0 +1,239 @@ +package org.springblade.lims.utils; + +import org.springblade.lims.excel.BiochemicalIdentificationExcel; +import org.springblade.lims.excel.ExamineTemplate2Excel; +import org.springblade.lims.excel.XN2Excel; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 数据结构适配器 + *

+ * 将不同 parser(ptExcel、blsExcel、xnExcel、ktyExcel、shjdExcel)返回的异构数据结构 + * 统一为「遍历每个样品 → 执行回调」的模式,用于公式注入。 + *

+ * 每种 parser 的返回值结构: + *

+ * ptExcel (inputMode=1,4) → Map<String, List<Map<String, Map<String, Object>>>>
+ * blsExcel (inputMode=2,6,7) → List<ExamineTemplate2Excel>
+ * ktyExcel (inputMode=3) → List<Map<String, Object>>
+ * xnExcel (inputMode=5) → List<XN2Excel>
+ * shjdExcel (inputMode=8) → List<BiochemicalIdentificationExcel>
+ * 
+ * + * @author blade + * @since 2026-05-27 + */ +public final class DataStructureAdapter { + + private DataStructureAdapter() { + } + + /** + * 样品回调接口。 + *

+ * 实现类接收该样品的 primary value 和完整的上下文 env, + * 返回公式计算后的 result 字符串(如 "阳性"、"阴性"、"可疑")。 + */ + @FunctionalInterface + public interface SampleCallback { + /** + * @param value 样品主值(OD、CT、titer、凝集值等) + * @param env 公式可用的变量环境,包含 value 和其他上下文 + * @return 计算结果字符串,返回 null 或空字符串则保留原值 + */ + String apply(String value, Map env); + } + + // ===================== 统一入口 ===================== + + /** + * 根据 inputMode 分发到对应的处理器。 + * + * @param data parser 返回的原始数据结构 + * @param inputMode 检测模式(ExamineWay.inputMode) + * @param callback 样品回调 + * @param 数据结构泛型 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public static void visitSamples(T data, int inputMode, SampleCallback callback) { + switch (inputMode) { + case 1: // ELISA 常规板 + case 4: // PCR / 荧光PCR + visitPtExcel((Map>>>) data, callback); + break; + case 2: // 虎红平板凝集 + case 6: // 试管凝集(微量法) + case 7: // 虎红平板(另一种) + visitBlsExcel((List) data, callback); + break; + case 3: // 口蹄疫 + visitKtyExcel((List>) data, callback); + break; + case 5: // 血凝 + visitXnExcel((List) data, callback); + break; + case 8: // 菌落生长 + visitShjdExcel((List) data, callback); + break; + default: + // 未知 inputMode,不做处理 + break; + } + } + + // ===================== ptExcel (inputMode=1, 4) ===================== + + /** + * ptExcel 返回结构: + * Map<组名, List<Map<孔位, Map<字段, 值>>>> + *

+ * 每个样品是 Map<String, Object>,包含 keys: value, originResult, result, num, order + */ + @SuppressWarnings("unchecked") + private static void visitPtExcel(Map>>> data, SampleCallback callback) { + if (data == null) return; + + for (Map.Entry>>> groupEntry : data.entrySet()) { + List>> rows = groupEntry.getValue(); + if (rows == null) continue; + + for (Map> row : rows) { + if (row == null) continue; + + for (Map.Entry> wellEntry : row.entrySet()) { + Map wellData = wellEntry.getValue(); + if (wellData == null) continue; + + Object valueObj = wellData.get("value"); + if (valueObj == null) continue; + + String value = valueObj.toString(); + if (value.isEmpty() || "/".equals(value)) continue; + + // 提供完整的 wellData 作为公式 env + Map env = new HashMap<>(wellData); + env.put("value", value); + + String result = callback.apply(value, env); + if (result != null && !result.isEmpty()) { + wellData.put("result", result); + } + } + } + } + } + + // ===================== blsExcel (inputMode=2, 6, 7) ===================== + + /** + * blsExcel 返回 List<ExamineTemplate2Excel> + * 每个元素有: experieNum, value, result + */ + private static void visitBlsExcel(List data, SampleCallback callback) { + if (data == null) return; + + for (ExamineTemplate2Excel item : data) { + if (item == null) continue; + + String value = item.getValue(); + if (value == null || value.isEmpty()) continue; + + Map env = new HashMap<>(); + env.put("experieNum", item.getExperieNum()); + env.put("value", value); + + String result = callback.apply(value, env); + if (result != null && !result.isEmpty()) { + item.setResult(result); + } + } + } + + // ===================== ktyExcel (inputMode=3) ===================== + + /** + * ktyExcel(单板模式)返回 List<Map<String, Object>> + * 每个 Map 包含 keys: log2, result, order, experieNum 等 + *

+ * 变量名使用 "log2"(与 ptExcel 的 "value" 区分) + */ + private static void visitKtyExcel(List> data, SampleCallback callback) { + if (data == null) return; + + for (Map item : data) { + if (item == null) continue; + + // ktyExcel 主值是 log2 + Object valueObj = item.get("log2"); + if (valueObj == null) continue; + + String value = valueObj.toString(); + if (value.isEmpty()) continue; + + Map env = new HashMap<>(item); + // 确保 env 中有 value 别名 + env.put("value", value); + + String result = callback.apply(value, env); + if (result != null && !result.isEmpty()) { + item.put("result", result); + } + } + } + + // ===================== xnExcel (inputMode=5) ===================== + + /** + * xnExcel 返回 List<XN2Excel> + * 每个元素有: experieNum, ctValue, result + */ + private static void visitXnExcel(List data, SampleCallback callback) { + if (data == null) return; + + for (XN2Excel item : data) { + if (item == null) continue; + + String ctValue = item.getCtValue(); + if (ctValue == null || ctValue.isEmpty()) continue; + + Map env = new HashMap<>(); + env.put("experieNum", item.getExperieNum()); + env.put("ctValue", ctValue); + env.put("value", ctValue); // 兼容 value 变量名 + + String result = callback.apply(ctValue, env); + if (result != null && !result.isEmpty()) { + item.setResult(result); + } + } + } + + // ===================== shjdExcel (inputMode=8) ===================== + + /** + * shjdExcel 返回 List<BiochemicalIdentificationExcel> + * 每个元素有: experieNum, value, result + */ + private static void visitShjdExcel(List data, SampleCallback callback) { + if (data == null) return; + + for (BiochemicalIdentificationExcel item : data) { + if (item == null) continue; + + String value = item.getValue(); + if (value == null || value.isEmpty()) continue; + + Map env = new HashMap<>(); + env.put("experieNum", item.getExperieNum()); + env.put("value", value); + + String result = callback.apply(value, env); + if (result != null && !result.isEmpty()) { + item.setResult(result); + } + } + } +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/lims/utils/FormulaValidationTool.java b/lab-service/lab-lims/src/main/java/org/springblade/lims/utils/FormulaValidationTool.java new file mode 100644 index 0000000..b1f005b --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/lims/utils/FormulaValidationTool.java @@ -0,0 +1,234 @@ +package org.springblade.lims.utils; + +import com.googlecode.aviator.AviatorEvaluator; +import com.googlecode.aviator.AviatorEvaluatorInstance; +import com.googlecode.aviator.Expression; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Aviator 公式校验工具类 + *

+ * 提供公式语法校验和安全校验功能: + * - 语法检查(编译) + * - 安全校验(长度限制、危险类调用阻止) + * - 变量提取 + * + * @author blade + * @since 2026-05-27 + */ +public class FormulaValidationTool { + + /** + * 公式最大长度 + */ + private static final int MAX_EXPRESSION_LENGTH = 500; + + /** + * 危险关键词列表,禁止在公式中出现 + */ + private static final String[] DANGEROUS_KEYWORDS = { + "Runtime", "Process", "System", "ClassLoader", "Thread", "Socket", + "ProcessBuilder", "File", "FileOutputStream", "FileInputStream", + "exec", "Runtime.getRuntime", "ProcessHandle" + }; + + /** + * 校验结果 + */ + public static class ValidationResult { + private boolean valid; + private String message; + private List variables; + + public ValidationResult() {} + + public ValidationResult(boolean valid, String message, List variables) { + this.valid = valid; + this.message = message; + this.variables = variables; + } + + public boolean isValid() { return valid; } + public void setValid(boolean valid) { this.valid = valid; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public List getVariables() { return variables; } + public void setVariables(List variables) { this.variables = variables; } + } + + /** + * 公式语法校验(仅编译检查,不执行) + * + * @param expression 公式表达式 + * @return 校验结果 + */ + public static ValidationResult validate(String expression) { + ValidationResult result = new ValidationResult(); + try { + // 编译表达式检查语法 + Expression compiledExpr = AviatorEvaluator.compile(expression); + + // 提取变量名 + List varNames = compiledExpr.getVariableNames(); + result.setVariables(varNames); + + result.setValid(true); + result.setMessage("公式语法校验通过"); + } catch (Exception e) { + result.setValid(false); + result.setMessage("公式语法错误: " + e.getMessage()); + } + return result; + } + + /** + * 安全校验(长度、危险类 + 语法检查) + * + * @param expression 公式表达式 + * @return 校验结果 + */ + public static ValidationResult validateSafe(String expression) { + // 1. 长度校验 + if (expression == null || expression.isEmpty()) { + ValidationResult r = new ValidationResult(); + r.setValid(false); + r.setMessage("公式不能为空"); + return r; + } + + if (expression.length() > MAX_EXPRESSION_LENGTH) { + ValidationResult r = new ValidationResult(); + r.setValid(false); + r.setMessage("公式超过最大长度限制(" + MAX_EXPRESSION_LENGTH + ")"); + return r; + } + + // 2. 危险关键词校验 + for (String keyword : DANGEROUS_KEYWORDS) { + if (expression.contains(keyword)) { + ValidationResult r = new ValidationResult(); + r.setValid(false); + r.setMessage("公式包含危险类调用(" + keyword + "),已禁止"); + return r; + } + } + + // 3. 语法校验 + final ValidationResult[] result = {null}; + final String[] errorMsg = {null}; + CountDownLatch latch = new CountDownLatch(1); + + // 使用独立线程执行编译,防止死循环或长时间阻塞 + Thread compileThread = new Thread(() -> { + try { + result[0] = validate(expression); + } catch (Exception e) { + errorMsg[0] = e.getMessage(); + } finally { + latch.countDown(); + } + }); + compileThread.setDaemon(true); + compileThread.start(); + + try { + // 设置超时时间 5 秒 + boolean completed = latch.await(5, TimeUnit.SECONDS); + if (!completed) { + compileThread.interrupt(); + ValidationResult r = new ValidationResult(); + r.setValid(false); + r.setMessage("公式编译超时,请检查公式逻辑"); + return r; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + ValidationResult r = new ValidationResult(); + r.setValid(false); + r.setMessage("公式校验被中断"); + return r; + } + + if (result[0] != null) { + return result[0]; + } + + ValidationResult r = new ValidationResult(); + r.setValid(false); + r.setMessage("公式校验失败: " + errorMsg[0]); + return r; + } + + // ========== Formula Execution ========== + + /** + * 执行公式(直接执行,不带超时保护) + * + * @param expression 公式表达式 + * @param env 变量环境 + * @return 公式执行结果(通常是 String 或 Number) + */ + public static Object execute(String expression, Map env) { + try { + Expression compiledExpr = AviatorEvaluator.compile(expression); + return compiledExpr.execute(env); + } catch (Exception e) { + throw new RuntimeException("公式执行失败: " + e.getMessage(), e); + } + } + + /** + * 执行公式(带超时保护,用于生产环境) + * + * @param expression 公式表达式 + * @param env 变量环境 + * @param timeoutMs 超时时间(毫秒),默认 5000 + * @return 公式执行结果(通常是 String 或 Number) + */ + public static Object executeSafe(String expression, Map env, long timeoutMs) { + final Object[] result = {null}; + final String[] errorMsg = {null}; + CountDownLatch latch = new CountDownLatch(1); + + Thread execThread = new Thread(() -> { + try { + Expression compiledExpr = AviatorEvaluator.compile(expression); + result[0] = compiledExpr.execute(env); + } catch (Exception e) { + errorMsg[0] = e.getMessage(); + } finally { + latch.countDown(); + } + }); + execThread.setDaemon(true); + execThread.start(); + + try { + boolean completed = latch.await(timeoutMs, TimeUnit.MILLISECONDS); + if (!completed) { + execThread.interrupt(); + throw new RuntimeException("公式执行超时(" + timeoutMs + "ms)"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("公式执行被中断", e); + } + + if (errorMsg[0] != null) { + throw new RuntimeException("公式执行失败: " + errorMsg[0]); + } + + return result[0]; + } + + /** + * 执行公式(带默认 5 秒超时保护) + */ + public static Object executeSafe(String expression, Map env) { + return executeSafe(expression, env, 5000L); + } +}