diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiAlertServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiAlertServiceImpl.java new file mode 100644 index 000000000..1c462c6db --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiAlertServiceImpl.java @@ -0,0 +1,71 @@ +package com.genersoft.iot.vmp.aiot.service.impl; + +import com.genersoft.iot.vmp.aiot.bean.AiAlert; +import com.genersoft.iot.vmp.aiot.dao.AiAlertMapper; +import com.genersoft.iot.vmp.aiot.service.IAiAlertService; +import com.github.pagehelper.PageHelper; +import com.github.pagehelper.PageInfo; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Slf4j +@Service +public class AiAlertServiceImpl implements IAiAlertService { + + @Autowired + private AiAlertMapper alertMapper; + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Override + public void save(AiAlert alert) { + if (alert.getReceivedAt() == null) { + alert.setReceivedAt(LocalDateTime.now().format(FORMATTER)); + } + // 防止重复插入 + AiAlert existing = alertMapper.queryByAlertId(alert.getAlertId()); + if (existing != null) { + log.debug("[AiAlert] 告警已存在, 跳过: alertId={}", alert.getAlertId()); + return; + } + alertMapper.add(alert); + log.info("[AiAlert] 告警入库: alertId={}, type={}, camera={}", alert.getAlertId(), alert.getAlertType(), alert.getCameraId()); + } + + @Override + public AiAlert queryByAlertId(String alertId) { + return alertMapper.queryByAlertId(alertId); + } + + @Override + public PageInfo queryList(String cameraId, String alertType, String startTime, String endTime, int page, int count) { + PageHelper.startPage(page, count); + List list = alertMapper.queryList(cameraId, alertType, startTime, endTime); + return new PageInfo<>(list); + } + + @Override + public void delete(String alertId) { + alertMapper.deleteByAlertId(alertId); + } + + @Override + public void deleteBatch(List alertIds) { + if (alertIds != null && !alertIds.isEmpty()) { + alertMapper.deleteByAlertIds(alertIds); + } + } + + @Override + public Map statistics(String startTime) { + Map result = new LinkedHashMap<>(); + result.put("by_type", alertMapper.statisticsByType(startTime)); + result.put("by_camera", alertMapper.statisticsByCamera(startTime)); + return result; + } +} diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiAlgoTemplateServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiAlgoTemplateServiceImpl.java new file mode 100644 index 000000000..b6b8298a6 --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiAlgoTemplateServiceImpl.java @@ -0,0 +1,132 @@ +package com.genersoft.iot.vmp.aiot.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.genersoft.iot.vmp.aiot.bean.AiAlgoTemplate; +import com.genersoft.iot.vmp.aiot.bean.AiAlgorithm; +import com.genersoft.iot.vmp.aiot.bean.AiRoiAlgoBind; +import com.genersoft.iot.vmp.aiot.dao.AiAlgoTemplateMapper; +import com.genersoft.iot.vmp.aiot.dao.AiAlgorithmMapper; +import com.genersoft.iot.vmp.aiot.dao.AiRoiAlgoBindMapper; +import com.genersoft.iot.vmp.aiot.service.IAiAlgoTemplateService; +import com.genersoft.iot.vmp.aiot.service.IAiRedisConfigService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ObjectUtils; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Slf4j +@Service +public class AiAlgoTemplateServiceImpl implements IAiAlgoTemplateService { + + @Autowired + private AiAlgoTemplateMapper templateMapper; + + @Autowired + private AiRoiAlgoBindMapper bindMapper; + + @Autowired + private AiAlgorithmMapper algorithmMapper; + + @Autowired + private IAiRedisConfigService redisConfigService; + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Override + @Transactional + public void save(AiAlgoTemplate template) { + String now = LocalDateTime.now().format(FORMATTER); + template.setUpdateTime(now); + + if (!ObjectUtils.isEmpty(template.getTemplateId())) { + AiAlgoTemplate existing = templateMapper.queryByTemplateId(template.getTemplateId()); + if (existing != null) { + templateMapper.updateByTemplateId(template); + // 级联更新引用此模板的绑定 + cascadeUpdate(template.getTemplateId()); + log.info("[AiTemplate] 更新模板并级联: templateId={}", template.getTemplateId()); + return; + } + } + + if (ObjectUtils.isEmpty(template.getTemplateId())) { + template.setTemplateId(UUID.randomUUID().toString()); + } + template.setCreateTime(now); + templateMapper.add(template); + log.info("[AiTemplate] 创建模板: templateId={}", template.getTemplateId()); + } + + @Override + @Transactional + public void delete(String templateId) { + // 检查是否有绑定引用 + List refs = bindMapper.queryByTemplateId(templateId); + if (refs != null && !refs.isEmpty()) { + throw new IllegalArgumentException("模板正在被" + refs.size() + "个绑定引用,无法删除。请先解除引用。"); + } + templateMapper.deleteByTemplateId(templateId); + log.info("[AiTemplate] 删除模板: templateId={}", templateId); + } + + @Override + public AiAlgoTemplate queryByTemplateId(String templateId) { + return templateMapper.queryByTemplateId(templateId); + } + + @Override + public List queryList(String algoCode) { + return templateMapper.queryList(algoCode); + } + + @Override + @Transactional + public void cascadeUpdate(String templateId) { + AiAlgoTemplate template = templateMapper.queryByTemplateId(templateId); + if (template == null) return; + + List binds = bindMapper.queryByTemplateId(templateId); + if (binds == null || binds.isEmpty()) return; + + List affectedBindIds = new ArrayList<>(); + for (AiRoiAlgoBind bind : binds) { + String effectiveParams = mergeParams(template.getParams(), bind.getParamOverride()); + AiAlgorithm algo = algorithmMapper.queryByCode(bind.getAlgoCode()); + String algoName = algo != null ? algo.getAlgoName() : ""; + String targetClass = algo != null ? algo.getTargetClass() : "person"; + + redisConfigService.writeBindToRedis(bind, algoName, targetClass, effectiveParams); + affectedBindIds.add(bind.getBindId()); + } + + redisConfigService.publishConfigUpdate("bind", affectedBindIds); + log.info("[AiTemplate] 级联更新完成: templateId={}, 影响绑定数={}", templateId, affectedBindIds.size()); + } + + /** + * 参数合并:template.params 为基础,param_override 覆盖 + */ + @SuppressWarnings("unchecked") + public static String mergeParams(String templateParams, String paramOverride) { + if (ObjectUtils.isEmpty(templateParams)) { + return paramOverride != null ? paramOverride : "{}"; + } + if (ObjectUtils.isEmpty(paramOverride)) { + return templateParams; + } + try { + Map base = JSON.parseObject(templateParams, LinkedHashMap.class); + Map override = JSON.parseObject(paramOverride, LinkedHashMap.class); + base.putAll(override); + return JSON.toJSONString(base); + } catch (Exception e) { + log.warn("[AiTemplate] 参数合并失败, 使用模板参数: templateParams={}, override={}", templateParams, paramOverride); + return templateParams; + } + } +} diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiConfigSnapshotServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiConfigSnapshotServiceImpl.java new file mode 100644 index 000000000..51066d524 --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiConfigSnapshotServiceImpl.java @@ -0,0 +1,351 @@ +package com.genersoft.iot.vmp.aiot.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.genersoft.iot.vmp.aiot.bean.*; +import com.genersoft.iot.vmp.aiot.dao.AiConfigSnapshotMapper; +import com.genersoft.iot.vmp.aiot.dao.AiRoiAlgoBindMapper; +import com.genersoft.iot.vmp.aiot.dao.AiRoiMapper; +import com.genersoft.iot.vmp.aiot.service.IAiConfigService; +import com.genersoft.iot.vmp.aiot.service.IAiConfigSnapshotService; +import com.genersoft.iot.vmp.aiot.service.IAiRedisConfigService; +import com.github.pagehelper.PageHelper; +import com.github.pagehelper.PageInfo; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Slf4j +@Service +public class AiConfigSnapshotServiceImpl implements IAiConfigSnapshotService { + + @Autowired + private AiConfigSnapshotMapper snapshotMapper; + + @Autowired + private AiRoiMapper roiMapper; + + @Autowired + private AiRoiAlgoBindMapper bindMapper; + + @Lazy + @Autowired + private IAiConfigService configService; + + @Autowired + private IAiRedisConfigService redisConfigService; + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Override + public AiConfigSnapshot createSnapshot(String scopeType, String scopeId, String cameraId, + String snapshot, String changeType, String changeDesc, String createdBy) { + int maxVersion = snapshotMapper.getMaxVersion(scopeType, scopeId); + AiConfigSnapshot s = new AiConfigSnapshot(); + s.setVersion(maxVersion + 1); + s.setScopeType(scopeType); + s.setScopeId(scopeId); + s.setCameraId(cameraId); + s.setSnapshot(snapshot); + s.setChangeType(changeType); + s.setChangeDesc(changeDesc); + s.setCreatedBy(createdBy); + s.setCreatedAt(LocalDateTime.now().format(FORMATTER)); + snapshotMapper.add(s); + log.info("[AiSnapshot] 创建快照: scope={}/{}, version={}, type={}", scopeType, scopeId, s.getVersion(), changeType); + return s; + } + + @Override + public PageInfo queryVersions(String scopeType, String scopeId, String cameraId, int page, int count) { + PageHelper.startPage(page, count); + List list = snapshotMapper.queryList(scopeType, scopeId, cameraId); + return new PageInfo<>(list); + } + + @Override + public AiConfigSnapshot queryById(Long id) { + return snapshotMapper.queryById(id); + } + + @Override + @Transactional + public void rollbackCamera(String cameraId, Integer targetVersion, String operator) { + AiConfigSnapshot snapshot = snapshotMapper.queryByVersion("CAMERA", cameraId, targetVersion); + if (snapshot == null) { + throw new IllegalArgumentException("快照不存在: CAMERA/" + cameraId + "/v" + targetVersion); + } + + JSONObject config = JSON.parseObject(snapshot.getSnapshot()); + JSONArray rois = config.getJSONArray("rois"); + + // 删除当前所有ROI和绑定 + List currentRois = roiMapper.queryAllByCameraId(cameraId); + for (AiRoi roi : currentRois) { + bindMapper.deleteByRoiId(roi.getRoiId()); + roiMapper.deleteByRoiId(roi.getRoiId()); + } + + // 从快照恢复 + String now = LocalDateTime.now().format(FORMATTER); + if (rois != null) { + for (int i = 0; i < rois.size(); i++) { + JSONObject roiJson = rois.getJSONObject(i); + AiRoi roi = new AiRoi(); + roi.setRoiId(roiJson.getString("roi_id")); + roi.setCameraId(cameraId); + roi.setRoiType(roiJson.getString("roi_type")); + roi.setName(roiJson.getString("name")); + roi.setCoordinates(roiJson.containsKey("coordinates") ? + JSON.toJSONString(roiJson.get("coordinates")) : null); + roi.setEnabled(roiJson.getBooleanValue("enabled") ? 1 : 0); + roi.setPriority(roiJson.getIntValue("priority")); + roi.setCreateTime(now); + roi.setUpdateTime(now); + roiMapper.add(roi); + + JSONArray algos = roiJson.getJSONArray("algorithms"); + if (algos != null) { + for (int j = 0; j < algos.size(); j++) { + JSONObject algoJson = algos.getJSONObject(j); + AiRoiAlgoBind bind = new AiRoiAlgoBind(); + bind.setBindId(algoJson.containsKey("bind_id") ? + algoJson.getString("bind_id") : UUID.randomUUID().toString()); + bind.setRoiId(roi.getRoiId()); + bind.setAlgoCode(algoJson.getString("algo_code")); + bind.setParams(algoJson.containsKey("params") ? + JSON.toJSONString(algoJson.get("params")) : null); + bind.setEnabled(algoJson.getBooleanValue("enabled") ? 1 : 0); + bind.setPriority(algoJson.getIntValue("priority")); + bind.setTemplateId(algoJson.getString("template_id")); + bind.setParamOverride(algoJson.getString("param_override")); + bind.setCreateTime(now); + bind.setUpdateTime(now); + bindMapper.add(bind); + } + } + } + } + + // 生成回滚快照 + Map newConfig = configService.exportConfig(cameraId); + createSnapshot("CAMERA", cameraId, cameraId, JSON.toJSONString(newConfig), + "ROLLBACK", "回滚到版本" + targetVersion, operator); + + // 推送到Redis + redisConfigService.syncCameraConfigToRedis(cameraId); + List ids = new ArrayList<>(); + ids.add(cameraId); + redisConfigService.publishConfigUpdate("full", ids); + + log.info("[AiSnapshot] 摄像头配置回滚完成: cameraId={}, targetVersion={}", cameraId, targetVersion); + } + + @Override + @Transactional + public void rollbackRoi(String roiId, Integer targetVersion, String operator) { + AiConfigSnapshot snapshot = snapshotMapper.queryByVersion("ROI", roiId, targetVersion); + if (snapshot == null) { + throw new IllegalArgumentException("快照不存在: ROI/" + roiId + "/v" + targetVersion); + } + + JSONObject roiJson = JSON.parseObject(snapshot.getSnapshot()); + String cameraId = snapshot.getCameraId(); + + // 删除当前ROI的所有绑定 + bindMapper.deleteByRoiId(roiId); + + // 更新ROI + String now = LocalDateTime.now().format(FORMATTER); + AiRoi roi = roiMapper.queryByRoiId(roiId); + if (roi == null) { + roi = new AiRoi(); + roi.setRoiId(roiId); + roi.setCameraId(cameraId); + roi.setCreateTime(now); + } + roi.setRoiType(roiJson.getString("roi_type")); + roi.setName(roiJson.getString("name")); + roi.setCoordinates(roiJson.containsKey("coordinates") ? + JSON.toJSONString(roiJson.get("coordinates")) : null); + roi.setEnabled(roiJson.getBooleanValue("enabled") ? 1 : 0); + roi.setPriority(roiJson.getIntValue("priority")); + roi.setUpdateTime(now); + + if (roi.getId() != null) { + roiMapper.update(roi); + } else { + roiMapper.add(roi); + } + + // 恢复绑定 + JSONArray algos = roiJson.getJSONArray("algorithms"); + if (algos != null) { + for (int j = 0; j < algos.size(); j++) { + JSONObject algoJson = algos.getJSONObject(j); + AiRoiAlgoBind bind = new AiRoiAlgoBind(); + bind.setBindId(algoJson.containsKey("bind_id") ? + algoJson.getString("bind_id") : UUID.randomUUID().toString()); + bind.setRoiId(roiId); + bind.setAlgoCode(algoJson.getString("algo_code")); + bind.setParams(algoJson.containsKey("params") ? + JSON.toJSONString(algoJson.get("params")) : null); + bind.setEnabled(algoJson.getBooleanValue("enabled") ? 1 : 0); + bind.setPriority(algoJson.getIntValue("priority")); + bind.setTemplateId(algoJson.getString("template_id")); + bind.setParamOverride(algoJson.getString("param_override")); + bind.setCreateTime(now); + bind.setUpdateTime(now); + bindMapper.add(bind); + } + } + + // 生成回滚快照 + createSnapshot("ROI", roiId, cameraId, snapshot.getSnapshot(), + "ROLLBACK", "回滚到版本" + targetVersion, operator); + + // 推送到Redis + redisConfigService.syncCameraConfigToRedis(cameraId); + List ids = new ArrayList<>(); + ids.add(roiId); + redisConfigService.publishConfigUpdate("roi", ids); + + log.info("[AiSnapshot] ROI配置回滚完成: roiId={}, targetVersion={}", roiId, targetVersion); + } + + @Override + @Transactional + public void rollbackBind(String bindId, Integer targetVersion, String operator) { + AiConfigSnapshot snapshot = snapshotMapper.queryByVersion("BIND", bindId, targetVersion); + if (snapshot == null) { + throw new IllegalArgumentException("快照不存在: BIND/" + bindId + "/v" + targetVersion); + } + + JSONObject bindJson = JSON.parseObject(snapshot.getSnapshot()); + String cameraId = snapshot.getCameraId(); + String now = LocalDateTime.now().format(FORMATTER); + + AiRoiAlgoBind bind = bindMapper.queryByBindId(bindId); + if (bind == null) { + bind = new AiRoiAlgoBind(); + bind.setBindId(bindId); + bind.setRoiId(bindJson.getString("roi_id")); + bind.setAlgoCode(bindJson.getString("algo_code")); + bind.setCreateTime(now); + } + bind.setParams(bindJson.containsKey("params") ? JSON.toJSONString(bindJson.get("params")) : null); + bind.setEnabled(bindJson.getBooleanValue("enabled") ? 1 : 0); + bind.setPriority(bindJson.getIntValue("priority")); + bind.setTemplateId(bindJson.getString("template_id")); + bind.setParamOverride(bindJson.getString("param_override")); + bind.setUpdateTime(now); + + if (bind.getId() != null) { + bindMapper.updateByBindId(bind); + } else { + bindMapper.add(bind); + } + + // 生成回滚快照 + createSnapshot("BIND", bindId, cameraId, snapshot.getSnapshot(), + "ROLLBACK", "回滚到版本" + targetVersion, operator); + + // 推送到Redis + redisConfigService.syncCameraConfigToRedis(cameraId); + List ids = new ArrayList<>(); + ids.add(bindId); + redisConfigService.publishConfigUpdate("bind", ids); + + log.info("[AiSnapshot] 绑定配置回滚完成: bindId={}, targetVersion={}", bindId, targetVersion); + } + + @Override + public Map diff(String scopeType, String scopeId, Integer versionA, Integer versionB) { + AiConfigSnapshot snapshotA = snapshotMapper.queryByVersion(scopeType, scopeId, versionA); + AiConfigSnapshot snapshotB = snapshotMapper.queryByVersion(scopeType, scopeId, versionB); + + if (snapshotA == null || snapshotB == null) { + throw new IllegalArgumentException("快照不存在"); + } + + JSONObject jsonA = JSON.parseObject(snapshotA.getSnapshot()); + JSONObject jsonB = JSON.parseObject(snapshotB.getSnapshot()); + + Map result = new LinkedHashMap<>(); + result.put("scope_type", scopeType); + result.put("scope_id", scopeId); + result.put("version_a", versionA); + result.put("version_b", versionB); + + List> changes = new ArrayList<>(); + compareJson("", jsonA, jsonB, changes); + result.put("changes", changes); + + return result; + } + + @Override + public Map preview(String cameraId) { + // DB当前配置 + Map dbConfig = configService.exportConfig(cameraId); + // Redis当前配置 + Map redisConfig = redisConfigService.readConfigFromRedis(cameraId); + + Map result = new LinkedHashMap<>(); + result.put("camera_id", cameraId); + result.put("db_config", dbConfig); + result.put("redis_config", redisConfig); + + // 简单的diff + List> changes = new ArrayList<>(); + compareJson("", JSON.parseObject(JSON.toJSONString(dbConfig)), + JSON.parseObject(JSON.toJSONString(redisConfig)), changes); + result.put("changes", changes); + + return result; + } + + /** + * 递归比较两个JSON对象,收集差异 + */ + private void compareJson(String path, JSONObject a, JSONObject b, List> changes) { + Set allKeys = new LinkedHashSet<>(); + if (a != null) allKeys.addAll(a.keySet()); + if (b != null) allKeys.addAll(b.keySet()); + + for (String key : allKeys) { + String currentPath = path.isEmpty() ? key : path + "." + key; + Object valA = a != null ? a.get(key) : null; + Object valB = b != null ? b.get(key) : null; + + if (valA == null && valB != null) { + Map change = new LinkedHashMap<>(); + change.put("path", currentPath); + change.put("old_value", null); + change.put("new_value", valB); + changes.add(change); + } else if (valA != null && valB == null) { + Map change = new LinkedHashMap<>(); + change.put("path", currentPath); + change.put("old_value", valA); + change.put("new_value", null); + changes.add(change); + } else if (valA instanceof JSONObject && valB instanceof JSONObject) { + compareJson(currentPath, (JSONObject) valA, (JSONObject) valB, changes); + } else if (!Objects.equals(String.valueOf(valA), String.valueOf(valB))) { + Map change = new LinkedHashMap<>(); + change.put("path", currentPath); + change.put("old_value", valA); + change.put("new_value", valB); + changes.add(change); + } + } + } +} diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiEdgeDeviceServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiEdgeDeviceServiceImpl.java new file mode 100644 index 000000000..474bd117b --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiEdgeDeviceServiceImpl.java @@ -0,0 +1,85 @@ +package com.genersoft.iot.vmp.aiot.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.genersoft.iot.vmp.aiot.bean.AiEdgeDevice; +import com.genersoft.iot.vmp.aiot.dao.AiEdgeDeviceMapper; +import com.genersoft.iot.vmp.aiot.service.IAiEdgeDeviceService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Slf4j +@Service +public class AiEdgeDeviceServiceImpl implements IAiEdgeDeviceService { + + @Autowired + private AiEdgeDeviceMapper deviceMapper; + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Override + public void saveOrUpdateHeartbeat(String deviceId, String payload) { + String now = LocalDateTime.now().format(FORMATTER); + + try { + JSONObject json = JSON.parseObject(payload); + JSONObject status = json.getJSONObject("status"); + + AiEdgeDevice device = deviceMapper.queryByDeviceId(deviceId); + if (device == null) { + device = new AiEdgeDevice(); + device.setDeviceId(deviceId); + device.setStatus("online"); + device.setLastHeartbeat(now); + device.setUptimeSeconds(status != null ? status.getLong("uptime_seconds") : null); + device.setFramesProcessed(status != null ? status.getLong("frames_processed") : null); + device.setAlertsGenerated(status != null ? status.getLong("alerts_generated") : null); + device.setStreamStats(status != null && status.containsKey("stream_stats") ? + status.getJSONObject("stream_stats").toJSONString() : null); + device.setUpdatedAt(now); + deviceMapper.add(device); + log.info("[AiEdgeDevice] 新设备上线: deviceId={}", deviceId); + } else { + device.setStatus("online"); + device.setLastHeartbeat(now); + device.setUptimeSeconds(status != null ? status.getLong("uptime_seconds") : null); + device.setFramesProcessed(status != null ? status.getLong("frames_processed") : null); + device.setAlertsGenerated(status != null ? status.getLong("alerts_generated") : null); + device.setStreamStats(status != null && status.containsKey("stream_stats") ? + status.getJSONObject("stream_stats").toJSONString() : null); + device.setUpdatedAt(now); + deviceMapper.updateByDeviceId(device); + log.debug("[AiEdgeDevice] 心跳更新: deviceId={}", deviceId); + } + } catch (Exception e) { + log.error("[AiEdgeDevice] 处理心跳失败: deviceId={}", deviceId, e); + } + } + + @Override + public AiEdgeDevice queryByDeviceId(String deviceId) { + return deviceMapper.queryByDeviceId(deviceId); + } + + @Override + public List queryAll() { + return deviceMapper.queryAll(); + } + + @Override + @Scheduled(fixedRate = 90000) // 每90秒检查一次 + public void checkOffline() { + String now = LocalDateTime.now().format(FORMATTER); + String threshold = LocalDateTime.now().minusSeconds(90).format(FORMATTER); + int count = deviceMapper.markOffline(threshold, now); + if (count > 0) { + log.warn("[AiEdgeDevice] 标记{}台设备为离线", count); + } + } +} diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiRedisConfigServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiRedisConfigServiceImpl.java new file mode 100644 index 000000000..4f6166bc8 --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiRedisConfigServiceImpl.java @@ -0,0 +1,425 @@ +package com.genersoft.iot.vmp.aiot.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.genersoft.iot.vmp.aiot.bean.AiAlgorithm; +import com.genersoft.iot.vmp.aiot.bean.AiAlgoTemplate; +import com.genersoft.iot.vmp.aiot.bean.AiRoi; +import com.genersoft.iot.vmp.aiot.bean.AiRoiAlgoBind; +import com.genersoft.iot.vmp.aiot.dao.AiAlgorithmMapper; +import com.genersoft.iot.vmp.aiot.dao.AiAlgoTemplateMapper; +import com.genersoft.iot.vmp.aiot.dao.AiRoiAlgoBindMapper; +import com.genersoft.iot.vmp.aiot.dao.AiRoiMapper; +import com.genersoft.iot.vmp.aiot.service.IAiRedisConfigService; +import com.genersoft.iot.vmp.gb28181.bean.CommonGBChannel; +import com.genersoft.iot.vmp.gb28181.dao.CommonGBChannelMapper; +import com.genersoft.iot.vmp.media.bean.MediaServer; +import com.genersoft.iot.vmp.media.service.IMediaServerService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +public class AiRedisConfigServiceImpl implements IAiRedisConfigService { + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + @Autowired + private AiRoiMapper roiMapper; + + @Autowired + private AiRoiAlgoBindMapper bindMapper; + + @Autowired + private AiAlgorithmMapper algorithmMapper; + + @Autowired + private AiAlgoTemplateMapper templateMapper; + + @Autowired + private CommonGBChannelMapper channelMapper; + + @Autowired + private IMediaServerService mediaServerService; + + private static final long KEY_EXPIRE_SECONDS = 3600; + + @Override + public void writeRoiToRedis(AiRoi roi) { + String key = "config:roi:" + roi.getRoiId(); + Map hash = new LinkedHashMap<>(); + hash.put("roi_id", roi.getRoiId()); + hash.put("camera_id", roi.getCameraId()); + hash.put("roi_type", roi.getRoiType() != null ? roi.getRoiType() : "polygon"); + // coordinates: 转为Python list格式字符串(ai_edge用eval解析) + hash.put("coordinates", toPythonListStr(roi.getCoordinates())); + hash.put("enabled", roi.getEnabled() != null && roi.getEnabled() == 1 ? "True" : "False"); + hash.put("priority", String.valueOf(roi.getPriority() != null ? roi.getPriority() : 0)); + + stringRedisTemplate.opsForHash().putAll(key, hash); + stringRedisTemplate.expire(key, KEY_EXPIRE_SECONDS, TimeUnit.SECONDS); + log.debug("[AiRedis] 写入ROI配置: key={}", key); + } + + @Override + public void writeBindToRedis(AiRoiAlgoBind bind, String algoName, String targetClass, String effectiveParams) { + String key = "config:bind:" + bind.getBindId(); + Map hash = new LinkedHashMap<>(); + hash.put("bind_id", bind.getBindId()); + hash.put("roi_id", bind.getRoiId()); + hash.put("algo_code", bind.getAlgoCode()); + // params: 转为Python dict格式字符串(ai_edge用eval解析) + hash.put("params", toPythonDictStr(effectiveParams)); + hash.put("priority", String.valueOf(bind.getPriority() != null ? bind.getPriority() : 0)); + hash.put("enabled", bind.getEnabled() != null && bind.getEnabled() == 1 ? "True" : "False"); + hash.put("algo_name", algoName != null ? algoName : ""); + hash.put("target_class", targetClass != null ? targetClass : "person"); + + stringRedisTemplate.opsForHash().putAll(key, hash); + stringRedisTemplate.expire(key, KEY_EXPIRE_SECONDS, TimeUnit.SECONDS); + log.debug("[AiRedis] 写入绑定配置: key={}", key); + } + + @Override + public void writeCameraToRedis(String cameraId, String rtspUrl, String cameraName, String location) { + String key = "config:camera:" + cameraId; + Map hash = new LinkedHashMap<>(); + hash.put("camera_id", cameraId); + hash.put("rtsp_url", rtspUrl != null ? rtspUrl : ""); + hash.put("camera_name", cameraName != null ? cameraName : ""); + hash.put("enabled", "True"); + hash.put("location", location != null ? location : ""); + + stringRedisTemplate.opsForHash().putAll(key, hash); + stringRedisTemplate.expire(key, KEY_EXPIRE_SECONDS, TimeUnit.SECONDS); + log.debug("[AiRedis] 写入摄像头配置: key={}", key); + } + + @Override + public void deleteRoiFromRedis(String roiId) { + String key = "config:roi:" + roiId; + stringRedisTemplate.delete(key); + log.debug("[AiRedis] 删除ROI配置: key={}", key); + } + + @Override + public void deleteBindFromRedis(String bindId) { + String key = "config:bind:" + bindId; + stringRedisTemplate.delete(key); + log.debug("[AiRedis] 删除绑定配置: key={}", key); + } + + @Override + public void publishConfigUpdate(String type, List ids) { + Map message = new LinkedHashMap<>(); + message.put("type", type); + message.put("ids", ids); + + // affected_items: ai_edge 的 _handle_config_update 只看这个字段 + // "all" 会同时清除 cameras 和 rois 缓存 + List affectedItems = new ArrayList<>(); + switch (type) { + case "full": + affectedItems.add("all"); + break; + case "roi": + affectedItems.add("roi"); + break; + case "bind": + affectedItems.add("roi"); // bind变更也需要刷新roi关联的算法 + break; + case "camera": + affectedItems.add("camera"); + break; + default: + affectedItems.add("all"); + } + message.put("affected_items", affectedItems); + message.put("version", "1.0.0"); + message.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS"))); + + String json = JSON.toJSONString(message); + stringRedisTemplate.convertAndSend("config_update", json); + log.info("[AiRedis] 发布config_update通知: type={}, affected_items={}, ids={}", type, affectedItems, ids); + } + + @Override + public Map readConfigFromRedis(String cameraId) { + Map result = new LinkedHashMap<>(); + result.put("camera_id", cameraId); + + // 扫描所有ROI keys,筛选属于该摄像头的 + Set roiKeys = stringRedisTemplate.keys("config:roi:*"); + List> rois = new ArrayList<>(); + List roiIds = new ArrayList<>(); + + if (roiKeys != null) { + for (String key : roiKeys) { + Map entries = stringRedisTemplate.opsForHash().entries(key); + if (cameraId.equals(entries.get("camera_id"))) { + Map roiMap = new LinkedHashMap<>(); + for (Map.Entry e : entries.entrySet()) { + roiMap.put(e.getKey().toString(), e.getValue()); + } + rois.add(roiMap); + roiIds.add(entries.get("roi_id").toString()); + } + } + } + + // 扫描所有bind keys,筛选属于该摄像头ROI的 + Set bindKeys = stringRedisTemplate.keys("config:bind:*"); + List> binds = new ArrayList<>(); + if (bindKeys != null) { + for (String key : bindKeys) { + Map entries = stringRedisTemplate.opsForHash().entries(key); + String bindRoiId = entries.get("roi_id") != null ? entries.get("roi_id").toString() : ""; + if (roiIds.contains(bindRoiId)) { + Map bindMap = new LinkedHashMap<>(); + for (Map.Entry e : entries.entrySet()) { + bindMap.put(e.getKey().toString(), e.getValue()); + } + binds.add(bindMap); + } + } + } + + result.put("rois", rois); + result.put("binds", binds); + return result; + } + + @Override + public void syncCameraConfigToRedis(String cameraId) { + List rois = roiMapper.queryAllByCameraId(cameraId); + List roiIds = new ArrayList<>(); + List bindIds = new ArrayList<>(); + + // 1. 清理该摄像头关联的旧Redis keys(防止已删除的ROI/bind残留) + cleanStaleCameraKeys(cameraId); + + // 2. 写入摄像头配置(含通道名称和RTSP地址) + writeCameraConfigFromChannel(cameraId, rois); + + // 3. 写入ROI和绑定配置 + for (AiRoi roi : rois) { + writeRoiToRedis(roi); + roiIds.add(roi.getRoiId()); + + List binds = bindMapper.queryByRoiId(roi.getRoiId()); + for (AiRoiAlgoBind bind : binds) { + AiAlgorithm algo = algorithmMapper.queryByCode(bind.getAlgoCode()); + String algoName = algo != null ? algo.getAlgoName() : ""; + String targetClass = algo != null ? algo.getTargetClass() : "person"; + String effectiveParams = resolveEffectiveParams(bind); + writeBindToRedis(bind, algoName, targetClass, effectiveParams); + bindIds.add(bind.getBindId()); + } + } + + log.info("[AiRedis] 全量同步摄像头配置完成: cameraId={}, ROIs={}, Binds={}", cameraId, roiIds.size(), bindIds.size()); + } + + /** + * 清理该摄像头在Redis中的旧ROI和bind keys,避免已删除的配置残留 + */ + private void cleanStaleCameraKeys(String cameraId) { + // 扫描所有 config:roi:* 找到属于该摄像头的,全部删除(后面会重新写入当前有效的) + Set roiKeys = stringRedisTemplate.keys("config:roi:*"); + List staleRoiIds = new ArrayList<>(); + if (roiKeys != null) { + for (String key : roiKeys) { + Object cam = stringRedisTemplate.opsForHash().get(key, "camera_id"); + if (cameraId.equals(cam)) { + Object roiId = stringRedisTemplate.opsForHash().get(key, "roi_id"); + if (roiId != null) { + staleRoiIds.add(roiId.toString()); + } + stringRedisTemplate.delete(key); + } + } + } + + // 删除属于这些ROI的bind keys + if (!staleRoiIds.isEmpty()) { + Set bindKeys = stringRedisTemplate.keys("config:bind:*"); + if (bindKeys != null) { + for (String key : bindKeys) { + Object roiId = stringRedisTemplate.opsForHash().get(key, "roi_id"); + if (roiId != null && staleRoiIds.contains(roiId.toString())) { + stringRedisTemplate.delete(key); + } + } + } + } + + log.debug("[AiRedis] 清理旧keys完成: cameraId={}, 旧ROIs={}", cameraId, staleRoiIds.size()); + } + + /** + * 从通道信息写入摄像头Redis配置 + */ + private void writeCameraConfigFromChannel(String cameraId, List rois) { + String cameraName = ""; + String rtspUrl = ""; + + // 从ROI关联的通道获取名称 + if (!rois.isEmpty()) { + AiRoi firstRoi = rois.get(0); + if (firstRoi.getChannelDbId() != null) { + try { + CommonGBChannel channel = channelMapper.queryById(firstRoi.getChannelDbId()); + if (channel != null) { + cameraName = channel.getGbName() != null ? channel.getGbName() : ""; + } + } catch (Exception e) { + log.warn("[AiRedis] 查询通道信息失败: channelDbId={}", firstRoi.getChannelDbId()); + } + } + } + + // 构建RTSP代理地址(通过ZLM媒体服务器) + // cameraId格式为 {app}/{stream},ZLM的RTSP路径直接使用该格式 + try { + MediaServer mediaServer = mediaServerService.getDefaultMediaServer(); + if (mediaServer != null && mediaServer.getRtspPort() != 0) { + rtspUrl = String.format("rtsp://%s:%s/%s", + mediaServer.getStreamIp() != null ? mediaServer.getStreamIp() : mediaServer.getIp(), + mediaServer.getRtspPort(), cameraId); + } else if (mediaServer != null) { + rtspUrl = String.format("http://%s:%s/%s.live.flv", + mediaServer.getStreamIp() != null ? mediaServer.getStreamIp() : mediaServer.getIp(), + mediaServer.getHttpPort(), cameraId); + } + } catch (Exception e) { + log.warn("[AiRedis] 获取媒体服务器信息失败,RTSP地址为空: {}", e.getMessage()); + } + + writeCameraToRedis(cameraId, rtspUrl, cameraName, ""); + } + + /** + * 解析绑定的有效参数:如果有模板引用,则合并模板参数和覆盖参数 + */ + private String resolveEffectiveParams(AiRoiAlgoBind bind) { + if (bind.getTemplateId() != null && !bind.getTemplateId().isEmpty()) { + AiAlgoTemplate template = templateMapper.queryByTemplateId(bind.getTemplateId()); + if (template != null) { + return AiAlgoTemplateServiceImpl.mergeParams(template.getParams(), bind.getParamOverride()); + } + } + return bind.getParams() != null ? bind.getParams() : "{}"; + } + + /** + * 将坐标JSON转为Python list格式 + * 输入: [{"x":0.28,"y":0.56},{"x":0.01,"y":0.80}] (前端格式) + * 输出: [[0.28, 0.56], [0.01, 0.80]] (Python可eval的格式) + * 输入: [[0.1,0.2],[0.3,0.4]] (已是数组格式) + * 输出: [[0.1, 0.2], [0.3, 0.4]] (直接返回) + */ + private String toPythonListStr(String jsonCoordinates) { + if (jsonCoordinates == null || jsonCoordinates.isEmpty()) { + return "[]"; + } + try { + JSONArray arr = JSON.parseArray(jsonCoordinates); + if (arr == null || arr.isEmpty()) { + return "[]"; + } + // 检测是否为 [{x:..., y:...}] 格式 + Object first = arr.get(0); + if (first instanceof com.alibaba.fastjson2.JSONObject) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < arr.size(); i++) { + if (i > 0) sb.append(", "); + com.alibaba.fastjson2.JSONObject point = arr.getJSONObject(i); + sb.append("[").append(point.get("x")).append(", ").append(point.get("y")).append("]"); + } + sb.append("]"); + return sb.toString(); + } + } catch (Exception e) { + log.debug("[AiRedis] 坐标格式转换跳过,原样返回: {}", jsonCoordinates); + } + // 已是 [[x,y],...] 格式,直接返回 + return jsonCoordinates; + } + + /** + * 将JSON格式的参数字符串转为Python dict格式 + * 输入: {"confirm_leave_sec":10,"cooldown_sec":300} + * 输出: {'confirm_leave_sec': 10, 'cooldown_sec': 300} + */ + private String toPythonDictStr(String jsonParams) { + if (jsonParams == null || jsonParams.isEmpty()) { + return "{}"; + } + try { + // 解析JSON然后重新格式化为Python风格 + Map map = JSON.parseObject(jsonParams, LinkedHashMap.class); + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) sb.append(", "); + first = false; + sb.append("'").append(entry.getKey()).append("': "); + Object val = entry.getValue(); + if (val instanceof String) { + sb.append("'").append(val).append("'"); + } else if (val instanceof Boolean) { + sb.append((Boolean) val ? "True" : "False"); + } else if (val instanceof List) { + sb.append(toPythonValue(val)); + } else { + sb.append(val); + } + } + sb.append("}"); + return sb.toString(); + } catch (Exception e) { + log.warn("[AiRedis] JSON转Python dict失败, 原样返回: {}", jsonParams); + return jsonParams; + } + } + + @SuppressWarnings("unchecked") + private String toPythonValue(Object val) { + if (val == null) return "None"; + if (val instanceof String) return "'" + val + "'"; + if (val instanceof Boolean) return (Boolean) val ? "True" : "False"; + if (val instanceof List) { + List list = (List) val; + StringBuilder sb = new StringBuilder("["); + boolean first = true; + for (Object item : list) { + if (!first) sb.append(", "); + first = false; + sb.append(toPythonValue(item)); + } + sb.append("]"); + return sb.toString(); + } + if (val instanceof Map) { + Map map = (Map) val; + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) sb.append(", "); + first = false; + sb.append("'").append(entry.getKey()).append("': ").append(toPythonValue(entry.getValue())); + } + sb.append("}"); + return sb.toString(); + } + return val.toString(); + } +}