feat(inspection): 新增巡检仪表识别和人工判断功能

- 添加巡检仪表读数识别动作枚举和处理器
- 实现人工巡检判断任务创建功能
- 扩展巡检报警监听服务支持工作流上下文
- 新增统一巡检结果表和媒体证据表
- 更新巡检告警实体和数据传输对象
- 扩展工作流执行事件传递更多节点信息
- 优化巡检报警入库服务支持订阅模式
This commit is contained in:
lixiaolong 2026-07-24 13:43:03 +08:00
parent dfdf575932
commit 521e3e4e24
35 changed files with 2507 additions and 136 deletions

View File

@ -0,0 +1,126 @@
package com.cmvr.web.controller.inspection;
import com.cmvr.common.annotation.Log;
import com.cmvr.common.core.controller.BaseController;
import com.cmvr.common.core.domain.AjaxResult;
import com.cmvr.common.core.page.TableDataInfo;
import com.cmvr.common.enums.BusinessType;
import com.cmvr.common.utils.poi.ExcelUtil;
import com.cmvr.inspection.domain.dto.InspectionResultQuery;
import com.cmvr.inspection.domain.dto.InspectionResultReviewRequest;
import com.cmvr.inspection.domain.vo.InspectionResultVo;
import com.cmvr.inspection.service.IInspectionResultService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 巡检结果人工复核和任务报表接口
*/
@RestController
@RequestMapping("/inspection/result")
@Api(tags = "智能巡检--巡检结果", description = "巡检结果查询、人工复核、三级告警统计和报表导出")
@ApiResponses({
@ApiResponse(code = 200, message = "请求处理成功"),
@ApiResponse(code = 400, message = "请求参数或状态不合法"),
@ApiResponse(code = 401, message = "未登录或登录已失效"),
@ApiResponse(code = 403, message = "无接口权限"),
@ApiResponse(code = 500, message = "系统内部异常")
})
@RequiredArgsConstructor
public class InspectionResultController extends BaseController
{
private final IInspectionResultService inspectionResultService;
@ApiOperation(value = "查询巡检结果列表",
notes = "分页查询PPE、仪表读数和人工判断结果查询条件均为可选结果按检测时间倒序。"
+ "返回rows元素为InspectionResultVo包含taskName任务名称和evidenceType主要证据类型"
+ "完整mediaList仅在详情接口返回")
@ApiImplicitParams({
@ApiImplicitParam(name = "pageNum", value = "页码从1开始", dataType = "int", paramType = "query", example = "1"),
@ApiImplicitParam(name = "pageSize", value = "每页数量", dataType = "int", paramType = "query", example = "10"),
@ApiImplicitParam(name = "taskInstanceId", value = "巡检任务实例数据库ID", dataType = "string", paramType = "query"),
@ApiImplicitParam(name = "taskId", value = "巡检任务ID", dataType = "string", paramType = "query"),
@ApiImplicitParam(name = "itemId", value = "检测项ID", dataType = "string", paramType = "query"),
@ApiImplicitParam(name = "resultType", value = "结果类型PPE、METER、MANUAL", dataType = "string", paramType = "query"),
@ApiImplicitParam(name = "resultStatus", value = "状态PENDING、NORMAL、ABNORMAL、RECOGNIZE_FAILED", dataType = "string", paramType = "query"),
@ApiImplicitParam(name = "alarmLevel", value = "告警级别1提示、2警告、3严重", dataType = "int", paramType = "query"),
@ApiImplicitParam(name = "resultName", value = "检查名称,支持模糊查询", dataType = "string", paramType = "query")
})
@PreAuthorize("@ss.hasPermi('inspection:result:list')")
@GetMapping("/list")
public TableDataInfo list(@ApiParam("巡检结果查询条件") InspectionResultQuery query)
{
startPage();
return getDataTable(inspectionResultService.selectResultList(query));
}
@ApiOperation(value = "查询巡检结果详情",
notes = "返回结构化巡检结果及按顺序排列的全部图片、视频证据",
response = InspectionResultVo.class)
@PreAuthorize("@ss.hasPermi('inspection:result:query')")
@GetMapping("/{id}")
public AjaxResult getInfo(@ApiParam(value = "巡检结果ID", required = true)
@PathVariable String id)
{
return success(inspectionResultService.selectResultById(id));
}
@ApiOperation(value = "提交人工复核结果",
notes = "仅PENDING或RECOGNIZE_FAILED状态可复核。ABNORMAL必须传alarmLevelreviewVersion用于并发控制")
@PreAuthorize("@ss.hasPermi('inspection:result:review')")
@Log(title = "巡检结果人工复核", businessType = BusinessType.UPDATE)
@PutMapping("/{id}/review")
public AjaxResult review(@ApiParam(value = "巡检结果ID", required = true)
@PathVariable String id,
@ApiParam(value = "人工复核内容", required = true)
@Validated @RequestBody InspectionResultReviewRequest request)
{
return success(inspectionResultService.review(id, request));
}
@ApiOperation(value = "查询巡检任务报表汇总",
notes = "返回summary统计和results明细。reviewCompleted=false表示仍有待复核或识别失败结果")
@PreAuthorize("@ss.hasPermi('inspection:result:report')")
@GetMapping("/report/{taskInstanceId}")
public AjaxResult report(@ApiParam(value = "巡检任务实例数据库ID", required = true)
@PathVariable String taskInstanceId)
{
AjaxResult result = AjaxResult.success();
result.put("summary", inspectionResultService.buildReportSummary(taskInstanceId));
InspectionResultQuery query = new InspectionResultQuery();
query.setTaskInstanceId(taskInstanceId);
result.put("results", inspectionResultService.selectResultList(query));
return result;
}
@ApiOperation(value = "导出巡检结果报表",
notes = "按查询条件导出Excel不传条件时导出全部巡检结果")
@PreAuthorize("@ss.hasPermi('inspection:result:export')")
@Log(title = "巡检结果", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response,
@ApiParam("导出筛选条件") InspectionResultQuery query)
{
List<InspectionResultVo> list = inspectionResultService.selectResultList(query);
ExcelUtil<InspectionResultVo> util = new ExcelUtil<>(InspectionResultVo.class);
util.exportExcel(response, list, "巡检结果数据");
}
}

View File

@ -156,8 +156,13 @@ public class TeFlowController extends BaseController {
}
@PostMapping("/action")
@ApiOperation("单个节点执行")
public AjaxResult actionExecute(@RequestBody FlowActionRequestVO flowActionRequestVO) {
@ApiOperation(value = "单个节点执行",
notes = "巡检仪表节点action=INSPECTION_METER_RECOGNIZEpayload使用InspectionMeterRecognizeConfigVO"
+ "人工判断节点action=INSPECTION_MANUAL_REVIEW_CREATEpayload使用InspectionManualReviewConfigVO。"
+ "该接口用于试调节点,不会生成正式巡检任务结果")
public AjaxResult actionExecute(
@io.swagger.annotations.ApiParam(value = "单节点动作和参数", required = true)
@RequestBody FlowActionRequestVO flowActionRequestVO) {
return AjaxResult.ok(flowActionExecutorService.actionExecute(flowActionRequestVO));
}

View File

@ -7,6 +7,11 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.cmvr.common.config.CmvrIotConfig;
import com.cmvr.test.model.vo.inspection.InspectionAlarmRuleVO;
import com.cmvr.test.model.vo.inspection.InspectionManualReviewConfigVO;
import com.cmvr.test.model.vo.inspection.InspectionMediaVO;
import com.cmvr.test.model.vo.inspection.InspectionMeterRecognizeConfigVO;
import com.fasterxml.classmate.TypeResolver;
import io.swagger.annotations.ApiOperation;
import io.swagger.models.auth.In;
import springfox.documentation.builders.ApiInfoBuilder;
@ -34,6 +39,10 @@ public class SwaggerConfig
@Autowired
private CmvrIotConfig cmvrIotConfig;
/** 用于将动态工作流payload对应的参数模型显式加入Swagger文档。 */
@Autowired
private TypeResolver typeResolver;
/** 是否开启swagger */
@Value("${swagger.enabled}")
private boolean enabled;
@ -62,6 +71,12 @@ public class SwaggerConfig
// 扫描所有 .apis(RequestHandlerSelectors.any())
.paths(PathSelectors.any())
.build()
// 工作流单节点接口使用JSONObject作为payload需显式注册巡检节点参数模型
.additionalModels(
typeResolver.resolve(InspectionMeterRecognizeConfigVO.class),
typeResolver.resolve(InspectionAlarmRuleVO.class),
typeResolver.resolve(InspectionManualReviewConfigVO.class),
typeResolver.resolve(InspectionMediaVO.class))
/* 设置安全模式swagger可以设置访问token */
.securitySchemes(securitySchemes())
.securityContexts(securityContexts())

View File

@ -13,7 +13,6 @@ import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@ -21,16 +20,14 @@ import java.util.concurrent.ConcurrentMap;
/**
* 巡检报警监听状态服务
*
* <p>工作流开始监听时根据终端ID解析终端IP并将 IP + 报警类型保存到内存
* cmvr_edge_ai 推送报警后巡检报警入库服务通过该服务判断是否需要保存
* 当前状态只在本 JVM 内有效应用重启后会清空</p>
* <p>监听条件仍然按边缘端IP和报警类型匹配同时保存工作流执行上下文
* 使异步上报的PPE事件能够归属到具体巡检任务检测项和节点</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class InspectionAlertListenService
{
/** 当前支持的 PPE 违规类型。 */
private static final Set<String> SUPPORTED_EVENT_TYPES;
static
@ -43,47 +40,59 @@ public class InspectionAlertListenService
private final IDeDeviceTerminalConfigService terminalConfigService;
/**
* key 为边缘端 gRPC IPvalue 为当前需要保存的报警类型集合
*/
private final ConcurrentMap<String, Set<String>> listenMap = new ConcurrentHashMap<>();
/** key=grpcIpvalue=当前IP下的工作流监听订阅。 */
private final ConcurrentMap<String, ConcurrentMap<String, ListenSubscription>> subscriptions =
new ConcurrentHashMap<>();
/**
* 开始监听指定终端的指定报警类型
*
* @param terminalId 终端ID
* @param eventTypes 报警类型支持 No-GloveNo-Helmet可多选
* @return 当前终端IP下正在监听的报警类型
*/
/** 兼容原有调用方式,创建一个不绑定工作流的监听。 */
public ListenState startListen(String terminalId, Collection<String> eventTypes)
{
String grpcIp = resolveTerminalIp(terminalId);
Set<String> normalizedTypes = normalizeEventTypes(eventTypes, true);
Set<String> target = listenMap.computeIfAbsent(grpcIp, key -> ConcurrentHashMap.newKeySet());
target.addAll(normalizedTypes);
log.info("开始监听巡检报警terminalId={}grpcIp={}eventTypes={}currentTypes={}",
terminalId, grpcIp, normalizedTypes, target);
return new ListenState(terminalId, grpcIp, snapshot(target));
return startListen(terminalId, eventTypes, null, null, null, null, null);
}
/**
* 结束监听指定终端的指定报警类型
*
* <p>移除不存在的 IP + 类型不抛异常只记录警告日志避免工作流重复清理时失败</p>
*
* @param terminalId 终端ID
* @param eventTypes 报警类型支持 No-GloveNo-Helmet可多选
* @return 当前终端IP下剩余正在监听的报警类型
*/
public ListenState stopListen(String terminalId, Collection<String> eventTypes)
public ListenState startListen(String terminalId, Collection<String> eventTypes,
String flowInstanceId, String taskId, String itemId,
String nodeId, String listenerKey)
{
String grpcIp = resolveTerminalIp(terminalId);
Set<String> normalizedTypes = normalizeEventTypes(eventTypes, true);
Set<String> current = listenMap.get(grpcIp);
ConcurrentMap<String, ListenSubscription> target =
subscriptions.computeIfAbsent(grpcIp, key -> new ConcurrentHashMap<>());
if (current == null)
for (String eventType : normalizedTypes)
{
ListenSubscription subscription = new ListenSubscription();
subscription.setSubscriptionId(buildSubscriptionId(flowInstanceId, itemId, listenerKey, eventType));
subscription.setTerminalId(terminalId);
subscription.setGrpcIp(grpcIp);
subscription.setEventType(eventType);
subscription.setFlowInstanceId(flowInstanceId);
subscription.setTaskId(taskId);
subscription.setItemId(itemId);
subscription.setNodeId(nodeId);
subscription.setListenerKey(StringUtils.trimToNull(listenerKey));
subscription.setStartedAt(System.currentTimeMillis());
target.put(subscription.getSubscriptionId(), subscription);
}
log.info("开始监听巡检报警terminalId={}grpcIp={}flowInstanceId={}itemId={}eventTypes={}",
terminalId, grpcIp, flowInstanceId, itemId, normalizedTypes);
return new ListenState(terminalId, grpcIp, currentTypes(target));
}
/** 兼容原有调用方式移除该IP下指定类型的全部监听。 */
public ListenState stopListen(String terminalId, Collection<String> eventTypes)
{
return stopListen(terminalId, eventTypes, null, null, null);
}
public ListenState stopListen(String terminalId, Collection<String> eventTypes,
String flowInstanceId, String itemId, String listenerKey)
{
String grpcIp = resolveTerminalIp(terminalId);
Set<String> normalizedTypes = normalizeEventTypes(eventTypes, true);
ConcurrentMap<String, ListenSubscription> current = subscriptions.get(grpcIp);
if (current == null || current.isEmpty())
{
log.warn("结束监听巡检报警时未找到IP监听状态terminalId={}grpcIp={}eventTypes={}",
terminalId, grpcIp, normalizedTypes);
@ -92,67 +101,124 @@ public class InspectionAlertListenService
for (String eventType : normalizedTypes)
{
if (!current.remove(eventType))
int removed = removeMatching(current, eventType, flowInstanceId, itemId, listenerKey);
if (removed == 0)
{
log.warn("结束监听巡检报警时事件类型不存在terminalId={}grpcIp={}eventType={}",
terminalId, grpcIp, eventType);
log.warn("结束监听巡检报警时未找到匹配订阅terminalId={}grpcIp={}flowInstanceId={}itemId={}eventType={}",
terminalId, grpcIp, flowInstanceId, itemId, eventType);
}
}
if (current.isEmpty())
{
listenMap.remove(grpcIp, current);
subscriptions.remove(grpcIp, current);
}
log.info("结束监听巡检报警terminalId={}grpcIp={}eventTypes={}remainingTypes={}",
terminalId, grpcIp, normalizedTypes, current);
return new ListenState(terminalId, grpcIp, snapshot(current));
List<String> remainingTypes = currentTypes(current);
log.info("结束监听巡检报警terminalId={}grpcIp={}flowInstanceId={}itemId={}remainingTypes={}",
terminalId, grpcIp, flowInstanceId, itemId, remainingTypes);
return new ListenState(terminalId, grpcIp, remainingTypes);
}
/**
* 判断报警是否命中当前监听条件
*
* @param grpcIp 边缘端 gRPC IP
* @param labels 报警标签列表来自 payload.labels
* @return true 表示允许入库false 表示忽略
*/
public boolean shouldStore(String grpcIp, Collection<String> labels)
/** 返回命中IP和任一标签的全部活动订阅。 */
public List<ListenSubscription> matchSubscriptions(String grpcIp, Collection<String> labels)
{
String normalizedIp = StringUtils.trimToNull(grpcIp);
if (normalizedIp == null)
{
log.warn("忽略巡检报警grpc_ip为空labels={}", labels);
return false;
return Collections.emptyList();
}
Set<String> listeningTypes = listenMap.get(normalizedIp);
if (listeningTypes == null || listeningTypes.isEmpty())
ConcurrentMap<String, ListenSubscription> current = subscriptions.get(normalizedIp);
if (current == null || current.isEmpty())
{
log.info("忽略巡检报警未开启该IP的报警监听grpcIp={}labels={}", normalizedIp, labels);
return false;
return Collections.emptyList();
}
Set<String> alertTypes = normalizeEventTypes(labels, false);
for (String alertType : alertTypes)
List<ListenSubscription> matched = new ArrayList<>();
for (ListenSubscription subscription : current.values())
{
if (listeningTypes.contains(alertType))
if (alertTypes.contains(subscription.getEventType()))
{
return true;
matched.add(subscription.copy());
}
}
if (matched.isEmpty())
{
log.info("忽略巡检报警事件类型未命中监听条件grpcIp={}labels={}listeningTypes={}",
normalizedIp, labels, listeningTypes);
return false;
normalizedIp, labels, currentTypes(current));
}
return matched;
}
public boolean shouldStore(String grpcIp, Collection<String> labels)
{
return !matchSubscriptions(grpcIp, labels).isEmpty();
}
/** 任务完成、失败或终止时清理该流程的全部监听。 */
public void clearByFlowInstanceId(String flowInstanceId)
{
String normalized = StringUtils.trimToNull(flowInstanceId);
if (normalized == null)
{
return;
}
int removed = 0;
for (ConcurrentMap.Entry<String, ConcurrentMap<String, ListenSubscription>> ipEntry : subscriptions.entrySet())
{
ConcurrentMap<String, ListenSubscription> current = ipEntry.getValue();
for (ConcurrentMap.Entry<String, ListenSubscription> entry : current.entrySet())
{
if (normalized.equals(entry.getValue().getFlowInstanceId()) && current.remove(entry.getKey(), entry.getValue()))
{
removed++;
}
}
if (current.isEmpty())
{
subscriptions.remove(ipEntry.getKey(), current);
}
}
if (removed > 0)
{
log.info("巡检任务结束清理报警监听flowInstanceId={}removed={}", normalized, removed);
}
}
/**
* 查询指定终端当前监听状态便于调试或前端展示
*/
public ListenState getListenState(String terminalId)
{
String grpcIp = resolveTerminalIp(terminalId);
return new ListenState(terminalId, grpcIp, snapshot(listenMap.get(grpcIp)));
return new ListenState(terminalId, grpcIp, currentTypes(subscriptions.get(grpcIp)));
}
private int removeMatching(ConcurrentMap<String, ListenSubscription> current, String eventType,
String flowInstanceId, String itemId, String listenerKey)
{
int removed = 0;
for (ConcurrentMap.Entry<String, ListenSubscription> entry : current.entrySet())
{
ListenSubscription subscription = entry.getValue();
boolean legacyRemove = StringUtils.isBlank(flowInstanceId);
boolean contextMatches = StringUtils.equals(flowInstanceId, subscription.getFlowInstanceId())
&& (StringUtils.isBlank(itemId) || StringUtils.equals(itemId, subscription.getItemId()))
&& (StringUtils.isBlank(listenerKey) || StringUtils.equals(listenerKey, subscription.getListenerKey()));
if (eventType.equals(subscription.getEventType()) && (legacyRemove || contextMatches)
&& current.remove(entry.getKey(), subscription))
{
removed++;
}
}
return removed;
}
private String buildSubscriptionId(String flowInstanceId, String itemId, String listenerKey, String eventType)
{
String flow = StringUtils.defaultIfBlank(flowInstanceId, "legacy");
String item = StringUtils.defaultIfBlank(itemId, "all");
String key = StringUtils.defaultIfBlank(listenerKey, "default");
return flow + "|" + item + "|" + key + "|" + eventType;
}
private String resolveTerminalIp(String terminalId)
@ -162,14 +228,12 @@ public class InspectionAlertListenService
{
throw new GlobalException("terminalId不能为空");
}
DeDeviceTerminalConfig terminalConfig =
terminalConfigService.selectDeDeviceTerminalConfigById(normalizedTerminalId);
if (terminalConfig == null)
{
throw new GlobalException("未找到终端配置terminalId=" + normalizedTerminalId);
}
String grpcIp = StringUtils.trimToNull(terminalConfig.getHost());
if (grpcIp == null)
{
@ -192,7 +256,6 @@ public class InspectionAlertListenService
}
}
}
if (failOnEmpty && result.isEmpty())
{
throw new GlobalException("eventTypes不能为空当前支持No-Glove、No-Helmet");
@ -207,7 +270,6 @@ public class InspectionAlertListenService
{
return null;
}
for (String supported : SUPPORTED_EVENT_TYPES)
{
if (supported.equalsIgnoreCase(value))
@ -215,23 +277,24 @@ public class InspectionAlertListenService
return supported;
}
}
log.warn("忽略不支持的巡检报警类型eventType={}supported={}", value, SUPPORTED_EVENT_TYPES);
return null;
}
private List<String> snapshot(Set<String> eventTypes)
private List<String> currentTypes(ConcurrentMap<String, ListenSubscription> current)
{
if (eventTypes == null || eventTypes.isEmpty())
if (current == null || current.isEmpty())
{
return Collections.emptyList();
}
return new ArrayList<>(eventTypes);
Set<String> values = new LinkedHashSet<>();
for (ListenSubscription subscription : current.values())
{
values.add(subscription.getEventType());
}
return new ArrayList<>(values);
}
/**
* 当前监听状态
*/
@Data
public static class ListenState
{
@ -239,4 +302,45 @@ public class InspectionAlertListenService
private final String grpcIp;
private final List<String> eventTypes;
}
@Data
public static class ListenSubscription
{
/** flowInstanceId、itemId、listenerKey、eventType组成的订阅唯一标识。 */
private String subscriptionId;
/** 工作流节点配置的终端ID。 */
private String terminalId;
/** 由终端配置解析出的gRPC IP也是PPE事件匹配IP。 */
private String grpcIp;
/** 当前订阅监听的PPE事件类型。 */
private String eventType;
/** 工作流运行实例ID用于关联巡检任务和结束时清理。 */
private String flowInstanceId;
/** 工作流编排任务ID。 */
private String taskId;
/** 当前巡检检测项ID。 */
private String itemId;
/** 启动监听的工作流节点ID。 */
private String nodeId;
/** 同一检测项存在多个监听器时使用的可选业务标识。 */
private String listenerKey;
/** 开始监听时间戳,单位毫秒。 */
private long startedAt;
private ListenSubscription copy()
{
ListenSubscription copy = new ListenSubscription();
copy.subscriptionId = subscriptionId;
copy.terminalId = terminalId;
copy.grpcIp = grpcIp;
copy.eventType = eventType;
copy.flowInstanceId = flowInstanceId;
copy.taskId = taskId;
copy.itemId = itemId;
copy.nodeId = nodeId;
copy.listenerKey = listenerKey;
copy.startedAt = startedAt;
return copy;
}
}
}

View File

@ -40,6 +40,18 @@ public class InspectionAlarm extends BaseEntity
@ApiModelProperty("任务执行实例ID")
private String taskInstanceId;
@ApiModelProperty("关联巡检结果ID")
private String resultId;
@ApiModelProperty("告警来源PPE、METER、MANUAL")
private String alarmSource;
@ApiModelProperty("检测项ID")
private String itemId;
@ApiModelProperty("工作流节点ID")
private String nodeId;
@Excel(name = "任务ID")
@ApiModelProperty("任务ID")
private String taskId;

View File

@ -0,0 +1,130 @@
package com.cmvr.inspection.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.cmvr.common.annotation.Excel;
import com.cmvr.common.core.domain.BaseEntity;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.util.Date;
/**
* 统一巡检结果承载PPE仪表读数和人工判断结果
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("inspection_result")
@ApiModel(value = "InspectionResult", description = "PPE识别、仪表读数识别和人工复核共用的巡检结果")
public class InspectionResult extends BaseEntity
{
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.INPUT)
@ApiModelProperty(value = "巡检结果主键", example = "8f3e1f3a2db84de2a84eebdf8951b86a")
private String id;
@Excel(name = "结果编码")
@ApiModelProperty(value = "巡检结果业务编码", example = "IR20260723143000123ABCDE")
private String resultCode;
@ApiModelProperty(value = "结果幂等键,平台内部使用", hidden = true)
private String dedupeKey;
@ApiModelProperty(value = "巡检任务实例数据库ID", example = "task-instance-id")
private String taskInstanceId;
@ApiModelProperty(value = "工作流运行实例ID", example = "flow-instance-id")
private String flowInstanceId;
@ApiModelProperty(value = "巡检任务ID", example = "inspection-task-id")
private String taskId;
@ApiModelProperty(value = "检测项ID", example = "detection-item-id")
private String itemId;
@ApiModelProperty(value = "工作流节点ID", example = "meter-node-1")
private String nodeId;
@ApiModelProperty(value = "工作流节点名称", example = "1号压力表读数识别")
private String nodeName;
@ApiModelProperty(value = "循环节点迭代路径JSON非循环节点通常为[]", example = "[1,2]")
private String iterationPath;
@Excel(name = "结果类型")
@ApiModelProperty(value = "结果类型", allowableValues = "PPE,METER,MANUAL", example = "METER")
private String resultType;
@Excel(name = "结果状态")
@ApiModelProperty(value = "结果状态", allowableValues = "PENDING,NORMAL,ABNORMAL,RECOGNIZE_FAILED", example = "ABNORMAL")
private String resultStatus;
@Excel(name = "检查名称")
@ApiModelProperty(value = "检查项显示名称", example = "1号压力表")
private String resultName;
@Excel(name = "数值")
@ApiModelProperty(value = "结构化仪表读数PPE和人工结果通常为空", example = "1.72")
private BigDecimal valueNumber;
@Excel(name = "结果内容")
@ApiModelProperty(value = "文本结果或原始识别值", example = "1.72")
private String valueText;
@Excel(name = "单位")
@ApiModelProperty(value = "仪表读数单位", example = "MPa")
private String unit;
@Excel(name = "告警级别")
@ApiModelProperty(value = "最终命中的最高告警级别1提示、2警告、3严重", allowableValues = "1,2,3", example = "2")
private Integer alarmLevel;
@ApiModelProperty(value = "告警描述", example = "压力过高")
private String alarmMessage;
@ApiModelProperty(value = "模型置信度范围0到1", example = "0.96")
private BigDecimal confidence;
@ApiModelProperty(value = "PPE边缘事件event_id", example = "event-abc")
private String sourceEventId;
@ApiModelProperty(value = "主要证据的永久MinIO地址")
private String evidenceUrl;
@ApiModelProperty(value = "主要证据类型与evidenceUrl同时写入",
allowableValues = "IMAGE,VIDEO", example = "IMAGE")
private String evidenceType;
@ApiModelProperty(value = "模型或边缘端原始返回内容")
private String rawResult;
@ApiModelProperty(value = "最终命中的告警规则JSON快照")
private String matchedRuleJson;
@ApiModelProperty(value = "本次执行使用的全部告警规则JSON快照")
private String ruleSnapshotJson;
@ApiModelProperty(value = "人工复核账号", example = "admin")
private String reviewer;
@ApiModelProperty(value = "人工复核时间")
private Date reviewTime;
@ApiModelProperty(value = "人工复核说明")
private String reviewRemark;
@ApiModelProperty(value = "人工复核乐观锁版本首次复核前为0", example = "0")
private Integer reviewVersion;
@ApiModelProperty(value = "异常结果关联的巡检告警ID")
private String alarmId;
@Excel(name = "检测时间", dateFormat = "yyyy-MM-dd HH:mm:ss")
@ApiModelProperty(value = "结果发生时间")
private Date occurredTime;
}

View File

@ -0,0 +1,43 @@
package com.cmvr.inspection.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 巡检结果关联的图片或视频证据
*
* <p>媒体内容保存在MinIO本表只保存永久访问地址不保存文件字节</p>
*/
@Data
@TableName("inspection_result_media")
@ApiModel(value = "InspectionResultMedia", description = "巡检结果关联的图片或视频证据")
public class InspectionResultMedia implements Serializable
{
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.INPUT)
@ApiModelProperty("媒体记录主键")
private String id;
@ApiModelProperty("关联巡检结果ID")
private String resultId;
@ApiModelProperty(value = "媒体类型", allowableValues = "IMAGE,VIDEO", example = "IMAGE")
private String mediaType;
@ApiModelProperty("MinIO永久媒体地址")
private String mediaUrl;
@ApiModelProperty(value = "同一巡检结果内的展示顺序", example = "0")
private Integer sortOrder;
@ApiModelProperty("记录创建时间")
private Date createTime;
}

View File

@ -0,0 +1,32 @@
package com.cmvr.inspection.domain.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/** 巡检结果列表和报表导出的查询条件。 */
@Data
@ApiModel(value = "InspectionResultQuery", description = "巡检结果查询条件,所有字段均为可选条件")
public class InspectionResultQuery
{
@ApiModelProperty("巡检任务实例数据库ID")
private String taskInstanceId;
@ApiModelProperty("巡检任务ID")
private String taskId;
@ApiModelProperty("检测项ID")
private String itemId;
@ApiModelProperty(value = "结果类型", allowableValues = "PPE,METER,MANUAL")
private String resultType;
@ApiModelProperty(value = "结果状态", allowableValues = "PENDING,NORMAL,ABNORMAL,RECOGNIZE_FAILED")
private String resultStatus;
@ApiModelProperty(value = "告警级别", allowableValues = "1,2,3")
private Integer alarmLevel;
@ApiModelProperty("检查名称,支持模糊查询")
private String resultName;
}

View File

@ -0,0 +1,32 @@
package com.cmvr.inspection.domain.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 人工复核提交参数
*
* <p>只能复核PENDING或RECOGNIZE_FAILED状态的结果提交ABNORMAL时必须指定alarmLevel</p>
*/
@Data
@ApiModel("巡检结果人工复核参数")
public class InspectionResultReviewRequest
{
@NotBlank(message = "resultStatus不能为空")
@ApiModelProperty(value = "复核结果", required = true,
allowableValues = "NORMAL,ABNORMAL", example = "ABNORMAL")
private String resultStatus;
@ApiModelProperty(value = "异常时必填1提示、2警告、3严重",
allowableValues = "1,2,3", example = "2")
private Integer alarmLevel;
@ApiModelProperty(value = "人工复核说明", example = "设备右侧存在漏油")
private String reviewRemark;
@ApiModelProperty(value = "当前reviewVersion用于防止多人重复复核首次通常为0", example = "0")
private Integer reviewVersion;
}

View File

@ -30,6 +30,18 @@ public class InspectionAlarmVo extends BaseEntity
@ApiModelProperty("id")
private String id;
@ApiModelProperty("关联巡检结果ID")
private String resultId;
@ApiModelProperty("告警来源PPE、METER、MANUAL")
private String alarmSource;
@ApiModelProperty("检测项ID")
private String itemId;
@ApiModelProperty("工作流节点ID")
private String nodeId;
/** 告警编码 */
@Excel(name = "告警编码")
@ApiModelProperty("告警编码")

View File

@ -0,0 +1,33 @@
package com.cmvr.inspection.domain.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.Map;
/** 单次巡检任务的结果汇总。 */
@Data
@ApiModel(value = "InspectionReportSummaryVo", description = "单次巡检任务的结果统计汇总")
public class InspectionReportSummaryVo
{
@ApiModelProperty("巡检任务实例数据库ID")
private String taskInstanceId;
@ApiModelProperty("结果总数")
private long totalCount;
@ApiModelProperty("正常结果数")
private long normalCount;
@ApiModelProperty("异常结果数")
private long abnormalCount;
@ApiModelProperty("待人工复核数")
private long pendingCount;
@ApiModelProperty("识别失败数")
private long recognizeFailedCount;
@ApiModelProperty("按告警级别统计key为1、2、3")
private Map<Integer, Long> alarmLevelCounts = new LinkedHashMap<>();
@ApiModelProperty("按结果类型统计key为PPE、METER、MANUAL")
private Map<String, Long> resultTypeCounts = new LinkedHashMap<>();
@ApiModelProperty("是否不存在待复核和识别失败结果")
private boolean reviewCompleted;
}

View File

@ -0,0 +1,27 @@
package com.cmvr.inspection.domain.vo;
import com.cmvr.common.annotation.Excel;
import com.cmvr.inspection.domain.InspectionResult;
import com.cmvr.inspection.domain.InspectionResultMedia;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/** 巡检结果详情,包含全部媒体证据。 */
@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel(value = "InspectionResultVo", description = "巡检结果详情及其全部图片、视频证据")
public class InspectionResultVo extends InspectionResult
{
private static final long serialVersionUID = 1L;
@Excel(name = "任务名称")
@ApiModelProperty(value = "巡检任务名称", example = "锅炉房日常巡检")
private String taskName;
@ApiModelProperty("巡检结果关联的全部媒体证据按sortOrder升序")
private List<InspectionResultMedia> mediaList;
}

View File

@ -4,12 +4,14 @@ package com.cmvr.inspection.listener;
import cn.hutool.core.util.StrUtil;
import com.cmvr.common.utils.DateUtils;
import com.cmvr.framework.websocket.service.MessagePushService;
import com.cmvr.device.service.InspectionAlertListenService;
import com.cmvr.inspection.domain.InspectionTaskInstance;
import com.cmvr.inspection.domain.InspectionTaskLog;
import com.cmvr.inspection.enums.InspectionLogTypeEnum;
import com.cmvr.inspection.enums.TaskStatusEnum;
import com.cmvr.inspection.service.IInspectionTaskInstanceService;
import com.cmvr.inspection.service.IInspectionTaskLogService;
import com.cmvr.inspection.service.IInspectionResultService;
import com.cmvr.test.flow.runtime.event.FlowExecutionEvent;
import com.cmvr.test.flow.runtime.event.FlowExecutionListener;
import com.cmvr.test.flow.runtime.event.FlowExecutionModuleCodes;
@ -24,18 +26,24 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
private final IInspectionTaskInstanceService inspectionTaskInstanceService;
private final IInspectionTaskLogService inspectionTaskLogService;
private final MessagePushService messagePushService;
private final IInspectionResultService inspectionResultService;
private final InspectionAlertListenService inspectionAlertListenService;
/** key:itemId, value:已完成节点集合 */
private final Map<String, Set<String>> itemNodeExecutMap = new HashMap<>();
/** key:flowInstId|itemId, value:已完成节点集合 */
private final Map<String, Set<String>> itemNodeExecutMap = new java.util.concurrent.ConcurrentHashMap<>();
/** key:taskInsId(流程实例id=taskInsId), value:已执行item集合 */
private final Map<String, Set<String>> taskItemExecutMap = new HashMap<>();
private final Map<String, Set<String>> taskItemExecutMap = new java.util.concurrent.ConcurrentHashMap<>();
public InspectionFlowExecutionListener(IInspectionTaskInstanceService inspectionTaskInstanceService,
IInspectionTaskLogService inspectionTaskLogService,
MessagePushService messagePushService) {
MessagePushService messagePushService,
IInspectionResultService inspectionResultService,
InspectionAlertListenService inspectionAlertListenService) {
this.inspectionTaskInstanceService = inspectionTaskInstanceService;
this.inspectionTaskLogService = inspectionTaskLogService;
this.messagePushService = messagePushService;
this.inspectionResultService = inspectionResultService;
this.inspectionAlertListenService = inspectionAlertListenService;
}
@Override
@ -52,19 +60,27 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
return;
}
// 无论数据库是否可用任务结束事件都必须先清理内存监听和进度状态
if (FlowExecutionEvent.EventType.TASK_COMPLETED == event.getEventType()
|| FlowExecutionEvent.EventType.TASK_FAILED == event.getEventType()
|| FlowExecutionEvent.EventType.TASK_STOPPED == event.getEventType()) {
clearCache(instId);
inspectionAlertListenService.clearByFlowInstanceId(instId);
}
// 1.节点完成才记录当前item下node
if (StrUtil.isNotBlank(itemId) && StrUtil.isNotBlank(nodeId)
&& FlowExecutionEvent.EventType.NODE_COMPLETED == event.getEventType()) {
itemNodeExecutMap.computeIfAbsent(itemId, k -> new HashSet<>()).add(nodeId);
itemNodeExecutMap.computeIfAbsent(itemExecutionKey(instId, itemId),
k -> java.util.concurrent.ConcurrentHashMap.newKeySet()).add(nodeId);
}
// 任意事件当前任务绑定itemitem一启动就入Map所以统计已完成item要-1
if (StrUtil.isNotBlank(instId) && StrUtil.isNotBlank(itemId)) {
taskItemExecutMap.computeIfAbsent(instId, k -> new HashSet<>()).add(itemId);
taskItemExecutMap.computeIfAbsent(instId,
k -> java.util.concurrent.ConcurrentHashMap.newKeySet()).add(itemId);
}
InspectionTaskInstance taskInstance = inspectionTaskInstanceService.lambdaQuery()
.eq(InspectionTaskInstance::getTaskInsId, instId)
.one();
InspectionTaskInstance taskInstance = findTaskInstance(instId);
if (taskInstance == null) {
log.warn("未查询到巡检实例,taskInsId:{}", instId);
return;
@ -77,13 +93,13 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
case TASK_COMPLETED:
targetStatus = TaskStatusEnum.SUCCESS.getCode();
needUpdateDb = true;
clearCache(instId);
pushCompleteMsg(taskInstance.getId(), now, targetStatus);
break;
case TASK_FAILED:
targetStatus = TaskStatusEnum.FAILED.getCode();
needUpdateDb = true;
clearCache(instId);
break;
case TASK_STOPPED:
break;
case NODE_COMPLETED:
default:
@ -96,6 +112,15 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
inspectionTaskInstanceService.updateById(taskInstance);
}
if (FlowExecutionEvent.EventType.NODE_COMPLETED == event.getEventType()) {
try {
inspectionResultService.recordWorkflowResult(event, taskInstance);
} catch (Exception e) {
log.error("保存工作流巡检结果失败,taskInstanceId:{},nodeId:{}",
taskInstance.getId(), nodeId, e);
}
}
// 节点推送
if (StrUtil.isNotBlank(nodeId)) {
int waypointCount = taskInstance.getWaypointCount() == null ? 0 : taskInstance.getWaypointCount();
@ -107,7 +132,8 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
int finishedItemNum = itemTotal - 1;
// 当前item已完成节点数
Set<String> finishedNodeSet = itemNodeExecutMap.getOrDefault(itemId, new HashSet<>());
Set<String> finishedNodeSet = itemNodeExecutMap.getOrDefault(
itemExecutionKey(instId, itemId), new HashSet<>());
int finishedNode = finishedNodeSet.size();
int totalNode = event.getNodeCount() == 0 ? 1 : event.getNodeCount();
@ -159,10 +185,35 @@ public class InspectionFlowExecutionListener implements FlowExecutionListener {
/** 任务结束统一清理缓存 */
private void clearCache(String taskInsId) {
Set<String> itemList = taskItemExecutMap.getOrDefault(taskInsId, new HashSet<>());
itemList.forEach(itemNodeExecutMap::remove);
itemList.forEach(itemId -> itemNodeExecutMap.remove(itemExecutionKey(taskInsId, itemId)));
taskItemExecutMap.remove(taskInsId);
}
private String itemExecutionKey(String taskInsId, String itemId) {
return taskInsId + "|" + itemId;
}
/**
* 工作流是异步启动的首个节点事件可能略早于巡检实例写入task_ins_id短暂重试消除该竞态
*/
private InspectionTaskInstance findTaskInstance(String taskInsId) {
for (int attempt = 0; attempt < 5; attempt++) {
InspectionTaskInstance instance = inspectionTaskInstanceService.lambdaQuery()
.eq(InspectionTaskInstance::getTaskInsId, taskInsId)
.one();
if (instance != null) {
return instance;
}
try {
Thread.sleep(50L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
return null;
}
/**
* 推送完成任务信息参数dbInsId为数据库主键ID
*/

View File

@ -0,0 +1,9 @@
package com.cmvr.inspection.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.cmvr.inspection.domain.InspectionResult;
/** 巡检结果Mapper。 */
public interface InspectionResultMapper extends BaseMapper<InspectionResult>
{
}

View File

@ -0,0 +1,9 @@
package com.cmvr.inspection.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.cmvr.inspection.domain.InspectionResultMedia;
/** 巡检结果媒体Mapper。 */
public interface InspectionResultMediaMapper extends BaseMapper<InspectionResultMedia>
{
}

View File

@ -0,0 +1,72 @@
package com.cmvr.inspection.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.cmvr.device.service.InspectionAlertListenService;
import com.cmvr.inspection.domain.InspectionDetectionAlert;
import com.cmvr.inspection.domain.InspectionResult;
import com.cmvr.inspection.domain.InspectionTaskInstance;
import com.cmvr.inspection.domain.dto.InspectionResultQuery;
import com.cmvr.inspection.domain.dto.InspectionResultReviewRequest;
import com.cmvr.inspection.domain.vo.InspectionReportSummaryVo;
import com.cmvr.inspection.domain.vo.InspectionResultVo;
import com.cmvr.test.flow.runtime.event.FlowExecutionEvent;
import java.util.List;
/** 统一巡检结果业务接口。 */
public interface IInspectionResultService extends IService<InspectionResult>
{
/**
* 按任务类型状态级别等条件查询巡检结果
*
* @param query 查询条件允许为空
* @return 按发生时间倒序排列的巡检结果
*/
List<InspectionResultVo> selectResultList(InspectionResultQuery query);
/**
* 查询巡检结果和全部媒体证据
*
* @param id 巡检结果ID
* @return 结果详情不存在时返回null
*/
InspectionResultVo selectResultById(String id);
/**
* 对待复核或识别失败结果进行人工判定
*
* @param id 巡检结果ID
* @param request 复核结论告警级别说明和版本
* @return 更新后的巡检结果
*/
InspectionResult review(String id, InspectionResultReviewRequest request);
/**
* 汇总单个巡检任务实例的结果数量告警级别和复核完成状态
*
* @param taskInstanceId 巡检任务实例数据库ID
* @return 报表汇总
*/
InspectionReportSummaryVo buildReportSummary(String taskInstanceId);
/**
* 从工作流节点标准输出中提取并保存巡检结果
*
* @param event 节点完成事件
* @param taskInstance 巡检任务实例
* @return 保存后的结果节点无巡检结果输出时返回null
*/
InspectionResult recordWorkflowResult(FlowExecutionEvent event, InspectionTaskInstance taskInstance);
/**
* 将边缘端PPE事件转换为统一巡检结果并按异常级别生成告警
*
* @param alert 已保存的PPE原始事件
* @param subscription 命中的工作流监听订阅
* @param taskInstance 巡检任务实例极端竞态下允许为空
* @return 保存后的PPE巡检结果
*/
InspectionResult recordPpeResult(InspectionDetectionAlert alert,
InspectionAlertListenService.ListenSubscription subscription,
InspectionTaskInstance taskInstance);
}

View File

@ -4,13 +4,16 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.cmvr.common.config.properties.MinioProperties;
import com.cmvr.common.core.minio.MinioService;
import com.cmvr.device.service.InspectionAlertListenService;
import com.cmvr.inspection.domain.InspectionTaskInstance;
import com.cmvr.inspection.domain.InspectionDetectionAlert;
import com.cmvr.inspection.domain.dto.alert.AlertEnvelope;
import com.cmvr.inspection.domain.dto.alert.AlertImage;
import com.cmvr.inspection.exception.DetectionAlertBadRequestException;
import com.cmvr.inspection.exception.DetectionAlertTemporaryException;
import com.cmvr.inspection.mapper.InspectionDetectionAlertMapper;
import com.cmvr.inspection.mapper.InspectionTaskInstanceMapper;
import com.cmvr.inspection.service.IInspectionDetectionAlertService;
import com.cmvr.inspection.service.IInspectionResultService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -26,6 +29,7 @@ import java.util.Base64;
import java.util.Date;
import java.util.Locale;
import java.util.UUID;
import java.util.List;
/**
* PPE 违规报警接收业务实现
@ -49,6 +53,8 @@ public class InspectionDetectionAlertServiceImpl implements IInspectionDetection
private final MinioProperties minioProperties;
private final ObjectMapper objectMapper;
private final InspectionAlertListenService inspectionAlertListenService;
private final InspectionTaskInstanceMapper taskInstanceMapper;
private final IInspectionResultService inspectionResultService;
@Override
@Transactional(rollbackFor = Exception.class)
@ -58,7 +64,10 @@ public class InspectionDetectionAlertServiceImpl implements IInspectionDetection
String idempotencyKey = trimToNull(requestIdempotencyKey);
// 工作流未开启对应 IP + 事件类型监听时报警只确认接收不做持久化
if (!inspectionAlertListenService.shouldStore(envelope.getGrpcIp(), envelope.getPayload().getLabels()))
List<InspectionAlertListenService.ListenSubscription> matchedSubscriptions =
inspectionAlertListenService.matchSubscriptions(
envelope.getGrpcIp(), envelope.getPayload().getLabels());
if (matchedSubscriptions.isEmpty())
{
log.info("忽略未监听的PPE报警eventId={}grpcIp={}labels={}",
eventId, envelope.getGrpcIp(), envelope.getPayload().getLabels());
@ -88,6 +97,17 @@ public class InspectionDetectionAlertServiceImpl implements IInspectionDetection
}
}
for (InspectionAlertListenService.ListenSubscription subscription : matchedSubscriptions)
{
InspectionTaskInstance taskInstance = findTaskInstance(subscription.getFlowInstanceId());
if (taskInstance == null)
{
log.warn("PPE报警未找到巡检任务实例eventId={}flowInstanceId={}",
eventId, subscription.getFlowInstanceId());
}
inspectionResultService.recordPpeResult(alert, subscription, taskInstance);
}
log.info("PPE报警接收成功alertId={}eventId={}sourceId={}",
alert.getId(), eventId, envelope.getSourceId());
return true;
@ -103,6 +123,16 @@ public class InspectionDetectionAlertServiceImpl implements IInspectionDetection
}
}
private InspectionTaskInstance findTaskInstance(String flowInstanceId)
{
if (StringUtils.isBlank(flowInstanceId))
{
return null;
}
return taskInstanceMapper.selectOne(new LambdaQueryWrapper<InspectionTaskInstance>()
.eq(InspectionTaskInstance::getTaskInsId, flowInstanceId).last("limit 1"));
}
/**
* 校验协议关键字段并返回用于重复过滤的 event_id
*/

View File

@ -0,0 +1,739 @@
package com.cmvr.inspection.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.cmvr.common.exception.ServiceException;
import com.cmvr.common.utils.DateUtils;
import com.cmvr.common.utils.SecurityUtils;
import com.cmvr.device.service.InspectionAlertListenService;
import com.cmvr.framework.websocket.service.MessagePushService;
import com.cmvr.inspection.domain.InspectionAlarm;
import com.cmvr.inspection.domain.InspectionDetectionAlert;
import com.cmvr.inspection.domain.InspectionResult;
import com.cmvr.inspection.domain.InspectionResultMedia;
import com.cmvr.inspection.domain.InspectionRobot;
import com.cmvr.inspection.domain.InspectionTask;
import com.cmvr.inspection.domain.InspectionTaskInstance;
import com.cmvr.inspection.domain.InspectionTaskLog;
import com.cmvr.inspection.domain.dto.InspectionResultQuery;
import com.cmvr.inspection.domain.dto.InspectionResultReviewRequest;
import com.cmvr.inspection.domain.vo.InspectionReportSummaryVo;
import com.cmvr.inspection.domain.vo.InspectionResultVo;
import com.cmvr.inspection.enums.InspectionLogTypeEnum;
import com.cmvr.inspection.enums.TaskStatusEnum;
import com.cmvr.inspection.mapper.InspectionAlarmMapper;
import com.cmvr.inspection.mapper.InspectionResultMapper;
import com.cmvr.inspection.mapper.InspectionResultMediaMapper;
import com.cmvr.inspection.mapper.InspectionRobotMapper;
import com.cmvr.inspection.mapper.InspectionTaskMapper;
import com.cmvr.inspection.mapper.InspectionTaskInstanceMapper;
import com.cmvr.inspection.service.IInspectionResultService;
import com.cmvr.inspection.service.IInspectionTaskLogService;
import com.cmvr.test.flow.runtime.event.FlowExecutionEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.HashSet;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 统一巡检结果业务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class InspectionResultServiceImpl extends ServiceImpl<InspectionResultMapper, InspectionResult>
implements IInspectionResultService
{
private static final String RESULT_MARKER = "_inspectionResult";
private static final String STATUS_PENDING = "PENDING";
private static final String STATUS_NORMAL = "NORMAL";
private static final String STATUS_ABNORMAL = "ABNORMAL";
private static final String STATUS_RECOGNIZE_FAILED = "RECOGNIZE_FAILED";
private final InspectionResultMediaMapper mediaMapper;
private final InspectionAlarmMapper alarmMapper;
private final InspectionTaskMapper taskMapper;
private final InspectionTaskInstanceMapper taskInstanceMapper;
private final InspectionRobotMapper robotMapper;
private final IInspectionTaskLogService taskLogService;
private final MessagePushService messagePushService;
@Override
public List<InspectionResultVo> selectResultList(InspectionResultQuery query)
{
InspectionResultQuery condition = query == null ? new InspectionResultQuery() : query;
LambdaQueryWrapper<InspectionResult> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(StringUtils.isNotBlank(condition.getTaskInstanceId()),
InspectionResult::getTaskInstanceId, condition.getTaskInstanceId())
.eq(StringUtils.isNotBlank(condition.getTaskId()),
InspectionResult::getTaskId, condition.getTaskId())
.eq(StringUtils.isNotBlank(condition.getItemId()),
InspectionResult::getItemId, condition.getItemId())
.eq(StringUtils.isNotBlank(condition.getResultType()),
InspectionResult::getResultType, condition.getResultType())
.eq(StringUtils.isNotBlank(condition.getResultStatus()),
InspectionResult::getResultStatus, condition.getResultStatus())
.eq(condition.getAlarmLevel() != null,
InspectionResult::getAlarmLevel, condition.getAlarmLevel())
.like(StringUtils.isNotBlank(condition.getResultName()),
InspectionResult::getResultName, condition.getResultName())
.orderByDesc(InspectionResult::getOccurredTime, InspectionResult::getCreateTime);
return toResultVoList(list(wrapper));
}
@Override
public InspectionResultVo selectResultById(String id)
{
InspectionResult result = getById(id);
if (result == null)
{
return null;
}
InspectionResultVo vo = new InspectionResultVo();
BeanUtils.copyProperties(result, vo);
if (StringUtils.isNotBlank(result.getTaskId()))
{
InspectionTask task = taskMapper.selectById(result.getTaskId());
vo.setTaskName(task == null ? null : task.getTaskName());
}
List<InspectionResultMedia> mediaList = mediaMapper.selectList(new LambdaQueryWrapper<InspectionResultMedia>()
.eq(InspectionResultMedia::getResultId, id)
.orderByAsc(InspectionResultMedia::getSortOrder));
vo.setMediaList(mediaList);
return vo;
}
/**
* 批量补充任务名称并转换为列表视图避免按结果逐条查询任务表
*/
private List<InspectionResultVo> toResultVoList(List<InspectionResult> results)
{
if (results == null || results.isEmpty())
{
return Collections.emptyList();
}
Set<String> taskIds = results.stream()
.map(InspectionResult::getTaskId)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toCollection(HashSet::new));
Map<String, InspectionTask> taskMap = taskIds.isEmpty()
? Collections.emptyMap()
: taskMapper.selectBatchIds(taskIds).stream()
.collect(Collectors.toMap(InspectionTask::getId, Function.identity(), (left, right) -> left));
List<InspectionResultVo> resultVos = new ArrayList<>(results.size());
for (InspectionResult result : results)
{
InspectionResultVo vo = new InspectionResultVo();
BeanUtils.copyProperties(result, vo);
InspectionTask task = taskMap.get(result.getTaskId());
vo.setTaskName(task == null ? null : task.getTaskName());
resultVos.add(vo);
}
return resultVos;
}
/**
* 提交人工复核使用reviewVersion做条件更新保证并发审核只有一个请求成功
*/
@Override
@Transactional(rollbackFor = Exception.class)
public InspectionResult review(String id, InspectionResultReviewRequest request)
{
if (request == null)
{
throw new ServiceException("复核参数不能为空");
}
String targetStatus = StringUtils.upperCase(StringUtils.trimToEmpty(request.getResultStatus()));
if (!STATUS_NORMAL.equals(targetStatus) && !STATUS_ABNORMAL.equals(targetStatus))
{
throw new ServiceException("人工复核结果只能为NORMAL或ABNORMAL");
}
if (STATUS_ABNORMAL.equals(targetStatus)
&& (request.getAlarmLevel() == null || request.getAlarmLevel() < 1 || request.getAlarmLevel() > 3))
{
throw new ServiceException("异常结果必须指定1到3级告警级别");
}
InspectionResult current = getById(id);
if (current == null)
{
throw new ServiceException("巡检结果不存在");
}
if (!STATUS_PENDING.equals(current.getResultStatus())
&& !STATUS_RECOGNIZE_FAILED.equals(current.getResultStatus()))
{
throw new ServiceException("该巡检结果已完成复核,不能重复提交");
}
int expectedVersion = request.getReviewVersion() == null
? valueOrZero(current.getReviewVersion()) : request.getReviewVersion();
String username = SecurityUtils.getUsername();
Date now = DateUtils.getNowDate();
LambdaUpdateWrapper<InspectionResult> update = new LambdaUpdateWrapper<>();
update.eq(InspectionResult::getId, id)
.eq(InspectionResult::getReviewVersion, expectedVersion)
.set(InspectionResult::getResultStatus, targetStatus)
.set(InspectionResult::getAlarmLevel,
STATUS_ABNORMAL.equals(targetStatus) ? request.getAlarmLevel() : null)
.set(InspectionResult::getAlarmMessage,
STATUS_ABNORMAL.equals(targetStatus) ? "人工复核判定异常" : null)
.set(InspectionResult::getReviewer, username)
.set(InspectionResult::getReviewTime, now)
.set(InspectionResult::getReviewRemark, request.getReviewRemark())
.set(InspectionResult::getReviewVersion, expectedVersion + 1)
.set(InspectionResult::getUpdateBy, username)
.set(InspectionResult::getUpdateTime, now);
if (!update(update))
{
throw new ServiceException("巡检结果已被其他人员修改,请刷新后重试");
}
InspectionResult reviewed = getById(id);
if (STATUS_ABNORMAL.equals(reviewed.getResultStatus()))
{
createAlarmIfNecessary(reviewed);
}
appendResultLog(reviewed, "人工复核完成");
pushResult(reviewed);
return reviewed;
}
@Override
public InspectionReportSummaryVo buildReportSummary(String taskInstanceId)
{
List<InspectionResult> results = list(new LambdaQueryWrapper<InspectionResult>()
.eq(InspectionResult::getTaskInstanceId, taskInstanceId));
InspectionReportSummaryVo summary = new InspectionReportSummaryVo();
summary.setTaskInstanceId(taskInstanceId);
summary.setTotalCount(results.size());
Map<Integer, Long> levelCounts = new LinkedHashMap<>();
levelCounts.put(1, 0L);
levelCounts.put(2, 0L);
levelCounts.put(3, 0L);
Map<String, Long> typeCounts = new LinkedHashMap<>();
for (InspectionResult result : results)
{
switch (StringUtils.defaultString(result.getResultStatus()))
{
case STATUS_NORMAL:
summary.setNormalCount(summary.getNormalCount() + 1);
break;
case STATUS_ABNORMAL:
summary.setAbnormalCount(summary.getAbnormalCount() + 1);
break;
case STATUS_PENDING:
summary.setPendingCount(summary.getPendingCount() + 1);
break;
case STATUS_RECOGNIZE_FAILED:
summary.setRecognizeFailedCount(summary.getRecognizeFailedCount() + 1);
break;
default:
break;
}
if (result.getAlarmLevel() != null)
{
levelCounts.put(result.getAlarmLevel(), levelCounts.getOrDefault(result.getAlarmLevel(), 0L) + 1);
}
String type = StringUtils.defaultIfBlank(result.getResultType(), "UNKNOWN");
typeCounts.put(type, typeCounts.getOrDefault(type, 0L) + 1);
}
summary.setAlarmLevelCounts(levelCounts);
summary.setResultTypeCounts(typeCounts);
summary.setReviewCompleted(summary.getPendingCount() == 0 && summary.getRecognizeFailedCount() == 0);
return summary;
}
/**
* 消费工作流节点的标准巡检输出普通节点没有_inspectionResult时直接忽略
*/
@Override
@Transactional(rollbackFor = Exception.class)
public InspectionResult recordWorkflowResult(FlowExecutionEvent event, InspectionTaskInstance taskInstance)
{
if (event == null || event.getOutputParams() == null)
{
return null;
}
JSONObject source = event.getOutputParams().getJSONObject(RESULT_MARKER);
if (source == null)
{
return null;
}
String iterationPath = JSON.toJSONString(event.getIterations() == null
? Collections.emptyList() : event.getIterations());
String dedupeKey = limit("WF|" + event.getInstId() + "|" + event.getItemId() + "|"
+ event.getNodeId() + "|" + iterationPath, 255);
InspectionResult existing = findByDedupeKey(dedupeKey);
if (existing != null)
{
return existing;
}
InspectionResult result = baseResult(taskInstance, event.getInstId(), event.getItemId(), event.getNodeId());
result.setDedupeKey(dedupeKey);
result.setNodeName(event.getNodeName());
result.setIterationPath(iterationPath);
result.setResultType(upper(source.getString("resultType")));
result.setResultStatus(upper(source.getString("resultStatus")));
result.setResultName(StringUtils.defaultIfBlank(source.getString("resultName"), event.getNodeName()));
result.setValueNumber(source.getBigDecimal("valueNumber"));
result.setValueText(source.getString("valueText"));
result.setUnit(source.getString("unit"));
result.setAlarmLevel(source.getInteger("alarmLevel"));
result.setAlarmMessage(source.getString("alarmMessage"));
result.setConfidence(source.getBigDecimal("confidence"));
result.setEvidenceUrl(source.getString("evidenceUrl"));
result.setEvidenceType(resolveSourceEvidenceType(source));
result.setRawResult(toJsonText(source.get("rawResult")));
result.setMatchedRuleJson(toJsonText(source.get("matchedRule")));
result.setRuleSnapshotJson(toJsonText(source.get("ruleSnapshot")));
validateStandardResult(result);
try
{
insertResult(result);
}
catch (DuplicateKeyException ex)
{
return findByDedupeKey(dedupeKey);
}
saveMedia(result.getId(), source);
postCreate(result, "巡检节点产生结果");
return result;
}
/**
* 将一条PPE原始报警按命中的工作流订阅转换为巡检结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public InspectionResult recordPpeResult(InspectionDetectionAlert alert,
InspectionAlertListenService.ListenSubscription subscription,
InspectionTaskInstance taskInstance)
{
String dedupeKey = limit("PPE|" + alert.getEventId() + "|" + subscription.getSubscriptionId(), 255);
InspectionResult existing = findByDedupeKey(dedupeKey);
if (existing != null)
{
return existing;
}
InspectionResult result = baseResult(taskInstance, subscription.getFlowInstanceId(),
subscription.getItemId(), subscription.getNodeId());
result.setDedupeKey(dedupeKey);
result.setResultType("PPE");
result.setResultStatus(STATUS_ABNORMAL);
result.setResultName(ppeName(subscription.getEventType()));
result.setValueText(subscription.getEventType());
result.setAlarmLevel("No-Helmet".equals(subscription.getEventType()) ? 3 : 2);
result.setAlarmMessage(ppeMessage(subscription.getEventType()));
result.setConfidence(alert.getMaxConfidence() == null
? null : BigDecimal.valueOf(alert.getMaxConfidence()));
result.setSourceEventId(alert.getEventId());
result.setEvidenceUrl(alert.getImagePath());
result.setEvidenceType(StringUtils.isBlank(alert.getImagePath()) ? null : "IMAGE");
result.setRawResult(alert.getRawJson());
result.setOccurredTime(fromUnixNs(alert.getTriggeredAtNs()));
try
{
insertResult(result);
}
catch (DuplicateKeyException ex)
{
return findByDedupeKey(dedupeKey);
}
if (StringUtils.isNotBlank(result.getEvidenceUrl()))
{
insertMedia(result.getId(), "IMAGE", result.getEvidenceUrl(), 0);
}
postCreate(result, "接收到PPE违规事件");
return result;
}
private InspectionResult baseResult(InspectionTaskInstance taskInstance, String flowInstanceId,
String itemId, String nodeId)
{
InspectionResult result = new InspectionResult();
result.setId(newId());
result.setResultCode("IR" + new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date())
+ result.getId().substring(0, 5).toUpperCase(Locale.ROOT));
result.setFlowInstanceId(flowInstanceId);
result.setItemId(itemId);
result.setNodeId(nodeId);
if (taskInstance != null)
{
result.setTaskInstanceId(taskInstance.getId());
result.setTaskId(taskInstance.getTaskId());
}
result.setReviewVersion(0);
result.setOccurredTime(DateUtils.getNowDate());
result.setCreateBy("system");
result.setCreateTime(DateUtils.getNowDate());
return result;
}
private void insertResult(InspectionResult result)
{
if (baseMapper.insert(result) != 1)
{
throw new ServiceException("保存巡检结果失败");
}
}
private void postCreate(InspectionResult result, String logPrefix)
{
if (STATUS_ABNORMAL.equals(result.getResultStatus()))
{
createAlarmIfNecessary(result);
}
appendResultLog(result, logPrefix);
pushResult(result);
}
/**
* 为异常结果幂等创建巡检告警数据库result_id唯一索引是并发场景的最终保障
*/
private void createAlarmIfNecessary(InspectionResult result)
{
InspectionAlarm existing = alarmMapper.selectOne(new LambdaQueryWrapper<InspectionAlarm>()
.eq(InspectionAlarm::getResultId, result.getId()).last("limit 1"));
if (existing != null)
{
if (!StringUtils.equals(existing.getId(), result.getAlarmId()))
{
result.setAlarmId(existing.getId());
baseMapper.updateById(result);
}
return;
}
InspectionTask task = StringUtils.isBlank(result.getTaskId())
? null : taskMapper.selectById(result.getTaskId());
InspectionTaskInstance taskInstance = null;
if (StringUtils.isNotBlank(result.getTaskInstanceId()))
{
taskInstance = new InspectionTaskInstance();
taskInstance.setId(result.getTaskInstanceId());
}
InspectionRobot robot = null;
if (taskInstance != null)
{
// 仅在需要构建告警时查询完整任务实例
taskInstance = taskInstanceMapper.selectById(result.getTaskInstanceId());
if (taskInstance != null && StringUtils.isNotBlank(taskInstance.getRobotId()))
{
robot = robotMapper.selectById(taskInstance.getRobotId());
}
}
InspectionAlarm alarm = new InspectionAlarm();
alarm.setId(newId());
alarm.setAlarmCode("IA" + new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date())
+ alarm.getId().substring(0, 4).toUpperCase(Locale.ROOT));
alarm.setResultId(result.getId());
alarm.setAlarmSource(result.getResultType());
alarm.setItemId(result.getItemId());
alarm.setNodeId(result.getNodeId());
alarm.setTaskInstanceId(result.getTaskInstanceId());
alarm.setTaskId(result.getTaskId());
alarm.setTaskName(task == null ? null : task.getTaskName());
alarm.setRobotId(taskInstance == null ? null : taskInstance.getRobotId());
alarm.setRobotName(robot == null ? null : robot.getRobotName());
alarm.setAlarmLevel(String.valueOf(result.getAlarmLevel() == null ? 2 : result.getAlarmLevel()));
alarm.setAlarmType("METER".equals(result.getResultType()) ? "1" : "2");
alarm.setAlarmTitle(result.getResultName() + "异常");
alarm.setAlarmContent(buildAlarmContent(result));
alarm.setAlarmTime(result.getOccurredTime());
alarm.setHandleStatus("0");
alarm.setEvidenceImage(result.getEvidenceUrl());
alarm.setCreateBy("system");
alarm.setCreateTime(DateUtils.getNowDate());
alarmMapper.insert(alarm);
result.setAlarmId(alarm.getId());
baseMapper.updateById(result);
try
{
messagePushService.pushToChannel("InspectionAlarm", alarm);
}
catch (Exception ex)
{
log.info("巡检告警无订阅者alarmId={}", alarm.getId());
}
}
private String buildAlarmContent(InspectionResult result)
{
if ("METER".equals(result.getResultType()))
{
return result.getResultName() + "读数为" + StringUtils.defaultString(result.getValueText())
+ StringUtils.defaultString(result.getUnit()) + ",触发" + result.getAlarmLevel()
+ "级告警:" + StringUtils.defaultString(result.getAlarmMessage());
}
return StringUtils.defaultIfBlank(result.getAlarmMessage(), result.getResultName() + "检测异常");
}
/** 将业务结果追加到现有巡检任务时间线,并通知任务详情订阅者。 */
private void appendResultLog(InspectionResult result, String prefix)
{
if (StringUtils.isBlank(result.getTaskInstanceId()))
{
log.warn("巡检结果未绑定任务实例跳过任务日志resultId={}flowInstanceId={}",
result.getId(), result.getFlowInstanceId());
return;
}
InspectionTaskLog taskLog = InspectionTaskLog.builder()
.id(newId())
.taskInstanceId(result.getTaskInstanceId())
.taskId(result.getTaskId())
.itemId(result.getItemId())
.nodeId(result.getNodeId())
.nodeName(result.getNodeName())
.logType(resolveLogType(result.getEvidenceUrl()))
.logContent(prefix + "" + resultDescription(result))
.mediaUrl(result.getEvidenceUrl())
.status(TaskStatusEnum.SUCCESS.getCode())
.logTime(DateUtils.getNowDate())
.extraInfo(JSON.toJSONString(Collections.singletonMap("resultId", result.getId())))
.build();
taskLogService.insertInspectionTaskLog(taskLog);
try
{
messagePushService.pushToChannel("InspectionTaskInstance", taskLog);
}
catch (Exception ex)
{
log.info("巡检结果日志无订阅者resultId={}", result.getId());
}
}
private String resultDescription(InspectionResult result)
{
if ("METER".equals(result.getResultType()))
{
return result.getResultName() + "=" + StringUtils.defaultString(result.getValueText())
+ StringUtils.defaultString(result.getUnit()) + ",结果=" + result.getResultStatus()
+ (result.getAlarmLevel() == null ? "" : ",告警级别=" + result.getAlarmLevel());
}
return result.getResultName() + ",结果=" + result.getResultStatus();
}
private void pushResult(InspectionResult result)
{
try
{
messagePushService.pushToChannel("InspectionResult", result);
}
catch (Exception ex)
{
log.info("巡检结果无订阅者resultId={}", result.getId());
}
}
/** 保存人工巡检的多媒体证据;只有单一证据时也统一落媒体表。 */
private void saveMedia(String resultId, JSONObject source)
{
JSONArray mediaList = source.getJSONArray("mediaList");
int order = 0;
if (mediaList != null)
{
for (Object item : mediaList)
{
if (item instanceof JSONObject)
{
JSONObject media = (JSONObject) item;
insertMedia(resultId, upper(media.getString("mediaType")), media.getString("mediaUrl"), order++);
}
else if (item != null)
{
String url = String.valueOf(item);
insertMedia(resultId, detectMediaType(url), url, order++);
}
}
}
if (order == 0 && StringUtils.isNotBlank(source.getString("evidenceUrl")))
{
insertMedia(resultId, detectMediaType(source.getString("evidenceUrl")),
source.getString("evidenceUrl"), 0);
}
}
private void insertMedia(String resultId, String mediaType, String mediaUrl, int order)
{
if (StringUtils.isBlank(mediaUrl))
{
return;
}
InspectionResultMedia media = new InspectionResultMedia();
media.setId(newId());
media.setResultId(resultId);
media.setMediaType(normalizeEvidenceType(
StringUtils.defaultIfBlank(mediaType, detectMediaType(mediaUrl))));
media.setMediaUrl(mediaUrl);
media.setSortOrder(order);
media.setCreateTime(DateUtils.getNowDate());
mediaMapper.insert(media);
}
private InspectionResult findByDedupeKey(String dedupeKey)
{
return baseMapper.selectOne(new LambdaQueryWrapper<InspectionResult>()
.eq(InspectionResult::getDedupeKey, dedupeKey).last("limit 1"));
}
private void validateStandardResult(InspectionResult result)
{
if (!("PPE".equals(result.getResultType()) || "METER".equals(result.getResultType())
|| "MANUAL".equals(result.getResultType())))
{
throw new ServiceException("工作流巡检结果类型不支持: " + result.getResultType());
}
if (!(STATUS_PENDING.equals(result.getResultStatus()) || STATUS_NORMAL.equals(result.getResultStatus())
|| STATUS_ABNORMAL.equals(result.getResultStatus())
|| STATUS_RECOGNIZE_FAILED.equals(result.getResultStatus())))
{
throw new ServiceException("工作流巡检结果状态不支持: " + result.getResultStatus());
}
if (STATUS_ABNORMAL.equals(result.getResultStatus())
&& (result.getAlarmLevel() == null || result.getAlarmLevel() < 1 || result.getAlarmLevel() > 3))
{
throw new ServiceException("异常巡检结果必须指定1到3级告警级别");
}
}
private int resolveLogType(String url)
{
String type = detectMediaType(url);
if ("VIDEO".equals(type))
{
return InspectionLogTypeEnum.VIDEO.getCode();
}
if ("IMAGE".equals(type))
{
return InspectionLogTypeEnum.IMAGE.getCode();
}
return InspectionLogTypeEnum.TEXT.getCode();
}
private String detectMediaType(String url)
{
String normalized = StringUtils.lowerCase(StringUtils.defaultString(url));
if (normalized.matches(".*\\.(mp4|avi|mov|mkv|webm)(\\?.*)?$"))
{
return "VIDEO";
}
return "IMAGE";
}
/**
* 在结果首次入库时确定主要证据类型列表查询无需再访问媒体明细表
*/
private String resolveSourceEvidenceType(JSONObject source)
{
String explicitType = normalizeEvidenceType(source.getString("evidenceType"));
if (StringUtils.isNotBlank(explicitType))
{
return explicitType;
}
// 人工巡检以mediaList第一条作为主要证据和evidenceUrl的赋值规则保持一致
JSONArray mediaList = source.getJSONArray("mediaList");
if (mediaList != null && !mediaList.isEmpty() && mediaList.get(0) instanceof JSONObject)
{
String firstType = normalizeEvidenceType(mediaList.getJSONObject(0).getString("mediaType"));
if (StringUtils.isNotBlank(firstType))
{
return firstType;
}
}
String evidenceUrl = source.getString("evidenceUrl");
return StringUtils.isBlank(evidenceUrl) ? null : detectMediaType(evidenceUrl);
}
/** 媒体类型只允许IMAGE或VIDEO避免主表和媒体明细出现不一致的自由文本。 */
private String normalizeEvidenceType(String value)
{
String normalized = upper(value);
if (StringUtils.isBlank(normalized))
{
return null;
}
if (!"IMAGE".equals(normalized) && !"VIDEO".equals(normalized))
{
throw new ServiceException("不支持的巡检证据类型: " + value);
}
return normalized;
}
private String ppeName(String eventType)
{
return "No-Helmet".equals(eventType) ? "未佩戴安全帽" : "未佩戴手套";
}
private String ppeMessage(String eventType)
{
return "No-Helmet".equals(eventType) ? "检测到人员未佩戴安全帽" : "检测到人员未佩戴手套";
}
private Date fromUnixNs(Long timestampNs)
{
return timestampNs == null ? DateUtils.getNowDate() : new Date(timestampNs / 1_000_000L);
}
private String toJsonText(Object value)
{
if (value == null)
{
return null;
}
return value instanceof String ? String.valueOf(value) : JSON.toJSONString(value);
}
private String upper(String value)
{
return StringUtils.upperCase(StringUtils.trimToEmpty(value));
}
private int valueOrZero(Integer value)
{
return value == null ? 0 : value;
}
private String limit(String value, int maxLength)
{
return value.length() <= maxLength ? value : value.substring(0, maxLength);
}
private String newId()
{
return UUID.randomUUID().toString().replace("-", "");
}
}

View File

@ -13,6 +13,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="remark" column="remark" />
<result property="alarmCode" column="alarm_code" />
<result property="taskInstanceId" column="task_instance_id" />
<result property="resultId" column="result_id" />
<result property="alarmSource" column="alarm_source" />
<result property="itemId" column="item_id" />
<result property="nodeId" column="node_id" />
<result property="taskId" column="task_id" />
<result property="taskName" column="task_name" />
<result property="robotId" column="robot_id" />
@ -39,6 +43,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="remark" column="remark" />
<result property="alarmCode" column="alarm_code" />
<result property="taskInstanceId" column="task_instance_id" />
<result property="resultId" column="result_id" />
<result property="alarmSource" column="alarm_source" />
<result property="itemId" column="item_id" />
<result property="nodeId" column="node_id" />
<result property="taskId" column="task_id" />
<result property="taskName" column="task_name" />
<result property="robotId" column="robot_id" />
@ -59,7 +67,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<sql id="selectInspectionAlarmVo">
select id, create_by, create_time, update_by, update_time, remark, alarm_code, task_instance_id, task_id, task_name, robot_id, robot_name, alarm_level, alarm_type, alarm_title, alarm_content, alarm_location, alarm_time, handle_status, handler, handle_time, handle_remark, evidence_image from inspection_alarm
select id, create_by, create_time, update_by, update_time, remark, alarm_code,
task_instance_id, result_id, alarm_source, item_id, node_id,
task_id, task_name, robot_id, robot_name, alarm_level, alarm_type,
alarm_title, alarm_content, alarm_location, alarm_time, handle_status,
handler, handle_time, handle_remark, evidence_image from inspection_alarm
</sql>
<select id="selectInspectionAlarmList" parameterType="InspectionAlarm" resultMap="InspectionAlarmResult">
@ -90,6 +102,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="remark != null">remark,</if>
<if test="alarmCode != null">alarm_code,</if>
<if test="taskInstanceId != null">task_instance_id,</if>
<if test="resultId != null">result_id,</if>
<if test="alarmSource != null">alarm_source,</if>
<if test="itemId != null">item_id,</if>
<if test="nodeId != null">node_id,</if>
<if test="taskId != null">task_id,</if>
<if test="taskName != null">task_name,</if>
<if test="robotId != null">robot_id,</if>
@ -115,6 +131,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="remark != null">#{remark},</if>
<if test="alarmCode != null">#{alarmCode},</if>
<if test="taskInstanceId != null">#{taskInstanceId},</if>
<if test="resultId != null">#{resultId},</if>
<if test="alarmSource != null">#{alarmSource},</if>
<if test="itemId != null">#{itemId},</if>
<if test="nodeId != null">#{nodeId},</if>
<if test="taskId != null">#{taskId},</if>
<if test="taskName != null">#{taskName},</if>
<if test="robotId != null">#{robotId},</if>
@ -143,6 +163,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="remark != null">remark = #{remark},</if>
<if test="alarmCode != null">alarm_code = #{alarmCode},</if>
<if test="taskInstanceId != null">task_instance_id = #{taskInstanceId},</if>
<if test="resultId != null">result_id = #{resultId},</if>
<if test="alarmSource != null">alarm_source = #{alarmSource},</if>
<if test="itemId != null">item_id = #{itemId},</if>
<if test="nodeId != null">node_id = #{nodeId},</if>
<if test="taskId != null">task_id = #{taskId},</if>
<if test="taskName != null">task_name = #{taskName},</if>
<if test="robotId != null">robot_id = #{robotId},</if>
@ -175,7 +199,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectInspectionAlarmVoList" parameterType="InspectionAlarm" resultMap="InspectionAlarmVoResult">
select a.id, a.create_by, a.create_time, a.update_by, a.update_time, a.remark,
a.alarm_code, a.task_instance_id, a.task_id, t.task_name, a.robot_id, r.robot_name,
a.alarm_code, a.task_instance_id, a.result_id, a.alarm_source, a.item_id, a.node_id,
a.task_id, t.task_name, a.robot_id, r.robot_name,
a.alarm_level, a.alarm_type, a.alarm_title, a.alarm_content, a.alarm_location,
a.alarm_time, a.handle_status, a.handler, a.handle_time, a.handle_remark, a.evidence_image,
u1.nick_name as create_by_name, u2.nick_name as update_by_name

View File

@ -74,6 +74,7 @@ public enum ActionEnum {
INTENT_RECOGNITION("LLM", "INTENT_RECOGNITION", "意图识别"),
GENERATE_ADVANCED_AUDIO("LLM", "GENERATE_ADVANCED_AUDIO", "tts语音合成"),
AI_AGENT_PLATFORM("LLM", "AI_AGENT_PLATFORM", "商道智能体"),
INSPECTION_METER_RECOGNIZE("LLM", "INSPECTION_METER_RECOGNIZE", "巡检仪表读数识别"),
AI_TTS("LLM", "AI_TTS", "tts语音播放"),
GET_CURRENT_PAGE("LLM", "GET_CURRENT_PAGE", "获取当前页面名称"),
@ -83,7 +84,8 @@ public enum ActionEnum {
// 巡检报警
INSPECTION_ALERT_LISTEN_START("EDGE", "INSPECTION_ALERT_LISTEN_START", "开始监听报警事件"),
INSPECTION_ALERT_LISTEN_STOP("EDGE", "INSPECTION_ALERT_LISTEN_STOP", "结束监听报警事件")
INSPECTION_ALERT_LISTEN_STOP("EDGE", "INSPECTION_ALERT_LISTEN_STOP", "结束监听报警事件"),
INSPECTION_MANUAL_REVIEW_CREATE("EDGE", "INSPECTION_MANUAL_REVIEW_CREATE", "创建人工巡检判断任务")
;

View File

@ -1,11 +1,15 @@
package com.cmvr.test.flow.runtime.event;
import com.alibaba.fastjson2.JSONObject;
import com.cmvr.test.enums.ActionEnum;
import com.cmvr.test.enums.TaskStatusEnum;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 流程执行事件
* <p>
@ -62,6 +66,26 @@ public class FlowExecutionEvent {
*/
private String nodeName;
/**
* 当前节点动作用于业务监听器识别标准化节点输出
*/
private ActionEnum action;
/**
* 节点成功执行后的输出参数
*/
private JSONObject outputParams;
/**
* 当前工作流绑定的终端ID
*/
private String terminalId;
/**
* 循环节点迭代路径用于区分同一节点的多次执行结果
*/
private List<Integer> iterations;
/**
* 当前任务状态
*/

View File

@ -10,6 +10,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.function.Function;
@Slf4j
@ -55,6 +56,11 @@ public class FlowAfterInterceptor extends AbstractFlowMsgPreInterceptor{
.nodeId(message.getNodeId())
.nodeCount(message.getGraph().allNodeIds().size())
.nodeType(message.getNodeType())
.action(message.getAction())
.outputParams(result != null ? result.getOutputParams() : null)
.terminalId(message.getTerminalId())
.iterations(message.getIterations() == null
? new ArrayList<>() : new ArrayList<>(message.getIterations()))
.errorMessage(errorMessage)
.build();

View File

@ -37,15 +37,19 @@ public class EdgeInspectionAlertOperateService implements EdgeOperateService
JSONObject inputParams = message.getInputParams() == null ? new JSONObject() : message.getInputParams();
String terminalId = StringUtils.defaultIfBlank(inputParams.getString("terminalId"), message.getTerminalId());
Collection<String> eventTypes = resolveEventTypes(inputParams);
String listenerKey = inputParams.getString("listenerKey");
InspectionAlertListenService.ListenState state;
switch (message.getAction())
{
case INSPECTION_ALERT_LISTEN_START:
state = inspectionAlertListenService.startListen(terminalId, eventTypes);
state = inspectionAlertListenService.startListen(terminalId, eventTypes,
message.getInstId(), message.getTaskId(), message.getItemId(),
message.getNodeId(), listenerKey);
break;
case INSPECTION_ALERT_LISTEN_STOP:
state = inspectionAlertListenService.stopListen(terminalId, eventTypes);
state = inspectionAlertListenService.stopListen(terminalId, eventTypes,
message.getInstId(), message.getItemId(), listenerKey);
break;
default:
throw new GlobalException("不支持的巡检报警监听动作: " + message.getAction());

View File

@ -0,0 +1,127 @@
package com.cmvr.test.flow.runtime.operator.edge;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.cmvr.common.exception.GlobalException;
import com.cmvr.test.enums.ActionEnum;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
import com.cmvr.test.model.vo.inspection.InspectionManualReviewConfigVO;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.util.Collection;
/**
* 创建待人工复核巡检结果工作流不等待人工处理
*
* <p>输入参数见{@link InspectionManualReviewConfigVO}节点只登记已由前置组件保存到MinIO的
* 图片或视频地址不复制媒体文件节点执行后立即返回PENDING人工通过巡检结果接口复核</p>
*/
@Service
public class EdgeManualInspectionOperateService implements EdgeOperateService
{
private static final String RESULT_MARKER = "_inspectionResult";
@Override
public boolean supports(ActionEnum action)
{
return ActionEnum.INSPECTION_MANUAL_REVIEW_CREATE.equals(action);
}
@Override
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message)
{
JSONObject input = message.getInputParams() == null ? new JSONObject() : message.getInputParams();
InspectionManualReviewConfigVO config = input.to(InspectionManualReviewConfigVO.class);
String resultName = StringUtils.defaultIfBlank(config.getResultName(), "人工巡检");
JSONArray mediaList = resolveMediaList(input);
if (mediaList.isEmpty())
{
throw new GlobalException("人工巡检至少需要一张图片或一个视频");
}
JSONObject inspectionResult = new JSONObject();
inspectionResult.put("resultType", "MANUAL");
inspectionResult.put("resultStatus", "PENDING");
inspectionResult.put("resultName", resultName);
inspectionResult.put("evidenceUrl", mediaList.getJSONObject(0).getString("mediaUrl"));
inspectionResult.put("evidenceType", mediaList.getJSONObject(0).getString("mediaType"));
inspectionResult.put("mediaList", mediaList);
inspectionResult.put("rawResult", new JSONObject()
.fluentPut("reviewCriteria", config.getReviewCriteria())
.fluentPut("defaultAlarmLevel", config.getDefaultAlarmLevel()));
JSONObject output = new JSONObject();
output.put("resultStatus", "PENDING");
output.put("mediaList", mediaList);
output.put(RESULT_MARKER, inspectionResult);
return TaskNodeExecuteResult.success(output);
}
/**
* 合并mediaListimageUrl和videoUrl并统一为标准媒体对象数组
*/
private JSONArray resolveMediaList(JSONObject input)
{
JSONArray result = new JSONArray();
Object raw = input.get("mediaList");
if (raw instanceof JSONArray)
{
for (Object item : (JSONArray) raw)
{
addMedia(result, item, null);
}
}
else if (raw instanceof Collection)
{
for (Object item : (Collection<?>) raw)
{
addMedia(result, item, null);
}
}
else
{
addMedia(result, raw, null);
}
addMedia(result, input.get("imageUrl"), "IMAGE");
addMedia(result, input.get("videoUrl"), "VIDEO");
return result;
}
/** 支持标准媒体对象也兼容历史工作流直接传入URL字符串。 */
private void addMedia(JSONArray target, Object value, String defaultType)
{
if (value == null)
{
return;
}
if (value instanceof JSONObject)
{
JSONObject source = (JSONObject) value;
String url = StringUtils.trimToNull(source.getString("mediaUrl"));
if (url != null)
{
target.add(new JSONObject()
.fluentPut("mediaUrl", url)
.fluentPut("mediaType", StringUtils.defaultIfBlank(
source.getString("mediaType"), detectType(url))));
}
return;
}
String url = StringUtils.trimToNull(String.valueOf(value));
if (url != null)
{
target.add(new JSONObject()
.fluentPut("mediaUrl", url)
.fluentPut("mediaType", StringUtils.defaultIfBlank(defaultType, detectType(url))));
}
}
/** 未显式指定类型时,根据常见视频后缀推断,其余按图片处理。 */
private String detectType(String url)
{
String normalized = StringUtils.lowerCase(url);
return normalized.matches(".*\\.(mp4|avi|mov|mkv|webm)(\\?.*)?$") ? "VIDEO" : "IMAGE";
}
}

View File

@ -1,28 +1,23 @@
package com.cmvr.test.flow.runtime.operator.edge.ti;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.text.StrPool;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.cmvr.common.config.properties.MinioProperties;
import com.cmvr.common.core.minio.MinioService;
import com.cmvr.common.exception.GlobalException;
import com.cmvr.common.utils.http.CallAPIUtil;
import com.cmvr.llm.config.APIProperties;
import com.cmvr.llm.config.AgentConfig;
import com.cmvr.llm.util.LargeModelFileUploadUtil;
import com.cmvr.test.enums.ActionEnum;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
import com.cmvr.test.flow.runtime.operator.edge.EdgeOperateService;
import com.cmvr.test.service.ex.ExTiVehicleFunctionService;
import com.cmvr.test.service.ShangdaoFileService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.File;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
@ -34,9 +29,8 @@ import java.util.Map;
public class TiTouchOperateService implements EdgeOperateService {
private final ExTiVehicleFunctionService exTiVehicleFunctionService;
private final MinioService minioService;
private final MinioProperties minioProps;
private final APIProperties apiProperties;
private final ShangdaoFileService shangdaoFileService;
// 设置运动模式
public static final List<String> DRIVE_MODE_PATH = Arrays.asList("主页", "设置", "驾驶模式", "运动模式");
@ -81,11 +75,11 @@ public class TiTouchOperateService implements EdgeOperateService {
case TI_TOUCH_COORDINATES: {
// 页面图片(相机)
String imageUrl = inputParams.getString("imageUrl");
String pageImageUrl = uploadImageFromMinio(imageUrl);
String pageImageUrl = shangdaoFileService.uploadFromMinio(imageUrl);
// icon图片
String iconUrl = inputParams.getString("iconUrl");
String iconImageUrl = uploadImageFromMinio(iconUrl);
String iconImageUrl = shangdaoFileService.uploadFromMinio(iconUrl);
// 获取触控坐标
String touchCoordinates = getTouchCoordinates(pageImageUrl, iconImageUrl);
@ -109,10 +103,10 @@ public class TiTouchOperateService implements EdgeOperateService {
}
public String test(String imageUrl,String iconUrl) {
String pageImageUrl = uploadImageFromMinio(imageUrl);
String pageImageUrl = shangdaoFileService.uploadFromMinio(imageUrl);
// icon图片
String iconImageUrl = uploadImageFromMinio(iconUrl);
String iconImageUrl = shangdaoFileService.uploadFromMinio(iconUrl);
// 获取触控坐标
return getTouchCoordinates(pageImageUrl, iconImageUrl);
@ -142,29 +136,6 @@ public class TiTouchOperateService implements EdgeOperateService {
}
}
/**
* 根据 MinIO 文件访问地址上传到商道返回商道文件地址
*/
private String uploadImageFromMinio(String url) {
try {
String bucketName = minioProps.getBucketName();
String prefix = StrPool.SLASH + bucketName + StrPool.SLASH;
String objectName = StrUtil.removePrefix(
url,
StrUtil.subBefore(url, prefix, true) + prefix
);
File imageFile = minioService.getFile(bucketName, objectName);
byte[] imageBytes = FileUtil.readBytes(imageFile);
return LargeModelFileUploadUtil.uploadFile(imageBytes);
} catch (Exception e) {
log.error("上传 MinIO 文件到商道失败url={}", url, e);
throw new GlobalException("图片上传失败");
}
}
private String queryResultByRunId(String processId, AgentConfig agentConfig) {
Map<String, String> headers = new HashMap<>();
headers.put("Apikey", agentConfig.getAppKey());

View File

@ -0,0 +1,310 @@
package com.cmvr.test.flow.runtime.operator.llm;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.cmvr.common.exception.GlobalException;
import com.cmvr.llm.service.LLMAiAgentPlatformService;
import com.cmvr.test.enums.ActionEnum;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteResult;
import com.cmvr.test.model.vo.inspection.InspectionAlarmRuleVO;
import com.cmvr.test.model.vo.inspection.InspectionMeterRecognizeConfigVO;
import com.cmvr.test.service.ShangdaoFileService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 巡检仪表读数识别节点
*
* <p>输入参数见{@link InspectionMeterRecognizeConfigVO}节点先将MinIO图片上传商道
* 再调用固定智能体识别读数识别成功后执行全部告警规则命中多条规则时取最高级别</p>
*
* <p>节点输出包含valueunitresultStatus和alarmLevel同时通过内部字段
* {@code _inspectionResult}交给巡检事件监听器持久化该内部字段不需要前端配置</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class InspectionMeterRecognizeOperateService implements LLMOperateService
{
private static final String API_KEY = "d9gqfkd4shheenomcfcg";
private static final String RESULT_MARKER = "_inspectionResult";
private static final Pattern NUMBER_PATTERN =
Pattern.compile("[-+]?(?:\\d+(?:\\.\\d+)?|\\.\\d+)");
private final ShangdaoFileService shangdaoFileService;
private final LLMAiAgentPlatformService llmAiAgentPlatformService;
@Override
public boolean supports(ActionEnum action)
{
return ActionEnum.INSPECTION_METER_RECOGNIZE.equals(action);
}
@Override
public TaskNodeExecuteResult execute(TaskNodeExecuteMessage message)
{
JSONObject input = message.getInputParams() == null ? new JSONObject() : message.getInputParams();
InspectionMeterRecognizeConfigVO config = input.to(InspectionMeterRecognizeConfigVO.class);
String imageUrl = StringUtils.trimToNull(config.getImageUrl());
String resultName = StringUtils.defaultIfBlank(config.getResultName(), "仪表读数");
String configuredUnit = StringUtils.trimToNull(config.getUnit());
JSONArray ruleArray = input.getJSONArray("alarmRules");
List<AlarmRule> rules = parseRules(config.getAlarmRules());
// 商道文件地址有效期较短仅用于本次识别长期证据仍保存原始MinIO地址
String shangdaoUrl = shangdaoFileService.uploadFromMinio(imageUrl);
JSONObject response = llmAiAgentPlatformService.query(
ActionEnum.INSPECTION_METER_RECOGNIZE.getAction(), shangdaoUrl,
API_KEY, Boolean.FALSE, new JSONObject());
String rawResult = response == null ? null : response.getString("result");
Reading reading = parseReading(rawResult, configuredUnit);
JSONObject output = new JSONObject();
output.put("imageUrl", imageUrl);
output.put("rawResult", rawResult);
output.put("resultName", resultName);
JSONObject inspectionResult = new JSONObject();
inspectionResult.put("resultType", "METER");
inspectionResult.put("resultName", resultName);
inspectionResult.put("evidenceUrl", imageUrl);
inspectionResult.put("evidenceType", "IMAGE");
inspectionResult.put("rawResult", rawResult);
inspectionResult.put("ruleSnapshot", ruleArray == null ? new JSONArray() : ruleArray);
// 无法提取数值时保留原始响应并进入人工复核不执行阈值规则避免误报警
if (reading == null)
{
output.put("resultStatus", "RECOGNIZE_FAILED");
inspectionResult.put("resultStatus", "RECOGNIZE_FAILED");
inspectionResult.put("valueText", rawResult);
inspectionResult.put("alarmMessage", "商道返回结果无法解析为仪表读数");
output.put(RESULT_MARKER, inspectionResult);
return TaskNodeExecuteResult.success(output);
}
AlarmRule matchedRule = matchHighestRule(reading.value, rules);
String resultStatus = matchedRule == null ? "NORMAL" : "ABNORMAL";
String unit = StringUtils.defaultIfBlank(configuredUnit, reading.unit);
output.put("value", reading.value);
output.put("unit", unit);
output.put("resultStatus", resultStatus);
output.put("alarmLevel", matchedRule == null ? null : matchedRule.level);
inspectionResult.put("resultStatus", resultStatus);
inspectionResult.put("valueNumber", reading.value);
inspectionResult.put("valueText", reading.value.toPlainString());
inspectionResult.put("unit", unit);
if (matchedRule != null)
{
inspectionResult.put("alarmLevel", matchedRule.level);
inspectionResult.put("alarmMessage", matchedRule.message);
inspectionResult.put("matchedRule", matchedRule.source);
}
output.put(RESULT_MARKER, inspectionResult);
return TaskNodeExecuteResult.success(output);
}
/**
* 校验并转换前端配置的多级告警规则
*/
private List<AlarmRule> parseRules(List<InspectionAlarmRuleVO> sourceRules)
{
List<AlarmRule> result = new ArrayList<>();
if (sourceRules == null)
{
return result;
}
for (int i = 0; i < sourceRules.size(); i++)
{
InspectionAlarmRuleVO source = sourceRules.get(i);
if (source == null)
{
throw new GlobalException("alarmRules[" + i + "]格式错误");
}
Integer level = source.getLevel();
if (level == null || level < 1 || level > 3)
{
throw new GlobalException("alarmRules[" + i + "].level只能为1、2、3");
}
String operator = StringUtils.upperCase(StringUtils.trimToEmpty(source.getOperator()));
validateOperator(operator, i);
AlarmRule rule = new AlarmRule();
rule.level = level;
rule.operator = operator;
rule.threshold = source.getThreshold();
rule.minValue = source.getMinValue();
rule.maxValue = source.getMaxValue();
rule.includeMin = source.getIncludeMin() == null || source.getIncludeMin();
rule.includeMax = source.getIncludeMax() == null || source.getIncludeMax();
rule.message = StringUtils.defaultIfBlank(source.getMessage(), "仪表读数触发告警");
rule.source = JSON.parseObject(JSON.toJSONString(source));
validateThreshold(rule, i);
result.add(rule);
}
return result;
}
private void validateOperator(String operator, int index)
{
if (!("GT".equals(operator) || "GTE".equals(operator) || "LT".equals(operator)
|| "LTE".equals(operator) || "EQ".equals(operator) || "BETWEEN".equals(operator)
|| "NOT_BETWEEN".equals(operator)))
{
throw new GlobalException("alarmRules[" + index + "].operator不支持: " + operator);
}
}
private void validateThreshold(AlarmRule rule, int index)
{
boolean between = "BETWEEN".equals(rule.operator) || "NOT_BETWEEN".equals(rule.operator);
if (between)
{
if (rule.minValue == null || rule.maxValue == null || rule.minValue.compareTo(rule.maxValue) > 0)
{
throw new GlobalException("alarmRules[" + index + "]区间上下界配置错误");
}
}
else if (rule.threshold == null)
{
throw new GlobalException("alarmRules[" + index + "].threshold不能为空");
}
}
/** 命中多条重叠规则时按level取最高严重级别与前端数组顺序无关。 */
private AlarmRule matchHighestRule(BigDecimal value, List<AlarmRule> rules)
{
return rules.stream()
.filter(rule -> rule.matches(value))
.max(Comparator.comparingInt(rule -> rule.level))
.orElse(null);
}
/**
* 解析商道结果优先读取标准JSON字段兼容纯文本数字返回
*/
private Reading parseReading(String rawResult, String configuredUnit)
{
String text = StringUtils.trimToNull(rawResult);
if (text == null)
{
return null;
}
String cleaned = text.replace("```json", "").replace("```", "").trim();
try
{
Object parsed = JSON.parse(cleaned);
if (parsed instanceof JSONObject)
{
JSONObject object = (JSONObject) parsed;
BigDecimal value = firstDecimal(object, "value", "reading", "meterValue", "result");
if (value != null)
{
return new Reading(value,
StringUtils.defaultIfBlank(object.getString("unit"), configuredUnit));
}
}
}
catch (Exception ignored)
{
log.debug("商道仪表结果不是标准JSON尝试提取数字rawResult={}", text);
}
Matcher matcher = NUMBER_PATTERN.matcher(cleaned);
if (!matcher.find())
{
return null;
}
try
{
return new Reading(new BigDecimal(matcher.group()), configuredUnit);
}
catch (NumberFormatException ex)
{
return null;
}
}
private BigDecimal firstDecimal(JSONObject object, String... keys)
{
for (String key : keys)
{
Object value = object.get(key);
if (value instanceof Number || value instanceof String)
{
try
{
return new BigDecimal(String.valueOf(value));
}
catch (NumberFormatException ignored)
{
// 继续尝试下一个标准字段
}
}
}
return null;
}
private static final class Reading
{
private final BigDecimal value;
private final String unit;
private Reading(BigDecimal value, String unit)
{
this.value = value;
this.unit = unit;
}
}
private static final class AlarmRule
{
private int level;
private String operator;
private BigDecimal threshold;
private BigDecimal minValue;
private BigDecimal maxValue;
private boolean includeMin;
private boolean includeMax;
private String message;
private JSONObject source;
private boolean matches(BigDecimal value)
{
int thresholdCompare = threshold == null ? 0 : value.compareTo(threshold);
switch (operator.toUpperCase(Locale.ROOT))
{
case "GT": return thresholdCompare > 0;
case "GTE": return thresholdCompare >= 0;
case "LT": return thresholdCompare < 0;
case "LTE": return thresholdCompare <= 0;
case "EQ": return thresholdCompare == 0;
case "BETWEEN": return within(value);
case "NOT_BETWEEN": return !within(value);
default: return false;
}
}
private boolean within(BigDecimal value)
{
int minCompare = value.compareTo(minValue);
int maxCompare = value.compareTo(maxValue);
boolean lowerMatched = includeMin ? minCompare >= 0 : minCompare > 0;
boolean upperMatched = includeMax ? maxCompare <= 0 : maxCompare < 0;
return lowerMatched && upperMatched;
}
}
}

View File

@ -1,21 +1,28 @@
package com.cmvr.test.model.vo;
import com.alibaba.fastjson2.JSONObject;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* 工作流节点执行请求
*/
@Data
@ApiModel(value = "FlowActionRequestVO", description = "工作流单节点试执行请求")
public class FlowActionRequestVO {
/**
* 动作
*/
@ApiModelProperty(value = "动作编码。巡检新增动作INSPECTION_METER_RECOGNIZE、INSPECTION_MANUAL_REVIEW_CREATE",
required = true, example = "INSPECTION_METER_RECOGNIZE")
private String action;
/**
* 执行参数
*/
@ApiModelProperty(value = "节点参数。仪表识别参见InspectionMeterRecognizeConfigVO人工判断参见InspectionManualReviewConfigVO",
required = true)
private JSONObject payload;
}

View File

@ -0,0 +1,39 @@
package com.cmvr.test.model.vo.inspection;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
/** 仪表读数的单条告警规则配置。 */
@Data
@ApiModel(value = "InspectionAlarmRuleVO", description = "仪表读数多级告警规则")
public class InspectionAlarmRuleVO
{
@ApiModelProperty(value = "告警级别1提示、2警告、3严重", required = true,
allowableValues = "1,2,3", example = "2")
private Integer level;
@ApiModelProperty(value = "比较运算符", required = true,
allowableValues = "GT,GTE,LT,LTE,EQ,BETWEEN,NOT_BETWEEN", example = "GTE")
private String operator;
@ApiModelProperty(value = "GT/GTE/LT/LTE/EQ使用的比较阈值", example = "1.6")
private BigDecimal threshold;
@ApiModelProperty(value = "BETWEEN/NOT_BETWEEN使用的区间下界", example = "1.6")
private BigDecimal minValue;
@ApiModelProperty(value = "BETWEEN/NOT_BETWEEN使用的区间上界", example = "2.0")
private BigDecimal maxValue;
@ApiModelProperty(value = "区间规则是否包含下界默认true", example = "true")
private Boolean includeMin;
@ApiModelProperty(value = "区间规则是否包含上界默认true", example = "false")
private Boolean includeMax;
@ApiModelProperty(value = "命中规则后的告警描述", example = "压力过高")
private String message;
}

View File

@ -0,0 +1,33 @@
package com.cmvr.test.model.vo.inspection;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.List;
/** 创建人工巡检复核任务的工作流节点参数。 */
@Data
@ApiModel(value = "InspectionManualReviewConfigVO",
description = "INSPECTION_MANUAL_REVIEW_CREATE节点参数imageUrl、videoUrl、mediaList至少提供一个")
public class InspectionManualReviewConfigVO
{
@ApiModelProperty(value = "人工检查项名称,默认人工巡检", example = "设备外观检查")
private String resultName;
@ApiModelProperty("单张MinIO图片地址可引用上游拍照节点输出")
private String imageUrl;
@ApiModelProperty("单个MinIO视频地址可引用上游录像节点输出")
private String videoUrl;
@ApiModelProperty("多张图片或视频可与imageUrl、videoUrl同时使用")
private List<InspectionMediaVO> mediaList;
@ApiModelProperty(value = "供审核人员参考的判断标准", example = "检查设备是否破损、漏油")
private String reviewCriteria;
@ApiModelProperty(value = "建议告警级别,仅作为审核参考;提交异常复核时仍需明确选择",
allowableValues = "1,2,3", example = "2")
private Integer defaultAlarmLevel;
}

View File

@ -0,0 +1,18 @@
package com.cmvr.test.model.vo.inspection;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/** 人工巡检节点的一条媒体证据。 */
@Data
@ApiModel(value = "InspectionMediaVO", description = "人工巡检图片或视频参数")
public class InspectionMediaVO
{
@ApiModelProperty(value = "媒体类型", required = true,
allowableValues = "IMAGE,VIDEO", example = "IMAGE")
private String mediaType;
@ApiModelProperty(value = "MinIO永久媒体地址", required = true)
private String mediaUrl;
}

View File

@ -0,0 +1,27 @@
package com.cmvr.test.model.vo.inspection;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.List;
/** 仪表读数识别工作流节点参数。 */
@Data
@ApiModel(value = "InspectionMeterRecognizeConfigVO",
description = "INSPECTION_METER_RECOGNIZE节点参数")
public class InspectionMeterRecognizeConfigVO
{
@ApiModelProperty(value = "前置拍照节点输出的MinIO图片地址", required = true,
example = "http://127.0.0.1:9000/cmvr/inspection/pressure.jpg")
private String imageUrl;
@ApiModelProperty(value = "仪表或检查项名称,默认仪表读数", example = "1号压力表")
private String resultName;
@ApiModelProperty(value = "仪表单位", example = "MPa")
private String unit;
@ApiModelProperty("多级告警规则;为空时只识别和保存,不产生读数告警")
private List<InspectionAlarmRuleVO> alarmRules;
}

View File

@ -18,6 +18,9 @@ import com.cmvr.edge.client.service.EdgeArmService;
import com.cmvr.device.service.InspectionAlertListenService;
import com.cmvr.llm.service.LLMAiAgentPlatformService;
import com.cmvr.test.enums.ActionEnum;
import com.cmvr.test.flow.runtime.message.TaskNodeExecuteMessage;
import com.cmvr.test.flow.runtime.operator.edge.EdgeManualInspectionOperateService;
import com.cmvr.test.flow.runtime.operator.llm.InspectionMeterRecognizeOperateService;
import com.cmvr.test.model.vo.FlowActionRequestVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -36,6 +39,8 @@ public class FlowActionExecutorService {
private final EdgeAgvService edgeAgvService;
private final InspectionAlertListenService inspectionAlertListenService;
private final EdgeArmService edgeArmService;
private final EdgeManualInspectionOperateService edgeManualInspectionOperateService;
private final InspectionMeterRecognizeOperateService inspectionMeterRecognizeOperateService;
public String actionExecute(FlowActionRequestVO req) {
@ -67,6 +72,10 @@ public class FlowActionExecutorService {
|| ActionEnum.INSPECTION_ALERT_LISTEN_STOP.equals(action)) {
return executeInspectionAlertAction(action, payload);
}
if (ActionEnum.INSPECTION_MANUAL_REVIEW_CREATE.equals(action)) {
TaskNodeExecuteMessage message = buildSingleNodeMessage(action, payload);
return edgeManualInspectionOperateService.execute(message).getOutputParams().toJSONString();
}
if (StrUtil.isEmpty(terminalId) || StrUtil.isEmpty(deviceId)) {
throw new GlobalException("EDGE 类型动作必须提供 terminalId 和 deviceId");
@ -248,9 +257,19 @@ public class FlowActionExecutorService {
return "LLM 高级音频生成 OK";
case AI_AGENT_PLATFORM:
return llmAiAgentPlatformService.query(action.getAction(), req.getPayload().getJSONObject("config").getString("text"), req.getPayload().getJSONObject("config").getString("apiKey"), req.getPayload().getBoolean("invokeTts"), req.getPayload().getJSONObject("tts")).toString();
case INSPECTION_METER_RECOGNIZE:
return inspectionMeterRecognizeOperateService.execute(
buildSingleNodeMessage(action, req.getPayload())).getOutputParams().toJSONString();
default:
throw new UnsupportedOperationException("未实现的 LLM Action: " + action);
}
}
private TaskNodeExecuteMessage buildSingleNodeMessage(ActionEnum action, JSONObject payload) {
TaskNodeExecuteMessage message = new TaskNodeExecuteMessage();
message.setAction(action);
message.setInputParams(payload == null ? new JSONObject() : payload);
return message;
}
}

View File

@ -0,0 +1,121 @@
package com.cmvr.test.service;
import com.cmvr.common.config.properties.MinioProperties;
import com.cmvr.common.core.minio.MinioService;
import com.cmvr.common.exception.GlobalException;
import com.cmvr.llm.util.LargeModelFileUploadUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.io.File;
import java.nio.file.Files;
import java.net.URI;
import java.net.URLDecoder;
/**
* MinIO文件转存商道服务统一处理对象地址解析和大小限制
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ShangdaoFileService
{
private static final int MAX_FILE_BYTES = 20 * 1024 * 1024;
private final MinioService minioService;
private final MinioProperties minioProperties;
/**
* 下载指定MinIO对象并上传商道
*
* @param fileUrl 包含bucket的MinIO访问地址或bucket内对象路径
* @return 商道临时下载地址仅用于随后调用大模型
*/
public String uploadFromMinio(String fileUrl)
{
String objectName = resolveObjectName(fileUrl);
File temporaryFile = null;
try
{
temporaryFile = minioService.getFile(minioProperties.getBucketName(), objectName);
if (temporaryFile.length() > MAX_FILE_BYTES)
{
throw new GlobalException("巡检媒体文件不能超过20MB");
}
byte[] bytes = Files.readAllBytes(temporaryFile.toPath());
String shangdaoUrl = LargeModelFileUploadUtil.uploadFile(bytes);
if (StringUtils.isBlank(shangdaoUrl))
{
throw new GlobalException("商道文件上传未返回文件地址");
}
return shangdaoUrl;
}
catch (GlobalException ex)
{
throw ex;
}
catch (Exception ex)
{
log.error("MinIO文件上传商道失败fileUrl={}objectName={}", fileUrl, objectName, ex);
throw new GlobalException("巡检图片上传商道失败");
}
finally
{
if (temporaryFile != null && temporaryFile.exists() && !temporaryFile.delete())
{
log.warn("删除MinIO临时文件失败path={}", temporaryFile.getAbsolutePath());
}
}
}
/**
* 从完整URL或对象路径中提取当前bucket内的objectName并拒绝目录穿越路径
*/
private String resolveObjectName(String fileUrl)
{
String value = StringUtils.trimToNull(fileUrl);
if (value == null)
{
throw new GlobalException("imageUrl不能为空");
}
String bucketName = StringUtils.trimToEmpty(minioProperties.getBucketName());
try
{
String path = value;
if (value.contains("://"))
{
path = new URI(value).getRawPath();
}
path = URLDecoder.decode(path, "UTF-8").replace('\\', '/');
String bucketPrefix = "/" + bucketName + "/";
int bucketIndex = path.indexOf(bucketPrefix);
if (bucketIndex >= 0)
{
path = path.substring(bucketIndex + bucketPrefix.length());
}
else
{
path = StringUtils.removeStart(path, "/");
path = StringUtils.removeStart(path, bucketName + "/");
}
String objectName = StringUtils.trimToNull(path);
if (objectName == null || objectName.contains(".."))
{
throw new GlobalException("imageUrl不是有效的MinIO对象地址");
}
return objectName;
}
catch (GlobalException ex)
{
throw ex;
}
catch (Exception ex)
{
throw new GlobalException("无法解析MinIO文件地址: " + value);
}
}
}

View File

@ -124,6 +124,10 @@ CREATE TABLE `inspection_alarm` (
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
`alarm_code` varchar(64) DEFAULT NULL COMMENT '告警编码',
`task_instance_id` varchar(64) DEFAULT NULL COMMENT '任务执行实例ID',
`result_id` varchar(64) DEFAULT NULL COMMENT '关联巡检结果ID',
`alarm_source` varchar(20) DEFAULT NULL COMMENT '告警来源(PPE/METER/MANUAL)',
`item_id` varchar(64) DEFAULT NULL COMMENT '检测项ID',
`node_id` varchar(64) DEFAULT NULL COMMENT '工作流节点ID',
`task_id` varchar(64) DEFAULT NULL COMMENT '任务ID',
`task_name` varchar(100) DEFAULT NULL COMMENT '任务名称',
`robot_id` varchar(64) DEFAULT NULL COMMENT '机器人ID',
@ -141,6 +145,7 @@ CREATE TABLE `inspection_alarm` (
`evidence_image` varchar(500) DEFAULT NULL COMMENT '图片证据',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_alarm_code` (`alarm_code`),
UNIQUE KEY `uk_alarm_result_id` (`result_id`),
KEY `idx_task_instance_id` (`task_instance_id`),
KEY `idx_task_id` (`task_id`),
KEY `idx_robot_id` (`robot_id`),
@ -177,3 +182,64 @@ CREATE TABLE `inspection_task_log` (
KEY `idx_log_time` (`log_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='巡检任务执行日志表';
-- 6. 统一巡检结果表
DROP TABLE IF EXISTS `inspection_result_media`;
DROP TABLE IF EXISTS `inspection_result`;
CREATE TABLE `inspection_result` (
`id` varchar(32) NOT NULL COMMENT '主键ID',
`result_code` varchar(64) NOT NULL COMMENT '结果编码',
`dedupe_key` varchar(255) NOT NULL COMMENT '结果幂等键',
`task_instance_id` varchar(64) DEFAULT NULL COMMENT '巡检任务实例数据库ID',
`flow_instance_id` varchar(64) DEFAULT NULL COMMENT '工作流实例ID',
`task_id` varchar(64) DEFAULT NULL COMMENT '巡检任务ID',
`item_id` varchar(64) DEFAULT NULL COMMENT '检测项ID',
`node_id` varchar(64) DEFAULT NULL COMMENT '工作流节点ID',
`node_name` varchar(255) DEFAULT NULL COMMENT '工作流节点名称',
`iteration_path` varchar(255) DEFAULT NULL COMMENT '循环迭代路径JSON',
`result_type` varchar(20) NOT NULL COMMENT '结果类型(PPE/METER/MANUAL)',
`result_status` varchar(32) NOT NULL COMMENT 'PENDING/NORMAL/ABNORMAL/RECOGNIZE_FAILED',
`result_name` varchar(255) NOT NULL COMMENT '检查名称',
`value_number` decimal(20,8) DEFAULT NULL COMMENT '结构化数值',
`value_text` varchar(1000) DEFAULT NULL COMMENT '文本结果',
`unit` varchar(32) DEFAULT NULL COMMENT '单位',
`alarm_level` tinyint DEFAULT NULL COMMENT '告警级别(1提示 2警告 3严重)',
`alarm_message` varchar(1000) DEFAULT NULL COMMENT '告警描述',
`confidence` decimal(10,8) DEFAULT NULL COMMENT '模型置信度',
`source_event_id` varchar(128) DEFAULT NULL COMMENT '外部PPE事件ID',
`evidence_url` varchar(1000) DEFAULT NULL COMMENT '主要MinIO证据地址',
`evidence_type` varchar(20) DEFAULT NULL COMMENT '主要证据类型(IMAGE/VIDEO)',
`raw_result` mediumtext COMMENT '模型或边缘端原始返回',
`matched_rule_json` text COMMENT '最终命中的报警规则快照',
`rule_snapshot_json` mediumtext COMMENT '本次执行使用的完整规则快照',
`reviewer` varchar(64) DEFAULT NULL COMMENT '人工复核人',
`review_time` datetime DEFAULT NULL COMMENT '人工复核时间',
`review_remark` varchar(1000) DEFAULT NULL COMMENT '人工复核说明',
`review_version` int NOT NULL DEFAULT 0 COMMENT '人工复核乐观锁版本',
`alarm_id` varchar(64) DEFAULT NULL COMMENT '关联告警ID',
`occurred_time` datetime NOT NULL COMMENT '结果发生时间',
`create_by` varchar(64) DEFAULT '' COMMENT '创建者',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT '' COMMENT '更新者',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_inspection_result_code` (`result_code`),
UNIQUE KEY `uk_inspection_result_dedupe` (`dedupe_key`),
KEY `idx_result_task_instance` (`task_instance_id`),
KEY `idx_result_flow_instance` (`flow_instance_id`),
KEY `idx_result_status` (`result_status`),
KEY `idx_result_type` (`result_type`),
KEY `idx_result_occurred_time` (`occurred_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一巡检结果表';
-- 7. 巡检结果媒体证据表
CREATE TABLE `inspection_result_media` (
`id` varchar(32) NOT NULL COMMENT '主键ID',
`result_id` varchar(32) NOT NULL COMMENT '巡检结果ID',
`media_type` varchar(20) NOT NULL COMMENT 'IMAGE或VIDEO',
`media_url` varchar(1000) NOT NULL COMMENT 'MinIO媒体地址',
`sort_order` int NOT NULL DEFAULT 0 COMMENT '排序号',
`create_time` datetime NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_result_media_result_id` (`result_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='巡检结果媒体证据表';

91
sql/inspection_result.sql Normal file
View File

@ -0,0 +1,91 @@
-- 统一巡检结果和三级告警增量脚本。
-- 执行前请先备份数据库;本脚本不删除现有巡检数据。
CREATE TABLE IF NOT EXISTS `inspection_result` (
`id` varchar(32) NOT NULL COMMENT '主键ID',
`result_code` varchar(64) NOT NULL COMMENT '结果编码',
`dedupe_key` varchar(255) NOT NULL COMMENT '结果幂等键',
`task_instance_id` varchar(64) DEFAULT NULL COMMENT '巡检任务实例数据库ID',
`flow_instance_id` varchar(64) DEFAULT NULL COMMENT '工作流实例ID',
`task_id` varchar(64) DEFAULT NULL COMMENT '巡检任务ID',
`item_id` varchar(64) DEFAULT NULL COMMENT '检测项ID',
`node_id` varchar(64) DEFAULT NULL COMMENT '工作流节点ID',
`node_name` varchar(255) DEFAULT NULL COMMENT '工作流节点名称',
`iteration_path` varchar(255) DEFAULT NULL COMMENT '循环迭代路径JSON',
`result_type` varchar(20) NOT NULL COMMENT 'PPE/METER/MANUAL',
`result_status` varchar(32) NOT NULL COMMENT 'PENDING/NORMAL/ABNORMAL/RECOGNIZE_FAILED',
`result_name` varchar(255) NOT NULL COMMENT '检查名称',
`value_number` decimal(20,8) DEFAULT NULL COMMENT '结构化数值',
`value_text` varchar(1000) DEFAULT NULL COMMENT '文本结果',
`unit` varchar(32) DEFAULT NULL COMMENT '单位',
`alarm_level` tinyint DEFAULT NULL COMMENT '告警级别(1提示 2警告 3严重)',
`alarm_message` varchar(1000) DEFAULT NULL COMMENT '告警描述',
`confidence` decimal(10,8) DEFAULT NULL COMMENT '模型置信度',
`source_event_id` varchar(128) DEFAULT NULL COMMENT '外部PPE事件ID',
`evidence_url` varchar(1000) DEFAULT NULL COMMENT '主要MinIO证据地址',
`evidence_type` varchar(20) DEFAULT NULL COMMENT '主要证据类型(IMAGE/VIDEO)',
`raw_result` mediumtext COMMENT '模型或边缘端原始返回',
`matched_rule_json` text COMMENT '最终命中的报警规则',
`rule_snapshot_json` mediumtext COMMENT '完整报警规则快照',
`reviewer` varchar(64) DEFAULT NULL COMMENT '人工复核人',
`review_time` datetime DEFAULT NULL COMMENT '人工复核时间',
`review_remark` varchar(1000) DEFAULT NULL COMMENT '人工复核说明',
`review_version` int NOT NULL DEFAULT 0 COMMENT '人工复核版本',
`alarm_id` varchar(64) DEFAULT NULL COMMENT '关联告警ID',
`occurred_time` datetime NOT NULL COMMENT '结果发生时间',
`create_by` varchar(64) DEFAULT '' COMMENT '创建者',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT '' COMMENT '更新者',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_inspection_result_code` (`result_code`),
UNIQUE KEY `uk_inspection_result_dedupe` (`dedupe_key`),
KEY `idx_result_task_instance` (`task_instance_id`),
KEY `idx_result_flow_instance` (`flow_instance_id`),
KEY `idx_result_status` (`result_status`),
KEY `idx_result_type` (`result_type`),
KEY `idx_result_occurred_time` (`occurred_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一巡检结果表';
CREATE TABLE IF NOT EXISTS `inspection_result_media` (
`id` varchar(32) NOT NULL COMMENT '主键ID',
`result_id` varchar(32) NOT NULL COMMENT '巡检结果ID',
`media_type` varchar(20) NOT NULL COMMENT 'IMAGE或VIDEO',
`media_url` varchar(1000) NOT NULL COMMENT 'MinIO媒体地址',
`sort_order` int NOT NULL DEFAULT 0 COMMENT '排序号',
`create_time` datetime NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_result_media_result_id` (`result_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='巡检结果媒体证据表';
-- 已存在inspection_result表时补充主要证据类型字段。
SET @sql := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='inspection_result' AND column_name='evidence_type')=0,
'ALTER TABLE inspection_result ADD COLUMN evidence_type varchar(20) DEFAULT NULL COMMENT ''主要证据类型(IMAGE/VIDEO)'' AFTER evidence_url', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 回填历史结果使用与主表evidence_url完全一致的媒体记录避免取错非主要证据。
UPDATE inspection_result r
JOIN inspection_result_media m ON m.result_id = r.id AND m.media_url = r.evidence_url
SET r.evidence_type = m.media_type
WHERE r.evidence_type IS NULL;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='inspection_alarm' AND column_name='result_id')=0,
'ALTER TABLE inspection_alarm ADD COLUMN result_id varchar(64) DEFAULT NULL COMMENT ''关联巡检结果ID'' AFTER task_instance_id', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='inspection_alarm' AND column_name='alarm_source')=0,
'ALTER TABLE inspection_alarm ADD COLUMN alarm_source varchar(20) DEFAULT NULL COMMENT ''告警来源'' AFTER result_id', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='inspection_alarm' AND column_name='item_id')=0,
'ALTER TABLE inspection_alarm ADD COLUMN item_id varchar(64) DEFAULT NULL COMMENT ''检测项ID'' AFTER alarm_source', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='inspection_alarm' AND column_name='node_id')=0,
'ALTER TABLE inspection_alarm ADD COLUMN node_id varchar(64) DEFAULT NULL COMMENT ''工作流节点ID'' AFTER item_id', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='inspection_alarm' AND index_name='uk_alarm_result_id')=0,
'ALTER TABLE inspection_alarm ADD UNIQUE KEY uk_alarm_result_id (result_id)', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;