From 3a601b37e6290e7bf760560a8150e10cd258172e Mon Sep 17 00:00:00 2001 From: 16337 <1633794139@qq.com> Date: Fri, 27 Feb 2026 17:25:32 +0800 Subject: [PATCH 01/31] =?UTF-8?q?feat(aiot):=20Edge=E6=88=AA=E5=9B=BE?= =?UTF-8?q?=E6=96=B9=E6=A1=88=E6=9B=BF=E4=BB=A3ZLM=E6=88=AA=E5=9B=BE?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81COS=20URL=E8=BF=94=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 IAiScreenshotService 接口和实现:通过 Redis Stream 请求 Edge 截图,轮询等待结果,支持 5 分钟缓存和 force 刷新 - AiRoiController.getSnap() 从 ZLM 二进制截图改为返回 JSON(含 COS URL) - 前端 aiRoi.js 新增 getSnapUrl 方法 - roiConfig.vue 改为异步加载截图,增加 loading 状态和错误提示 Co-Authored-By: Claude Opus 4.6 --- .../vmp/aiot/controller/AiRoiController.java | 97 +------------ .../aiot/service/IAiScreenshotService.java | 15 ++ .../service/impl/AiScreenshotServiceImpl.java | 137 ++++++++++++++++++ web/src/api/aiRoi.js | 8 + web/src/views/cameraConfig/roiConfig.vue | 36 +++-- 5 files changed, 194 insertions(+), 99 deletions(-) create mode 100644 src/main/java/com/genersoft/iot/vmp/aiot/service/IAiScreenshotService.java create mode 100644 src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java index c41a528f2..41902af70 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java @@ -4,26 +4,17 @@ import com.genersoft.iot.vmp.aiot.bean.AiRoi; import com.genersoft.iot.vmp.aiot.bean.AiRoiAlgoBind; import com.genersoft.iot.vmp.aiot.bean.AiRoiDetail; import com.genersoft.iot.vmp.aiot.service.IAiRoiService; -import com.genersoft.iot.vmp.media.bean.MediaServer; -import com.genersoft.iot.vmp.media.service.IMediaServerService; -import com.genersoft.iot.vmp.streamProxy.bean.StreamProxy; -import com.genersoft.iot.vmp.streamProxy.service.IStreamProxyService; +import com.genersoft.iot.vmp.aiot.service.IAiScreenshotService; import com.github.pagehelper.PageInfo; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import java.util.List; -import java.util.concurrent.TimeUnit; +import java.util.Map; @Slf4j @RestController @@ -35,10 +26,7 @@ public class AiRoiController { private IAiRoiService roiService; @Autowired - private IMediaServerService mediaServerService; - - @Autowired - private IStreamProxyService streamProxyService; + private IAiScreenshotService screenshotService; @Operation(summary = "分页查询ROI列表") @GetMapping("/list") @@ -93,80 +81,11 @@ public class AiRoiController { roiService.updateAlgoParams(bind); } - @Operation(summary = "获取摄像头截图") + @Operation(summary = "获取摄像头截图(Edge截图 → COS URL)") @GetMapping("/snap") - public void getSnap(HttpServletResponse resp, - @RequestParam String cameraCode) { - // 通过 camera_code 查询 StreamProxy - StreamProxy proxy = streamProxyService.getStreamProxyByCameraCode(cameraCode); - - if (proxy == null) { - log.warn("[AI截图] 未找到camera_code对应的StreamProxy: {}", cameraCode); - resp.setStatus(HttpServletResponse.SC_NOT_FOUND); - return; - } - - // 使用 proxy 的 app 和 stream - String app = proxy.getApp(); - String stream = proxy.getStream(); - - MediaServer mediaServer = mediaServerService.getDefaultMediaServer(); - if (mediaServer == null) { - log.warn("[AI截图] 无可用媒体服务器"); - resp.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE); - return; - } - // 使用ZLM内部RTSP流地址截图(流必须在线) - String internalUrl; - if (mediaServer.getRtspPort() != 0) { - internalUrl = String.format("rtsp://127.0.0.1:%s/%s/%s", mediaServer.getRtspPort(), app, stream); - } else { - internalUrl = String.format("http://127.0.0.1:%s/%s/%s.live.mp4", mediaServer.getHttpPort(), app, stream); - } - log.info("[AI截图] cameraCode={}, app={}, stream={}, 内部地址={}", cameraCode, app, stream, internalUrl); - - String zlmApi = String.format("http://%s:%s/index/api/getSnap", - mediaServer.getIp(), mediaServer.getHttpPort()); - HttpUrl httpUrl = HttpUrl.parse(zlmApi); - if (httpUrl == null) { - resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - return; - } - HttpUrl requestUrl = httpUrl.newBuilder() - .addQueryParameter("secret", mediaServer.getSecret()) - .addQueryParameter("url", internalUrl) - .addQueryParameter("timeout_sec", "10") - .addQueryParameter("expire_sec", "1") - .build(); - - OkHttpClient client = new OkHttpClient.Builder() - .connectTimeout(5, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .build(); - - try { - Request request = new Request.Builder().url(requestUrl).build(); - Response response = client.newCall(request).execute(); - if (response.isSuccessful() && response.body() != null) { - String contentType = response.header("Content-Type", ""); - byte[] bytes = response.body().bytes(); - // ZLM默认logo是47255字节,跳过它 - if (contentType.contains("image") && bytes.length != 47255) { - resp.setContentType(MediaType.IMAGE_JPEG_VALUE); - resp.getOutputStream().write(bytes); - resp.getOutputStream().flush(); - } else { - log.warn("[AI截图] 截图返回默认logo或非图片,流可能未在线,size={}", bytes.length); - resp.setStatus(HttpServletResponse.SC_NO_CONTENT); - } - } else { - log.warn("[AI截图] ZLM请求失败: {} {}", response.code(), response.message()); - resp.setStatus(HttpServletResponse.SC_BAD_GATEWAY); - } - response.close(); - } catch (Exception e) { - log.warn("[AI截图] 截图异常: {}", e.getMessage()); - resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - } + public Map getSnap( + @RequestParam String cameraCode, + @RequestParam(defaultValue = "false") boolean force) { + return screenshotService.requestScreenshot(cameraCode, force); } } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiScreenshotService.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiScreenshotService.java new file mode 100644 index 000000000..702e8e37a --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiScreenshotService.java @@ -0,0 +1,15 @@ +package com.genersoft.iot.vmp.aiot.service; + +import java.util.Map; + +public interface IAiScreenshotService { + + /** + * 请求 Edge 截图 + * + * @param cameraCode 摄像头编码(对应 StreamProxy.cameraCode) + * @param force true 则忽略缓存强制截图 + * @return {status: "ok"/"error"/"timeout", url: "...", stale: true/false, message: "..."} + */ + Map requestScreenshot(String cameraCode, boolean force); +} diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java new file mode 100644 index 000000000..1c1903c46 --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java @@ -0,0 +1,137 @@ +package com.genersoft.iot.vmp.aiot.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.genersoft.iot.vmp.aiot.service.IAiScreenshotService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +public class AiScreenshotServiceImpl implements IAiScreenshotService { + + private static final String SNAP_REQUEST_STREAM = "edge_snap_request"; + private static final String SNAP_RESULT_KEY_PREFIX = "snap:result:"; + private static final String SNAP_CACHE_KEY_PREFIX = "snap:cache:"; + + /** 轮询间隔 ms */ + private static final long POLL_INTERVAL_MS = 500; + /** 最大等待时间 ms */ + private static final long MAX_WAIT_MS = 15_000; + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + @Override + public Map requestScreenshot(String cameraCode, boolean force) { + Map result = new HashMap<>(); + + // 1. 检查缓存(非 force 模式) + if (!force) { + String cacheJson = stringRedisTemplate.opsForValue().get(SNAP_CACHE_KEY_PREFIX + cameraCode); + if (cacheJson != null) { + try { + JSONObject cached = JSON.parseObject(cacheJson); + result.put("status", "ok"); + result.put("url", cached.getString("url")); + result.put("cached", true); + log.info("[AI截图] 命中缓存: cameraCode={}", cameraCode); + return result; + } catch (Exception e) { + log.warn("[AI截图] 缓存解析失败: {}", e.getMessage()); + } + } + } + + // 2. 生成 request_id 和 COS 路径 + String requestId = UUID.randomUUID().toString().replace("-", "").substring(0, 12); + String cosPath = buildCosPath(cameraCode, requestId); + + // 3. XADD 到 Stream + Map fields = new HashMap<>(); + fields.put("request_id", requestId); + fields.put("camera_code", cameraCode); + fields.put("cos_path", cosPath); + + try { + MapRecord record = MapRecord.create(SNAP_REQUEST_STREAM, fields); + RecordId recordId = stringRedisTemplate.opsForStream().add(record); + log.info("[AI截图] 发送截图请求: requestId={}, cameraCode={}, streamId={}", requestId, cameraCode, recordId); + } catch (Exception e) { + log.error("[AI截图] 发送截图请求失败: {}", e.getMessage()); + result.put("status", "error"); + result.put("message", "发送截图请求失败"); + return result; + } + + // 4. 轮询结果 + String resultKey = SNAP_RESULT_KEY_PREFIX + requestId; + long deadline = System.currentTimeMillis() + MAX_WAIT_MS; + + while (System.currentTimeMillis() < deadline) { + try { + Thread.sleep(POLL_INTERVAL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + + String resultJson = stringRedisTemplate.opsForValue().get(resultKey); + if (resultJson != null) { + try { + JSONObject res = JSON.parseObject(resultJson); + result.put("status", res.getString("status")); + if ("ok".equals(res.getString("status"))) { + result.put("url", res.getString("url")); + } else { + result.put("message", res.getString("message")); + } + // 清理结果 key + stringRedisTemplate.delete(resultKey); + return result; + } catch (Exception e) { + log.warn("[AI截图] 结果解析失败: {}", e.getMessage()); + } + } + } + + // 5. 超时 → 尝试返回过期缓存 + log.warn("[AI截图] 截图超时: cameraCode={}, requestId={}", cameraCode, requestId); + String staleCache = stringRedisTemplate.opsForValue().get(SNAP_CACHE_KEY_PREFIX + cameraCode); + if (staleCache != null) { + try { + JSONObject cached = JSON.parseObject(staleCache); + result.put("status", "ok"); + result.put("url", cached.getString("url")); + result.put("stale", true); + return result; + } catch (Exception ignored) { + } + } + + result.put("status", "timeout"); + result.put("message", "边缘设备响应超时,请确认设备在线"); + return result; + } + + /** + * 构建 COS 存储路径: snapshots/{camera_code}/{日期}/{时间}_{id}.jpg + */ + private String buildCosPath(String cameraCode, String requestId) { + LocalDateTime now = LocalDateTime.now(); + String date = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); + String time = now.format(DateTimeFormatter.ofPattern("HH-mm-ss")); + return String.format("snapshots/%s/%s/%s_%s.jpg", cameraCode, date, time, requestId); + } +} diff --git a/web/src/api/aiRoi.js b/web/src/api/aiRoi.js index dcc39a54d..6898efdf2 100644 --- a/web/src/api/aiRoi.js +++ b/web/src/api/aiRoi.js @@ -62,3 +62,11 @@ export function updateAlgoParams(data) { data: data }) } + +export function getSnapUrl(cameraCode, force = false) { + return request({ + method: 'get', + url: '/api/ai/roi/snap', + params: { cameraCode, force } + }) +} diff --git a/web/src/views/cameraConfig/roiConfig.vue b/web/src/views/cameraConfig/roiConfig.vue index c4b978345..d689682d7 100644 --- a/web/src/views/cameraConfig/roiConfig.vue +++ b/web/src/views/cameraConfig/roiConfig.vue @@ -8,7 +8,7 @@
画矩形 画多边形 - 刷新截图 + 刷新截图 推送到边缘端
@@ -87,7 +87,7 @@ "}) + List queryPage(@Param("status") String status); + + @Select("SELECT COUNT(*) FROM wvp_ai_edge_device WHERE status=#{status}") + int countByStatus(@Param("status") String status); + + @Select("SELECT COUNT(*) FROM wvp_ai_edge_device") + int countAll(); } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiEdgeDeviceService.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiEdgeDeviceService.java index 48aa405e8..228d53fdb 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiEdgeDeviceService.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiEdgeDeviceService.java @@ -1,8 +1,10 @@ package com.genersoft.iot.vmp.aiot.service; import com.genersoft.iot.vmp.aiot.bean.AiEdgeDevice; +import com.github.pagehelper.PageInfo; import java.util.List; +import java.util.Map; public interface IAiEdgeDeviceService { @@ -13,4 +15,8 @@ public interface IAiEdgeDeviceService { List queryAll(); void checkOffline(); + + PageInfo queryPage(int page, int count, String status); + + Map getStatistics(); } 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 index 474bd117b..4d055f640 100644 --- 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 @@ -5,6 +5,8 @@ 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 com.github.pagehelper.PageHelper; +import com.github.pagehelper.PageInfo; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; @@ -12,7 +14,9 @@ import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.HashMap; import java.util.List; +import java.util.Map; @Slf4j @Service @@ -82,4 +86,20 @@ public class AiEdgeDeviceServiceImpl implements IAiEdgeDeviceService { log.warn("[AiEdgeDevice] 标记{}台设备为离线", count); } } + + @Override + public PageInfo queryPage(int page, int count, String status) { + PageHelper.startPage(page, count); + List list = deviceMapper.queryPage(status); + return new PageInfo<>(list); + } + + @Override + public Map getStatistics() { + Map stats = new HashMap<>(); + stats.put("total", deviceMapper.countAll()); + stats.put("online", deviceMapper.countByStatus("online")); + stats.put("offline", deviceMapper.countByStatus("offline")); + return stats; + } } From e140d4ceeb038b7052944254f338d9de99d667a6 Mon Sep 17 00:00:00 2001 From: 16337 <1633794139@qq.com> Date: Mon, 2 Mar 2026 17:28:39 +0800 Subject: [PATCH 24/31] =?UTF-8?q?fix(aiot):=20=E4=BF=AE=E5=A4=8D=20backfil?= =?UTF-8?q?l-device-id=20=E8=A2=AB=E5=85=A8=E5=B1=80=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E6=8B=A6=E6=88=AA=E5=99=A8=E8=AF=AF=E8=BD=AC=E5=9E=8B=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GlobalResponseAdvice:65 对 LinkedHashMap 中的 "status" 键强转 Integer, 但 backfill 端点放入的是字符串 "success",触发 ClassCastException。 - backfill 返回 Map 的键从 "status" 改为 "result" 避免冲突 - GlobalResponseAdvice 增加 instanceof Integer 类型检查,防止其他端点踩坑 Co-Authored-By: Claude Opus 4.6 --- .../genersoft/iot/vmp/aiot/controller/AiConfigController.java | 2 +- .../java/com/genersoft/iot/vmp/conf/GlobalResponseAdvice.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiConfigController.java b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiConfigController.java index 2c865231d..9d54858fc 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiConfigController.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiConfigController.java @@ -162,7 +162,7 @@ public class AiConfigController { int updatedRois = roiMapper.updateAllDeviceId(targetDeviceId); result.put("rois_updated", updatedRois); - result.put("status", "success"); + result.put("result", "success"); result.put("device_id", targetDeviceId); log.info("[AiConfig] 统一 device_id 完成: deviceId={}, deletedDevices={}, updatedRois={}", targetDeviceId, deleted, updatedRois); diff --git a/src/main/java/com/genersoft/iot/vmp/conf/GlobalResponseAdvice.java b/src/main/java/com/genersoft/iot/vmp/conf/GlobalResponseAdvice.java index 18da00ea7..f1d1f7a31 100644 --- a/src/main/java/com/genersoft/iot/vmp/conf/GlobalResponseAdvice.java +++ b/src/main/java/com/genersoft/iot/vmp/conf/GlobalResponseAdvice.java @@ -62,7 +62,8 @@ public class GlobalResponseAdvice implements ResponseBodyAdvice { if (body instanceof LinkedHashMap) { LinkedHashMap bodyMap = (LinkedHashMap) body; - if (bodyMap.get("status") != null && (Integer)bodyMap.get("status") != 200) { + Object statusVal = bodyMap.get("status"); + if (statusVal instanceof Integer && (Integer) statusVal != 200) { return body; } } From 547dfdd5f44147adbc9960806be3ca721e3e8ec0 Mon Sep 17 00:00:00 2001 From: 16337 <1633794139@qq.com> Date: Tue, 3 Mar 2026 16:56:26 +0800 Subject: [PATCH 25/31] =?UTF-8?q?feat(aiot):=20=E6=88=AA=E5=9B=BE=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=20+=20ROI=20=E6=98=BE=E7=A4=BA=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20+=20=E5=91=8A=E8=AD=A6=E5=9B=BE=E7=89=87=E4=BB=A3?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 截图策略改为截一次持久化到 DB,打开 ROI 页面不再触发 Edge 截图: - 新增 wvp_ai_camera_snapshot 表存储 cos_key - AiScreenshotServiceImpl 回调时持久化 cos_key,图片代理增加 DB 回退 - 前端 getSnapUrl 直接返回代理 URL,force=true 才触发 Edge RoiCanvas 修复: - ResizeObserver 替代 nextTick 初始化 canvas - 图片加载失败时仍初始化 canvas 以显示 ROI 告警图片代理: - AiAlertController 新增 /image 端点通过 presign URL 代理 COS 图片 - 新增告警列表前端页面 alertList Co-Authored-By: Claude Opus 4.6 --- sql/wvp_ai_camera_snapshot.sql | 8 + .../aiot/controller/AiAlertController.java | 15 ++ .../vmp/aiot/controller/AiRoiController.java | 2 +- .../vmp/aiot/dao/AiCameraSnapshotMapper.java | 15 ++ .../iot/vmp/aiot/service/IAiAlertService.java | 2 + .../aiot/service/impl/AiAlertServiceImpl.java | 26 +++ .../service/impl/AiScreenshotServiceImpl.java | 75 ++++++-- .../vmp/conf/security/WebSecurityConfig.java | 1 + src/main/resources/application-dev.yml | 2 + src/main/resources/application-docker.yml | 2 + web/src/api/aiAlert.js | 51 ++++++ web/src/api/aiRoi.js | 25 ++- web/src/router/index.js | 11 ++ web/src/views/alertList/index.vue | 166 ++++++++++++++++++ web/src/views/cameraConfig/roiConfig.vue | 4 +- .../views/roiConfig/components/RoiCanvas.vue | 19 +- 16 files changed, 399 insertions(+), 25 deletions(-) create mode 100644 sql/wvp_ai_camera_snapshot.sql create mode 100644 src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java create mode 100644 web/src/api/aiAlert.js create mode 100644 web/src/views/alertList/index.vue diff --git a/sql/wvp_ai_camera_snapshot.sql b/sql/wvp_ai_camera_snapshot.sql new file mode 100644 index 000000000..932782aeb --- /dev/null +++ b/sql/wvp_ai_camera_snapshot.sql @@ -0,0 +1,8 @@ +-- Screenshot persistence table for storing COS object keys +-- Run this SQL on the WVP database before deploying the new version + +CREATE TABLE IF NOT EXISTS wvp_ai_camera_snapshot ( + camera_code VARCHAR(64) PRIMARY KEY, + cos_key VARCHAR(512) NOT NULL COMMENT 'COS 对象键', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java index 7b6d8c0ad..94ef66ad9 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java @@ -9,6 +9,8 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -60,6 +62,19 @@ public class AiAlertController { return alertService.statistics(startTime); } + @Operation(summary = "告警图片代理") + @GetMapping("/image") + public ResponseEntity getAlertImage(@RequestParam String imagePath) { + byte[] image = alertService.proxyAlertImage(imagePath); + if (image == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_JPEG) + .header("Cache-Control", "public, max-age=3600") + .body(image); + } + // ==================== Edge 告警上报 ==================== @Operation(summary = "Edge 告警上报") diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java index 7c5c60f86..aa9b29ac0 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java @@ -124,7 +124,7 @@ public class AiRoiController { } return ResponseEntity.ok() .contentType(MediaType.IMAGE_JPEG) - .header("Cache-Control", "max-age=60") + .header("Cache-Control", "public, max-age=300") .body(image); } } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java b/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java new file mode 100644 index 000000000..44a789a50 --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java @@ -0,0 +1,15 @@ +package com.genersoft.iot.vmp.aiot.dao; + +import org.apache.ibatis.annotations.*; + +@Mapper +public interface AiCameraSnapshotMapper { + + @Select("SELECT cos_key FROM wvp_ai_camera_snapshot WHERE camera_code=#{cameraCode}") + String getCosKey(@Param("cameraCode") String cameraCode); + + @Insert("INSERT INTO wvp_ai_camera_snapshot (camera_code, cos_key) " + + "VALUES (#{cameraCode}, #{cosKey}) " + + "ON DUPLICATE KEY UPDATE cos_key=#{cosKey}, updated_at=NOW()") + int upsert(@Param("cameraCode") String cameraCode, @Param("cosKey") String cosKey); +} diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java index d257b18dd..4a26416f3 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java @@ -21,4 +21,6 @@ public interface IAiAlertService { Map statistics(String startTime); void updateDuration(String alertId, double durationMinutes); + + byte[] proxyAlertImage(String imagePath); } 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 index 9cb3972a2..14ef8415e 100644 --- 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 @@ -1,5 +1,7 @@ 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.AiAlert; import com.genersoft.iot.vmp.aiot.dao.AiAlertMapper; import com.genersoft.iot.vmp.aiot.service.IAiAlertService; @@ -7,8 +9,13 @@ 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.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; @@ -20,6 +27,9 @@ public class AiAlertServiceImpl implements IAiAlertService { @Autowired private AiAlertMapper alertMapper; + @Value("${ai.alert.presign-url:http://127.0.0.1:8000/admin-api/aiot/storage/presign}") + private String presignUrl; + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @Override @@ -72,4 +82,20 @@ public class AiAlertServiceImpl implements IAiAlertService { public void updateDuration(String alertId, double durationMinutes) { alertMapper.updateDuration(alertId, durationMinutes); } + + @Override + public byte[] proxyAlertImage(String imagePath) { + try { + RestTemplate rest = new RestTemplate(); + String endpoint = presignUrl + "?objectKey=" + + URLEncoder.encode(imagePath, StandardCharsets.UTF_8); + String json = rest.getForObject(endpoint, String.class); + JSONObject resp = JSON.parseObject(json); + String cosUrl = resp.getJSONObject("data").getString("url"); + return rest.getForObject(URI.create(cosUrl), byte[].class); + } catch (Exception e) { + log.error("[AiAlert] 代理告警图片失败: path={}, error={}", imagePath, e.getMessage()); + return null; + } + } } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java index 13036713f..4e1808b2f 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java @@ -2,6 +2,7 @@ package com.genersoft.iot.vmp.aiot.service.impl; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; +import com.genersoft.iot.vmp.aiot.dao.AiCameraSnapshotMapper; import com.genersoft.iot.vmp.aiot.service.IAiScreenshotService; import com.genersoft.iot.vmp.streamProxy.bean.StreamProxy; import com.genersoft.iot.vmp.streamProxy.dao.StreamProxyMapper; @@ -16,6 +17,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.HashMap; @@ -41,15 +44,24 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { /** 等待 Edge 回调的 pending 请求表 */ private final ConcurrentHashMap>> pendingRequests = new ConcurrentHashMap<>(); + /** requestId → cosPath 映射,用于回调时持久化 cos_key */ + private final ConcurrentHashMap pendingCosKeys = new ConcurrentHashMap<>(); + @Autowired private StringRedisTemplate stringRedisTemplate; @Autowired private StreamProxyMapper streamProxyMapper; + @Autowired + private AiCameraSnapshotMapper snapshotMapper; + @Value("${ai.screenshot.callback-url:}") private String callbackUrl; + @Value("${ai.alert.presign-url:http://127.0.0.1:8000/admin-api/aiot/storage/presign}") + private String presignUrl; + @Override public Map requestScreenshot(String cameraCode, boolean force) { Map result = new HashMap<>(); @@ -100,6 +112,7 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { try { MapRecord record = MapRecord.create(SNAP_REQUEST_STREAM, fields); RecordId recordId = stringRedisTemplate.opsForStream().add(record); + pendingCosKeys.put(requestId, cosPath); log.info("[AI截图] 发送截图请求: requestId={}, cameraCode={}, streamId={}", requestId, cameraCode, recordId); } catch (Exception e) { log.error("[AI截图] 发送截图请求失败: {}", e.getMessage()); @@ -166,6 +179,7 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { return result; } finally { pendingRequests.remove(requestId); + pendingCosKeys.remove(requestId); } } @@ -182,6 +196,19 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { if ("ok".equals(status) && cameraCode != null) { String url = (String) data.get("url"); writeCache(cameraCode, url); + + // 持久化 cos_key 到数据库 + String cosKey = pendingCosKeys.remove(requestId); + if (cosKey != null) { + try { + snapshotMapper.upsert(cameraCode, cosKey); + log.info("[AI截图] cos_key 已持久化: cameraCode={}, cosKey={}", cameraCode, cosKey); + } catch (Exception e) { + log.warn("[AI截图] cos_key 持久化失败: cameraCode={}, error={}", cameraCode, e.getMessage()); + } + } + } else { + pendingCosKeys.remove(requestId); } CompletableFuture> future = pendingRequests.get(requestId); @@ -218,27 +245,49 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { @Override public byte[] proxyScreenshotImage(String cameraCode) { + // 1. 先查 Redis 缓存(5分钟内的 presigned URL) String cacheJson = stringRedisTemplate.opsForValue().get(SNAP_CACHE_KEY_PREFIX + cameraCode); - if (cacheJson == null) { - log.warn("[AI截图] 代理图片: 缓存不存在 cameraCode={}", cameraCode); + if (cacheJson != null) { + try { + JSONObject cached = JSON.parseObject(cacheJson); + String cosUrl = cached.getString("url"); + if (cosUrl != null && !cosUrl.isEmpty()) { + RestTemplate rest = new RestTemplate(); + byte[] bytes = rest.getForObject(URI.create(cosUrl), byte[].class); + if (bytes != null) return bytes; + } + } catch (Exception e) { + log.warn("[AI截图] Redis 缓存 URL 下载失败,尝试 DB: {}", e.getMessage()); + } + } + + // 2. 查 DB 持久化的 cos_key(永不过期) + String cosKey = snapshotMapper.getCosKey(cameraCode); + if (cosKey == null) { + log.warn("[AI截图] 代理图片: 无缓存也无持久化记录 cameraCode={}", cameraCode); return null; } + // 3. 调 FastAPI 生成新的 presigned URL try { - JSONObject cached = JSON.parseObject(cacheJson); - String cosUrl = cached.getString("url"); - if (cosUrl == null || cosUrl.isEmpty()) { - log.warn("[AI截图] 代理图片: 缓存中无 URL cameraCode={}", cameraCode); - return null; - } + RestTemplate rest = new RestTemplate(); + String endpoint = presignUrl + "?objectKey=" + + URLEncoder.encode(cosKey, StandardCharsets.UTF_8); + String json = rest.getForObject(endpoint, String.class); + JSONObject resp = JSON.parseObject(json); + String cosUrl = resp.getJSONObject("data").getString("url"); - // 使用 URI.create 避免 RestTemplate 对已编码的预签名 URL 做二次编码 - RestTemplate restTemplate = new RestTemplate(); - byte[] imageBytes = restTemplate.getForObject(URI.create(cosUrl), byte[].class); - log.debug("[AI截图] 代理图片成功: cameraCode={}, size={}", cameraCode, imageBytes != null ? imageBytes.length : 0); + // 4. 下载并返回图片 + byte[] imageBytes = rest.getForObject(URI.create(cosUrl), byte[].class); + + // 5. 更新 Redis 缓存(加速后续请求) + if (imageBytes != null) { + writeCache(cameraCode, cosUrl); + } return imageBytes; } catch (Exception e) { - log.error("[AI截图] 代理下载图片失败: cameraCode={}, error={}", cameraCode, e.getMessage()); + log.error("[AI截图] 通过 DB cos_key 下载图片失败: cameraCode={}, cosKey={}, error={}", + cameraCode, cosKey, e.getMessage()); return null; } } diff --git a/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java b/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java index 5d5208eb5..5de05670e 100644 --- a/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java +++ b/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java @@ -106,6 +106,7 @@ public class WebSecurityConfig { defaultExcludes.add("/api/ai/roi/snap/image"); defaultExcludes.add("/api/ai/camera/get"); defaultExcludes.add("/api/ai/alert/edge/**"); + defaultExcludes.add("/api/ai/alert/image"); defaultExcludes.add("/api/ai/device/edge/**"); if (userSetting.getInterfaceAuthentication() && !userSetting.getInterfaceAuthenticationExcludes().isEmpty()) { diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 59d170601..57b4118aa 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -124,6 +124,8 @@ ai: screenshot: # Edge截图回调地址(WVP外部可访问地址,Edge通过此地址回调截图结果) callback-url: http://124.221.55.225:18080 + alert: + presign-url: http://127.0.0.1:8000/admin-api/aiot/storage/presign mqtt: # MQTT推送开关 enabled: false diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 4a24fa674..7d390fbcf 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -86,6 +86,8 @@ ai: enabled: ${AI_SERVICE_ENABLED:false} screenshot: callback-url: ${AI_SCREENSHOT_CALLBACK_URL:} + alert: + presign-url: ${AI_ALERT_PRESIGN_URL:http://vsp-service:8000/admin-api/aiot/storage/presign} mqtt: enabled: ${AI_MQTT_ENABLED:false} broker: ${AI_MQTT_BROKER:tcp://127.0.0.1:1883} diff --git a/web/src/api/aiAlert.js b/web/src/api/aiAlert.js new file mode 100644 index 000000000..909cfe0d2 --- /dev/null +++ b/web/src/api/aiAlert.js @@ -0,0 +1,51 @@ +import request from '@/utils/request' + +export function queryAlertList(params) { + const { page, count, cameraId, alertType, startTime, endTime } = params + return request({ + method: 'get', + url: '/api/ai/alert/list', + params: { page, count, cameraId, alertType, startTime, endTime } + }) +} + +export function queryAlertDetail(alertId) { + return request({ + method: 'get', + url: `/api/ai/alert/${alertId}` + }) +} + +export function deleteAlert(alertId) { + return request({ + method: 'delete', + url: '/api/ai/alert/delete', + params: { alertId } + }) +} + +export function deleteAlertBatch(alertIds) { + return request({ + method: 'delete', + url: '/api/ai/alert/delete', + params: { alertIds } + }) +} + +export function queryAlertStatistics(startTime) { + return request({ + method: 'get', + url: '/api/ai/alert/statistics', + params: { startTime } + }) +} + +/** + * 构建告警图片代理 URL + * @param {string} imagePath COS 对象键(image_path) + * @returns {string} 代理图片 URL + */ +export function getAlertImageUrl(imagePath) { + if (!imagePath) return '' + return '/api/ai/alert/image?imagePath=' + encodeURIComponent(imagePath) +} diff --git a/web/src/api/aiRoi.js b/web/src/api/aiRoi.js index 6898efdf2..5531f3772 100644 --- a/web/src/api/aiRoi.js +++ b/web/src/api/aiRoi.js @@ -63,10 +63,27 @@ export function updateAlgoParams(data) { }) } +/** + * 构建截图代理 URL。 + * 指向 /api/ai/roi/snap/image 代理端点,优先从 DB 读持久化 COS key。 + * force=true 时先触发 Edge 截新图并更新 DB。 + */ export function getSnapUrl(cameraCode, force = false) { - return request({ - method: 'get', - url: '/api/ai/roi/snap', - params: { cameraCode, force } + if (force) { + // force 时先触发一次截图请求(确保 Edge 截新图并更新 DB) + return request({ + method: 'get', + url: '/api/ai/roi/snap', + params: { cameraCode, force: true } + }).then(() => { + return { data: { status: 'ok', url: '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) + '&t=' + Date.now() } } + }).catch(() => { + // 截图请求可能超时,但 DB 会被更新,仍返回代理 URL + return { data: { status: 'ok', url: '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) + '&t=' + Date.now() } } + }) + } + // 非 force:直接返回代理 URL(从 DB 读已有截图,不触发 Edge) + return Promise.resolve({ + data: { status: 'ok', url: '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) } }) } diff --git a/web/src/router/index.js b/web/src/router/index.js index 6a8eee5f3..90696b7af 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -233,6 +233,17 @@ export const constantRoutes = [ } ] }, + { + path: '/alertList', + component: Layout, + redirect: '/alertList', + children: [{ + path: '', + name: 'AlertList', + component: () => import('@/views/alertList/index'), + meta: { title: '告警记录', icon: 'el-icon-warning' } + }] + }, { path: '/cameraConfig', component: Layout, diff --git a/web/src/views/alertList/index.vue b/web/src/views/alertList/index.vue new file mode 100644 index 000000000..20087b3ef --- /dev/null +++ b/web/src/views/alertList/index.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/web/src/views/cameraConfig/roiConfig.vue b/web/src/views/cameraConfig/roiConfig.vue index 48bc24e3d..d689682d7 100644 --- a/web/src/views/cameraConfig/roiConfig.vue +++ b/web/src/views/cameraConfig/roiConfig.vue @@ -153,9 +153,7 @@ export default { getSnapUrl(this.cameraId, force).then(res => { const data = res.data || res if (data.status === 'ok' && data.url) { - // 添加时间戳防止浏览器缓存旧截图 - const url = data.url - this.snapUrl = url + (url.includes('?') ? '&' : '?') + '_t=' + Date.now() + this.snapUrl = data.url if (data.stale) { this.$message.warning('截图为缓存数据,边缘设备可能离线') } diff --git a/web/src/views/roiConfig/components/RoiCanvas.vue b/web/src/views/roiConfig/components/RoiCanvas.vue index 2bece4a42..6b01b1e3f 100644 --- a/web/src/views/roiConfig/components/RoiCanvas.vue +++ b/web/src/views/roiConfig/components/RoiCanvas.vue @@ -57,16 +57,26 @@ export default { snapUrl() { this.loading = true this.errorMsg = '' + this.$nextTick(() => this.initCanvas()) } }, mounted() { - this.$nextTick(() => { - this.initCanvas() - window.addEventListener('resize', this.handleResize) - }) + if (this.$refs.wrapper) { + this._resizeObserver = new ResizeObserver(() => { + if (this.$refs.wrapper && this.$refs.wrapper.clientWidth > 0) { + this.initCanvas() + } + }) + this._resizeObserver.observe(this.$refs.wrapper) + } + window.addEventListener('resize', this.handleResize) }, beforeDestroy() { window.removeEventListener('resize', this.handleResize) + if (this._resizeObserver) { + this._resizeObserver.disconnect() + this._resizeObserver = null + } }, methods: { onImageLoad() { @@ -78,6 +88,7 @@ export default { onImageError() { this.loading = false this.errorMsg = '截图加载失败,请确认摄像头正在拉流' + this.$nextTick(() => this.initCanvas()) }, initCanvas() { const canvas = this.$refs.canvas From 86518ab16339e59dab89e4ec7a1ffe18f57e45d3 Mon Sep 17 00:00:00 2001 From: 16337 <1633794139@qq.com> Date: Tue, 3 Mar 2026 17:04:23 +0800 Subject: [PATCH 26/31] =?UTF-8?q?Revert=20"feat(aiot):=20=E6=88=AA?= =?UTF-8?q?=E5=9B=BE=E6=8C=81=E4=B9=85=E5=8C=96=20+=20ROI=20=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E4=BF=AE=E5=A4=8D=20+=20=E5=91=8A=E8=AD=A6=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E4=BB=A3=E7=90=86"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 547dfdd5f44147adbc9960806be3ca721e3e8ec0. --- sql/wvp_ai_camera_snapshot.sql | 8 - .../aiot/controller/AiAlertController.java | 15 -- .../vmp/aiot/controller/AiRoiController.java | 2 +- .../vmp/aiot/dao/AiCameraSnapshotMapper.java | 15 -- .../iot/vmp/aiot/service/IAiAlertService.java | 2 - .../aiot/service/impl/AiAlertServiceImpl.java | 26 --- .../service/impl/AiScreenshotServiceImpl.java | 75 ++------ .../vmp/conf/security/WebSecurityConfig.java | 1 - src/main/resources/application-dev.yml | 2 - src/main/resources/application-docker.yml | 2 - web/src/api/aiAlert.js | 51 ------ web/src/api/aiRoi.js | 25 +-- web/src/router/index.js | 11 -- web/src/views/alertList/index.vue | 166 ------------------ web/src/views/cameraConfig/roiConfig.vue | 4 +- .../views/roiConfig/components/RoiCanvas.vue | 19 +- 16 files changed, 25 insertions(+), 399 deletions(-) delete mode 100644 sql/wvp_ai_camera_snapshot.sql delete mode 100644 src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java delete mode 100644 web/src/api/aiAlert.js delete mode 100644 web/src/views/alertList/index.vue diff --git a/sql/wvp_ai_camera_snapshot.sql b/sql/wvp_ai_camera_snapshot.sql deleted file mode 100644 index 932782aeb..000000000 --- a/sql/wvp_ai_camera_snapshot.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Screenshot persistence table for storing COS object keys --- Run this SQL on the WVP database before deploying the new version - -CREATE TABLE IF NOT EXISTS wvp_ai_camera_snapshot ( - camera_code VARCHAR(64) PRIMARY KEY, - cos_key VARCHAR(512) NOT NULL COMMENT 'COS 对象键', - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java index 94ef66ad9..7b6d8c0ad 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java @@ -9,8 +9,6 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -62,19 +60,6 @@ public class AiAlertController { return alertService.statistics(startTime); } - @Operation(summary = "告警图片代理") - @GetMapping("/image") - public ResponseEntity getAlertImage(@RequestParam String imagePath) { - byte[] image = alertService.proxyAlertImage(imagePath); - if (image == null) { - return ResponseEntity.notFound().build(); - } - return ResponseEntity.ok() - .contentType(MediaType.IMAGE_JPEG) - .header("Cache-Control", "public, max-age=3600") - .body(image); - } - // ==================== Edge 告警上报 ==================== @Operation(summary = "Edge 告警上报") diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java index aa9b29ac0..7c5c60f86 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java @@ -124,7 +124,7 @@ public class AiRoiController { } return ResponseEntity.ok() .contentType(MediaType.IMAGE_JPEG) - .header("Cache-Control", "public, max-age=300") + .header("Cache-Control", "max-age=60") .body(image); } } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java b/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java deleted file mode 100644 index 44a789a50..000000000 --- a/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.genersoft.iot.vmp.aiot.dao; - -import org.apache.ibatis.annotations.*; - -@Mapper -public interface AiCameraSnapshotMapper { - - @Select("SELECT cos_key FROM wvp_ai_camera_snapshot WHERE camera_code=#{cameraCode}") - String getCosKey(@Param("cameraCode") String cameraCode); - - @Insert("INSERT INTO wvp_ai_camera_snapshot (camera_code, cos_key) " + - "VALUES (#{cameraCode}, #{cosKey}) " + - "ON DUPLICATE KEY UPDATE cos_key=#{cosKey}, updated_at=NOW()") - int upsert(@Param("cameraCode") String cameraCode, @Param("cosKey") String cosKey); -} diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java index 4a26416f3..d257b18dd 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java @@ -21,6 +21,4 @@ public interface IAiAlertService { Map statistics(String startTime); void updateDuration(String alertId, double durationMinutes); - - byte[] proxyAlertImage(String imagePath); } 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 index 14ef8415e..9cb3972a2 100644 --- 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 @@ -1,7 +1,5 @@ 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.AiAlert; import com.genersoft.iot.vmp.aiot.dao.AiAlertMapper; import com.genersoft.iot.vmp.aiot.service.IAiAlertService; @@ -9,13 +7,8 @@ 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.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; -import java.net.URI; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; @@ -27,9 +20,6 @@ public class AiAlertServiceImpl implements IAiAlertService { @Autowired private AiAlertMapper alertMapper; - @Value("${ai.alert.presign-url:http://127.0.0.1:8000/admin-api/aiot/storage/presign}") - private String presignUrl; - private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @Override @@ -82,20 +72,4 @@ public class AiAlertServiceImpl implements IAiAlertService { public void updateDuration(String alertId, double durationMinutes) { alertMapper.updateDuration(alertId, durationMinutes); } - - @Override - public byte[] proxyAlertImage(String imagePath) { - try { - RestTemplate rest = new RestTemplate(); - String endpoint = presignUrl + "?objectKey=" + - URLEncoder.encode(imagePath, StandardCharsets.UTF_8); - String json = rest.getForObject(endpoint, String.class); - JSONObject resp = JSON.parseObject(json); - String cosUrl = resp.getJSONObject("data").getString("url"); - return rest.getForObject(URI.create(cosUrl), byte[].class); - } catch (Exception e) { - log.error("[AiAlert] 代理告警图片失败: path={}, error={}", imagePath, e.getMessage()); - return null; - } - } } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java index 4e1808b2f..13036713f 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java @@ -2,7 +2,6 @@ package com.genersoft.iot.vmp.aiot.service.impl; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; -import com.genersoft.iot.vmp.aiot.dao.AiCameraSnapshotMapper; import com.genersoft.iot.vmp.aiot.service.IAiScreenshotService; import com.genersoft.iot.vmp.streamProxy.bean.StreamProxy; import com.genersoft.iot.vmp.streamProxy.dao.StreamProxyMapper; @@ -17,8 +16,6 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import java.net.URI; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.HashMap; @@ -44,24 +41,15 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { /** 等待 Edge 回调的 pending 请求表 */ private final ConcurrentHashMap>> pendingRequests = new ConcurrentHashMap<>(); - /** requestId → cosPath 映射,用于回调时持久化 cos_key */ - private final ConcurrentHashMap pendingCosKeys = new ConcurrentHashMap<>(); - @Autowired private StringRedisTemplate stringRedisTemplate; @Autowired private StreamProxyMapper streamProxyMapper; - @Autowired - private AiCameraSnapshotMapper snapshotMapper; - @Value("${ai.screenshot.callback-url:}") private String callbackUrl; - @Value("${ai.alert.presign-url:http://127.0.0.1:8000/admin-api/aiot/storage/presign}") - private String presignUrl; - @Override public Map requestScreenshot(String cameraCode, boolean force) { Map result = new HashMap<>(); @@ -112,7 +100,6 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { try { MapRecord record = MapRecord.create(SNAP_REQUEST_STREAM, fields); RecordId recordId = stringRedisTemplate.opsForStream().add(record); - pendingCosKeys.put(requestId, cosPath); log.info("[AI截图] 发送截图请求: requestId={}, cameraCode={}, streamId={}", requestId, cameraCode, recordId); } catch (Exception e) { log.error("[AI截图] 发送截图请求失败: {}", e.getMessage()); @@ -179,7 +166,6 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { return result; } finally { pendingRequests.remove(requestId); - pendingCosKeys.remove(requestId); } } @@ -196,19 +182,6 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { if ("ok".equals(status) && cameraCode != null) { String url = (String) data.get("url"); writeCache(cameraCode, url); - - // 持久化 cos_key 到数据库 - String cosKey = pendingCosKeys.remove(requestId); - if (cosKey != null) { - try { - snapshotMapper.upsert(cameraCode, cosKey); - log.info("[AI截图] cos_key 已持久化: cameraCode={}, cosKey={}", cameraCode, cosKey); - } catch (Exception e) { - log.warn("[AI截图] cos_key 持久化失败: cameraCode={}, error={}", cameraCode, e.getMessage()); - } - } - } else { - pendingCosKeys.remove(requestId); } CompletableFuture> future = pendingRequests.get(requestId); @@ -245,49 +218,27 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { @Override public byte[] proxyScreenshotImage(String cameraCode) { - // 1. 先查 Redis 缓存(5分钟内的 presigned URL) String cacheJson = stringRedisTemplate.opsForValue().get(SNAP_CACHE_KEY_PREFIX + cameraCode); - if (cacheJson != null) { - try { - JSONObject cached = JSON.parseObject(cacheJson); - String cosUrl = cached.getString("url"); - if (cosUrl != null && !cosUrl.isEmpty()) { - RestTemplate rest = new RestTemplate(); - byte[] bytes = rest.getForObject(URI.create(cosUrl), byte[].class); - if (bytes != null) return bytes; - } - } catch (Exception e) { - log.warn("[AI截图] Redis 缓存 URL 下载失败,尝试 DB: {}", e.getMessage()); - } - } - - // 2. 查 DB 持久化的 cos_key(永不过期) - String cosKey = snapshotMapper.getCosKey(cameraCode); - if (cosKey == null) { - log.warn("[AI截图] 代理图片: 无缓存也无持久化记录 cameraCode={}", cameraCode); + if (cacheJson == null) { + log.warn("[AI截图] 代理图片: 缓存不存在 cameraCode={}", cameraCode); return null; } - // 3. 调 FastAPI 生成新的 presigned URL try { - RestTemplate rest = new RestTemplate(); - String endpoint = presignUrl + "?objectKey=" + - URLEncoder.encode(cosKey, StandardCharsets.UTF_8); - String json = rest.getForObject(endpoint, String.class); - JSONObject resp = JSON.parseObject(json); - String cosUrl = resp.getJSONObject("data").getString("url"); - - // 4. 下载并返回图片 - byte[] imageBytes = rest.getForObject(URI.create(cosUrl), byte[].class); - - // 5. 更新 Redis 缓存(加速后续请求) - if (imageBytes != null) { - writeCache(cameraCode, cosUrl); + JSONObject cached = JSON.parseObject(cacheJson); + String cosUrl = cached.getString("url"); + if (cosUrl == null || cosUrl.isEmpty()) { + log.warn("[AI截图] 代理图片: 缓存中无 URL cameraCode={}", cameraCode); + return null; } + + // 使用 URI.create 避免 RestTemplate 对已编码的预签名 URL 做二次编码 + RestTemplate restTemplate = new RestTemplate(); + byte[] imageBytes = restTemplate.getForObject(URI.create(cosUrl), byte[].class); + log.debug("[AI截图] 代理图片成功: cameraCode={}, size={}", cameraCode, imageBytes != null ? imageBytes.length : 0); return imageBytes; } catch (Exception e) { - log.error("[AI截图] 通过 DB cos_key 下载图片失败: cameraCode={}, cosKey={}, error={}", - cameraCode, cosKey, e.getMessage()); + log.error("[AI截图] 代理下载图片失败: cameraCode={}, error={}", cameraCode, e.getMessage()); return null; } } diff --git a/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java b/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java index 5de05670e..5d5208eb5 100644 --- a/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java +++ b/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java @@ -106,7 +106,6 @@ public class WebSecurityConfig { defaultExcludes.add("/api/ai/roi/snap/image"); defaultExcludes.add("/api/ai/camera/get"); defaultExcludes.add("/api/ai/alert/edge/**"); - defaultExcludes.add("/api/ai/alert/image"); defaultExcludes.add("/api/ai/device/edge/**"); if (userSetting.getInterfaceAuthentication() && !userSetting.getInterfaceAuthenticationExcludes().isEmpty()) { diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 57b4118aa..59d170601 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -124,8 +124,6 @@ ai: screenshot: # Edge截图回调地址(WVP外部可访问地址,Edge通过此地址回调截图结果) callback-url: http://124.221.55.225:18080 - alert: - presign-url: http://127.0.0.1:8000/admin-api/aiot/storage/presign mqtt: # MQTT推送开关 enabled: false diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 7d390fbcf..4a24fa674 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -86,8 +86,6 @@ ai: enabled: ${AI_SERVICE_ENABLED:false} screenshot: callback-url: ${AI_SCREENSHOT_CALLBACK_URL:} - alert: - presign-url: ${AI_ALERT_PRESIGN_URL:http://vsp-service:8000/admin-api/aiot/storage/presign} mqtt: enabled: ${AI_MQTT_ENABLED:false} broker: ${AI_MQTT_BROKER:tcp://127.0.0.1:1883} diff --git a/web/src/api/aiAlert.js b/web/src/api/aiAlert.js deleted file mode 100644 index 909cfe0d2..000000000 --- a/web/src/api/aiAlert.js +++ /dev/null @@ -1,51 +0,0 @@ -import request from '@/utils/request' - -export function queryAlertList(params) { - const { page, count, cameraId, alertType, startTime, endTime } = params - return request({ - method: 'get', - url: '/api/ai/alert/list', - params: { page, count, cameraId, alertType, startTime, endTime } - }) -} - -export function queryAlertDetail(alertId) { - return request({ - method: 'get', - url: `/api/ai/alert/${alertId}` - }) -} - -export function deleteAlert(alertId) { - return request({ - method: 'delete', - url: '/api/ai/alert/delete', - params: { alertId } - }) -} - -export function deleteAlertBatch(alertIds) { - return request({ - method: 'delete', - url: '/api/ai/alert/delete', - params: { alertIds } - }) -} - -export function queryAlertStatistics(startTime) { - return request({ - method: 'get', - url: '/api/ai/alert/statistics', - params: { startTime } - }) -} - -/** - * 构建告警图片代理 URL - * @param {string} imagePath COS 对象键(image_path) - * @returns {string} 代理图片 URL - */ -export function getAlertImageUrl(imagePath) { - if (!imagePath) return '' - return '/api/ai/alert/image?imagePath=' + encodeURIComponent(imagePath) -} diff --git a/web/src/api/aiRoi.js b/web/src/api/aiRoi.js index 5531f3772..6898efdf2 100644 --- a/web/src/api/aiRoi.js +++ b/web/src/api/aiRoi.js @@ -63,27 +63,10 @@ export function updateAlgoParams(data) { }) } -/** - * 构建截图代理 URL。 - * 指向 /api/ai/roi/snap/image 代理端点,优先从 DB 读持久化 COS key。 - * force=true 时先触发 Edge 截新图并更新 DB。 - */ export function getSnapUrl(cameraCode, force = false) { - if (force) { - // force 时先触发一次截图请求(确保 Edge 截新图并更新 DB) - return request({ - method: 'get', - url: '/api/ai/roi/snap', - params: { cameraCode, force: true } - }).then(() => { - return { data: { status: 'ok', url: '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) + '&t=' + Date.now() } } - }).catch(() => { - // 截图请求可能超时,但 DB 会被更新,仍返回代理 URL - return { data: { status: 'ok', url: '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) + '&t=' + Date.now() } } - }) - } - // 非 force:直接返回代理 URL(从 DB 读已有截图,不触发 Edge) - return Promise.resolve({ - data: { status: 'ok', url: '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) } + return request({ + method: 'get', + url: '/api/ai/roi/snap', + params: { cameraCode, force } }) } diff --git a/web/src/router/index.js b/web/src/router/index.js index 90696b7af..6a8eee5f3 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -233,17 +233,6 @@ export const constantRoutes = [ } ] }, - { - path: '/alertList', - component: Layout, - redirect: '/alertList', - children: [{ - path: '', - name: 'AlertList', - component: () => import('@/views/alertList/index'), - meta: { title: '告警记录', icon: 'el-icon-warning' } - }] - }, { path: '/cameraConfig', component: Layout, diff --git a/web/src/views/alertList/index.vue b/web/src/views/alertList/index.vue deleted file mode 100644 index 20087b3ef..000000000 --- a/web/src/views/alertList/index.vue +++ /dev/null @@ -1,166 +0,0 @@ - - - - - diff --git a/web/src/views/cameraConfig/roiConfig.vue b/web/src/views/cameraConfig/roiConfig.vue index d689682d7..48bc24e3d 100644 --- a/web/src/views/cameraConfig/roiConfig.vue +++ b/web/src/views/cameraConfig/roiConfig.vue @@ -153,7 +153,9 @@ export default { getSnapUrl(this.cameraId, force).then(res => { const data = res.data || res if (data.status === 'ok' && data.url) { - this.snapUrl = data.url + // 添加时间戳防止浏览器缓存旧截图 + const url = data.url + this.snapUrl = url + (url.includes('?') ? '&' : '?') + '_t=' + Date.now() if (data.stale) { this.$message.warning('截图为缓存数据,边缘设备可能离线') } diff --git a/web/src/views/roiConfig/components/RoiCanvas.vue b/web/src/views/roiConfig/components/RoiCanvas.vue index 6b01b1e3f..2bece4a42 100644 --- a/web/src/views/roiConfig/components/RoiCanvas.vue +++ b/web/src/views/roiConfig/components/RoiCanvas.vue @@ -57,26 +57,16 @@ export default { snapUrl() { this.loading = true this.errorMsg = '' - this.$nextTick(() => this.initCanvas()) } }, mounted() { - if (this.$refs.wrapper) { - this._resizeObserver = new ResizeObserver(() => { - if (this.$refs.wrapper && this.$refs.wrapper.clientWidth > 0) { - this.initCanvas() - } - }) - this._resizeObserver.observe(this.$refs.wrapper) - } - window.addEventListener('resize', this.handleResize) + this.$nextTick(() => { + this.initCanvas() + window.addEventListener('resize', this.handleResize) + }) }, beforeDestroy() { window.removeEventListener('resize', this.handleResize) - if (this._resizeObserver) { - this._resizeObserver.disconnect() - this._resizeObserver = null - } }, methods: { onImageLoad() { @@ -88,7 +78,6 @@ export default { onImageError() { this.loading = false this.errorMsg = '截图加载失败,请确认摄像头正在拉流' - this.$nextTick(() => this.initCanvas()) }, initCanvas() { const canvas = this.$refs.canvas From bdd69ce26894a58dc039c58ce320575a562c0708 Mon Sep 17 00:00:00 2001 From: 16337 <1633794139@qq.com> Date: Tue, 3 Mar 2026 20:06:57 +0800 Subject: [PATCH 27/31] =?UTF-8?q?feat(aiot):=20=E9=9B=86=E6=88=90=E8=85=BE?= =?UTF-8?q?=E8=AE=AF=E4=BA=91=20COS=20Java=20SDK=EF=BC=8CWVP=20=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E7=94=9F=E6=88=90=20presigned=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pom.xml 新增 cos_api 5.6.227 依赖 - 新建 CosUtil 工具类(读取 ai.cos.* 配置,初始化 COSClient,生成预签名 URL) - application-dev.yml / application-docker.yml 增加 ai.cos 配置段 Co-Authored-By: Claude Opus 4.6 --- pom.xml | 7 ++ .../genersoft/iot/vmp/aiot/util/CosUtil.java | 96 +++++++++++++++++++ src/main/resources/application-dev.yml | 5 + src/main/resources/application-docker.yml | 5 + 4 files changed, 113 insertions(+) create mode 100644 src/main/java/com/genersoft/iot/vmp/aiot/util/CosUtil.java diff --git a/pom.xml b/pom.xml index 573c936a8..3616f1cfd 100644 --- a/pom.xml +++ b/pom.xml @@ -422,6 +422,13 @@ spring-boot-starter-test test + + + + com.qcloud + cos_api + 5.6.227 + diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/util/CosUtil.java b/src/main/java/com/genersoft/iot/vmp/aiot/util/CosUtil.java new file mode 100644 index 000000000..0b0d1aaac --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/util/CosUtil.java @@ -0,0 +1,96 @@ +package com.genersoft.iot.vmp.aiot.util; + +import com.qcloud.cos.COSClient; +import com.qcloud.cos.ClientConfig; +import com.qcloud.cos.auth.BasicCOSCredentials; +import com.qcloud.cos.auth.COSCredentials; +import com.qcloud.cos.http.HttpProtocol; +import com.qcloud.cos.region.Region; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.net.URL; +import java.util.Date; + +@Slf4j +@Component +public class CosUtil { + + @Value("${ai.cos.secret-id:${COS_SECRET_ID:}}") + private String secretId; + + @Value("${ai.cos.secret-key:${COS_SECRET_KEY:}}") + private String secretKey; + + @Value("${ai.cos.region:${COS_REGION:ap-beijing}}") + private String region; + + @Value("${ai.cos.bucket:${COS_BUCKET:}}") + private String bucket; + + private COSClient cosClient; + + @PostConstruct + public void init() { + if (secretId == null || secretId.isEmpty() || secretKey == null || secretKey.isEmpty()) { + log.warn("[COS] 未配置 COS 凭证(COS_SECRET_ID/COS_SECRET_KEY),图片代理功能不可用"); + return; + } + if (bucket == null || bucket.isEmpty()) { + log.warn("[COS] 未配置 COS_BUCKET,图片代理功能不可用"); + return; + } + try { + COSCredentials cred = new BasicCOSCredentials(secretId, secretKey); + ClientConfig config = new ClientConfig(new Region(region)); + config.setHttpProtocol(HttpProtocol.https); + cosClient = new COSClient(cred, config); + log.info("[COS] 客户端初始化成功: region={}, bucket={}", region, bucket); + } catch (Exception e) { + log.error("[COS] 客户端初始化失败: {}", e.getMessage()); + } + } + + @PreDestroy + public void destroy() { + if (cosClient != null) { + cosClient.shutdown(); + } + } + + /** + * 生成预签名下载 URL + * + * @param objectKey COS 对象路径 + * @param expireSeconds 有效期(秒) + * @return presigned URL,失败返回 null + */ + public String generatePresignedUrl(String objectKey, int expireSeconds) { + if (cosClient == null) { + log.warn("[COS] 客户端未初始化,无法生成 presigned URL"); + return null; + } + try { + Date expiration = new Date(System.currentTimeMillis() + expireSeconds * 1000L); + URL url = cosClient.generatePresignedUrl(bucket, objectKey, expiration); + return url.toString(); + } catch (Exception e) { + log.error("[COS] 生成 presigned URL 失败: key={}, error={}", objectKey, e.getMessage()); + return null; + } + } + + /** + * 生成预签名下载 URL(默认 1 小时有效) + */ + public String generatePresignedUrl(String objectKey) { + return generatePresignedUrl(objectKey, 3600); + } + + public boolean isAvailable() { + return cosClient != null; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 59d170601..671512501 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -124,6 +124,11 @@ ai: screenshot: # Edge截图回调地址(WVP外部可访问地址,Edge通过此地址回调截图结果) callback-url: http://124.221.55.225:18080 + cos: + secret-id: + secret-key: + region: ap-beijing + bucket: mqtt: # MQTT推送开关 enabled: false diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 4a24fa674..fb16a68b0 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -86,6 +86,11 @@ ai: enabled: ${AI_SERVICE_ENABLED:false} screenshot: callback-url: ${AI_SCREENSHOT_CALLBACK_URL:} + cos: + secret-id: ${COS_SECRET_ID:} + secret-key: ${COS_SECRET_KEY:} + region: ${COS_REGION:ap-beijing} + bucket: ${COS_BUCKET:} mqtt: enabled: ${AI_MQTT_ENABLED:false} broker: ${AI_MQTT_BROKER:tcp://127.0.0.1:1883} From 650894b4e4a06970fdc1d04c3aa47f14af065ad8 Mon Sep 17 00:00:00 2001 From: 16337 <1633794139@qq.com> Date: Tue, 3 Mar 2026 20:08:47 +0800 Subject: [PATCH 28/31] =?UTF-8?q?feat(aiot):=20=E6=88=AA=E5=9B=BE=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=20=E2=80=94=20cos=5Fkey=20=E5=AD=98=E5=85=A5?= =?UTF-8?q?=20DB=EF=BC=8CproxyImage=20=E6=94=AF=E6=8C=81=20DB=20=E5=85=9C?= =?UTF-8?q?=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 wvp_ai_camera_snapshot 表(camera_code → cos_key 映射) - 新建 AiCameraSnapshotMapper(getCosKey / upsert) - AiScreenshotServiceImpl: handleCallback 成功后将 cos_key 写入 DB - AiScreenshotServiceImpl: proxyScreenshotImage 增加 DB 兜底路径 Redis 缓存(5min) → DB(永久) → CosUtil 生成 presigned URL → 下载 - AiRoiController: Cache-Control 从 60s 增大到 300s Co-Authored-By: Claude Opus 4.6 --- sql/wvp_ai_camera_snapshot.sql | 6 ++ .../vmp/aiot/controller/AiRoiController.java | 2 +- .../vmp/aiot/dao/AiCameraSnapshotMapper.java | 15 ++++ .../service/impl/AiScreenshotServiceImpl.java | 82 ++++++++++++++++--- 4 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 sql/wvp_ai_camera_snapshot.sql create mode 100644 src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java diff --git a/sql/wvp_ai_camera_snapshot.sql b/sql/wvp_ai_camera_snapshot.sql new file mode 100644 index 000000000..2f87a1e8a --- /dev/null +++ b/sql/wvp_ai_camera_snapshot.sql @@ -0,0 +1,6 @@ +-- 截图持久化表:保存摄像头最新截图的 COS object key +CREATE TABLE IF NOT EXISTS wvp_ai_camera_snapshot ( + camera_code VARCHAR(64) PRIMARY KEY COMMENT '摄像头编码', + cos_key VARCHAR(512) NOT NULL COMMENT 'COS 对象键(永久有效)', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI摄像头截图持久化'; diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java index 7c5c60f86..aa9b29ac0 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java @@ -124,7 +124,7 @@ public class AiRoiController { } return ResponseEntity.ok() .contentType(MediaType.IMAGE_JPEG) - .header("Cache-Control", "max-age=60") + .header("Cache-Control", "public, max-age=300") .body(image); } } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java b/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java new file mode 100644 index 000000000..ab9c920b5 --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiCameraSnapshotMapper.java @@ -0,0 +1,15 @@ +package com.genersoft.iot.vmp.aiot.dao; + +import org.apache.ibatis.annotations.*; + +@Mapper +public interface AiCameraSnapshotMapper { + + @Select("SELECT cos_key FROM wvp_ai_camera_snapshot WHERE camera_code = #{cameraCode}") + String getCosKey(@Param("cameraCode") String cameraCode); + + @Insert("INSERT INTO wvp_ai_camera_snapshot (camera_code, cos_key) " + + "VALUES (#{cameraCode}, #{cosKey}) " + + "ON DUPLICATE KEY UPDATE cos_key = #{cosKey}, updated_at = NOW()") + int upsert(@Param("cameraCode") String cameraCode, @Param("cosKey") String cosKey); +} diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java index 13036713f..4a46aa391 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java @@ -2,7 +2,9 @@ package com.genersoft.iot.vmp.aiot.service.impl; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; +import com.genersoft.iot.vmp.aiot.dao.AiCameraSnapshotMapper; import com.genersoft.iot.vmp.aiot.service.IAiScreenshotService; +import com.genersoft.iot.vmp.aiot.util.CosUtil; import com.genersoft.iot.vmp.streamProxy.bean.StreamProxy; import com.genersoft.iot.vmp.streamProxy.dao.StreamProxyMapper; import lombok.extern.slf4j.Slf4j; @@ -41,12 +43,21 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { /** 等待 Edge 回调的 pending 请求表 */ private final ConcurrentHashMap>> pendingRequests = new ConcurrentHashMap<>(); + /** requestId → cosPath 映射,截图回调成功后持久化到 DB */ + private final ConcurrentHashMap pendingCosKeys = new ConcurrentHashMap<>(); + @Autowired private StringRedisTemplate stringRedisTemplate; @Autowired private StreamProxyMapper streamProxyMapper; + @Autowired + private AiCameraSnapshotMapper snapshotMapper; + + @Autowired + private CosUtil cosUtil; + @Value("${ai.screenshot.callback-url:}") private String callbackUrl; @@ -100,6 +111,7 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { try { MapRecord record = MapRecord.create(SNAP_REQUEST_STREAM, fields); RecordId recordId = stringRedisTemplate.opsForStream().add(record); + pendingCosKeys.put(requestId, cosPath); log.info("[AI截图] 发送截图请求: requestId={}, cameraCode={}, streamId={}", requestId, cameraCode, recordId); } catch (Exception e) { log.error("[AI截图] 发送截图请求失败: {}", e.getMessage()); @@ -182,6 +194,19 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { if ("ok".equals(status) && cameraCode != null) { String url = (String) data.get("url"); writeCache(cameraCode, url); + + // 持久化 cos_key 到 DB(永不过期,供后续直接读取) + String cosKey = pendingCosKeys.remove(requestId); + if (cosKey != null) { + try { + snapshotMapper.upsert(cameraCode, cosKey); + log.info("[AI截图] cos_key 已持久化: cameraCode={}, cosKey={}", cameraCode, cosKey); + } catch (Exception e) { + log.error("[AI截图] 持久化 cos_key 失败: cameraCode={}, error={}", cameraCode, e.getMessage()); + } + } + } else { + pendingCosKeys.remove(requestId); } CompletableFuture> future = pendingRequests.get(requestId); @@ -218,27 +243,58 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { @Override public byte[] proxyScreenshotImage(String cameraCode) { + // 1. 先查 Redis 缓存中的 presigned URL(5分钟有效) String cacheJson = stringRedisTemplate.opsForValue().get(SNAP_CACHE_KEY_PREFIX + cameraCode); - if (cacheJson == null) { - log.warn("[AI截图] 代理图片: 缓存不存在 cameraCode={}", cameraCode); + if (cacheJson != null) { + try { + JSONObject cached = JSON.parseObject(cacheJson); + String cosUrl = cached.getString("url"); + if (cosUrl != null && !cosUrl.isEmpty()) { + RestTemplate restTemplate = new RestTemplate(); + byte[] bytes = restTemplate.getForObject(URI.create(cosUrl), byte[].class); + if (bytes != null && bytes.length > 0) { + log.debug("[AI截图] 代理图片(Redis缓存): cameraCode={}, size={}", cameraCode, bytes.length); + return bytes; + } + } + } catch (Exception e) { + log.warn("[AI截图] Redis 缓存 URL 下载失败,尝试 DB: {}", e.getMessage()); + } + } + + // 2. 查 DB 持久化的 cos_key(永不过期) + String cosKey = snapshotMapper.getCosKey(cameraCode); + if (cosKey == null) { + log.warn("[AI截图] 代理图片: 无缓存也无持久化记录 cameraCode={}", cameraCode); + return null; + } + + // 3. 通过 CosUtil 直接生成 presigned URL(无需调 FastAPI) + if (!cosUtil.isAvailable()) { + log.warn("[AI截图] COS 客户端未初始化,无法生成 presigned URL"); + return null; + } + + String presignedUrl = cosUtil.generatePresignedUrl(cosKey); + if (presignedUrl == null) { + log.error("[AI截图] 生成 presigned URL 失败: cosKey={}", cosKey); return null; } try { - JSONObject cached = JSON.parseObject(cacheJson); - String cosUrl = cached.getString("url"); - if (cosUrl == null || cosUrl.isEmpty()) { - log.warn("[AI截图] 代理图片: 缓存中无 URL cameraCode={}", cameraCode); - return null; - } - - // 使用 URI.create 避免 RestTemplate 对已编码的预签名 URL 做二次编码 + // 4. 下载图片 RestTemplate restTemplate = new RestTemplate(); - byte[] imageBytes = restTemplate.getForObject(URI.create(cosUrl), byte[].class); - log.debug("[AI截图] 代理图片成功: cameraCode={}, size={}", cameraCode, imageBytes != null ? imageBytes.length : 0); + byte[] imageBytes = restTemplate.getForObject(URI.create(presignedUrl), byte[].class); + + // 5. 更新 Redis 缓存(加速后续请求) + if (imageBytes != null && imageBytes.length > 0) { + writeCache(cameraCode, presignedUrl); + log.debug("[AI截图] 代理图片(DB→COS): cameraCode={}, size={}", cameraCode, imageBytes.length); + } return imageBytes; } catch (Exception e) { - log.error("[AI截图] 代理下载图片失败: cameraCode={}, error={}", cameraCode, e.getMessage()); + log.error("[AI截图] 通过 DB cos_key 下载图片失败: cameraCode={}, cosKey={}, error={}", + cameraCode, cosKey, e.getMessage()); return null; } } From 80f027521688461fe7512af845b8025d633537e8 Mon Sep 17 00:00:00 2001 From: 16337 <1633794139@qq.com> Date: Tue, 3 Mar 2026 20:09:55 +0800 Subject: [PATCH 29/31] =?UTF-8?q?fix(aiot):=20ROI=20=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20=E5=89=8D=E7=AB=AF=E6=88=AA=E5=9B=BE?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aiRoi.js: getSnapUrl 改为直接返回 /snap/image 代理 URL, 非 force 模式不再触发 Edge 截图(从 DB 读持久化截图) - roiConfig.vue: fetchSnap 适配新 API(直接使用返回的 URL) - RoiCanvas.vue: · 添加 ResizeObserver 确保容器尺寸变化时重新初始化 canvas · onImageError 兜底初始化 canvas(截图失败仍可绘制/查看 ROI) · snapUrl watcher 触发 canvas 重初始化 Co-Authored-By: Claude Opus 4.6 --- web/src/api/aiRoi.js | 20 ++++++++++++++----- web/src/views/cameraConfig/roiConfig.vue | 16 ++------------- .../views/roiConfig/components/RoiCanvas.vue | 19 +++++++++++++++++- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/web/src/api/aiRoi.js b/web/src/api/aiRoi.js index 6898efdf2..a3bcbc032 100644 --- a/web/src/api/aiRoi.js +++ b/web/src/api/aiRoi.js @@ -64,9 +64,19 @@ export function updateAlgoParams(data) { } export function getSnapUrl(cameraCode, force = false) { - return request({ - method: 'get', - url: '/api/ai/roi/snap', - params: { cameraCode, force } - }) + if (force) { + // force 模式:先触发 Edge 截图(更新 DB),再返回代理 URL + return request({ + method: 'get', + url: '/api/ai/roi/snap', + params: { cameraCode, force: true } + }).then(() => { + return '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) + '&t=' + Date.now() + }).catch(() => { + // 截图请求失败也返回代理 URL(可能 DB 有旧数据) + return '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) + '&t=' + Date.now() + }) + } + // 非 force:直接返回代理 URL(从 DB 读取持久化截图,不触发 Edge) + return Promise.resolve('/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode)) } diff --git a/web/src/views/cameraConfig/roiConfig.vue b/web/src/views/cameraConfig/roiConfig.vue index 48bc24e3d..a3dfc0711 100644 --- a/web/src/views/cameraConfig/roiConfig.vue +++ b/web/src/views/cameraConfig/roiConfig.vue @@ -150,20 +150,8 @@ export default { fetchSnap(force = false) { if (!this.cameraId) return this.snapLoading = true - getSnapUrl(this.cameraId, force).then(res => { - const data = res.data || res - if (data.status === 'ok' && data.url) { - // 添加时间戳防止浏览器缓存旧截图 - const url = data.url - this.snapUrl = url + (url.includes('?') ? '&' : '?') + '_t=' + Date.now() - if (data.stale) { - this.$message.warning('截图为缓存数据,边缘设备可能离线') - } - } else if (data.status === 'timeout') { - this.$message.warning(data.message || '边缘设备响应超时') - } else { - this.$message.error(data.message || '截图失败') - } + getSnapUrl(this.cameraId, force).then(url => { + this.snapUrl = url }).catch(() => { this.$message.error('截图请求失败,请检查网络') }).finally(() => { diff --git a/web/src/views/roiConfig/components/RoiCanvas.vue b/web/src/views/roiConfig/components/RoiCanvas.vue index 2bece4a42..a732b97f1 100644 --- a/web/src/views/roiConfig/components/RoiCanvas.vue +++ b/web/src/views/roiConfig/components/RoiCanvas.vue @@ -43,7 +43,8 @@ export default { currentPoint: null, polygonPoints: [], loading: true, - errorMsg: '' + errorMsg: '', + resizeObserver: null } }, watch: { @@ -57,16 +58,30 @@ export default { snapUrl() { this.loading = true this.errorMsg = '' + this.$nextTick(() => this.initCanvas()) } }, mounted() { this.$nextTick(() => { this.initCanvas() + // ResizeObserver 确保容器尺寸变化时重新初始化 canvas + if (this.$refs.wrapper && typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => { + if (this.$refs.wrapper && this.$refs.wrapper.clientWidth > 0) { + this.initCanvas() + } + }) + this.resizeObserver.observe(this.$refs.wrapper) + } window.addEventListener('resize', this.handleResize) }) }, beforeDestroy() { window.removeEventListener('resize', this.handleResize) + if (this.resizeObserver) { + this.resizeObserver.disconnect() + this.resizeObserver = null + } }, methods: { onImageLoad() { @@ -78,6 +93,8 @@ export default { onImageError() { this.loading = false this.errorMsg = '截图加载失败,请确认摄像头正在拉流' + // 关键:截图失败也初始化 canvas,使 ROI 区域可见可操作 + this.$nextTick(() => this.initCanvas()) }, initCanvas() { const canvas = this.$refs.canvas From 90e9c1c89622f655c6a7f23a1f73c24ca080a821 Mon Sep 17 00:00:00 2001 From: 16337 <1633794139@qq.com> Date: Tue, 3 Mar 2026 20:12:19 +0800 Subject: [PATCH 30/31] =?UTF-8?q?feat(aiot):=20=E5=91=8A=E8=AD=A6=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E4=BB=A3=E7=90=86=20+=20=E5=91=8A=E8=AD=A6=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - IAiAlertService / AiAlertServiceImpl: 新增 proxyAlertImage() 支持 COS object key(通过 CosUtil 生成 presigned URL)和完整 URL - AiAlertController: 新增 GET /api/ai/alert/image 图片代理端点 - WebSecurityConfig: 白名单加 /api/ai/alert/image 前端: - 新建 aiAlert.js API(列表查询、删除、统计、图片 URL 构造) - 新建 alertList/index.vue 告警列表页面 · 分页表格 + 类型/时间筛选 · 缩略图通过 WVP 图片代理显示 · 详情弹窗 + 删除功能 - router/index.js: 添加告警记录路由 Co-Authored-By: Claude Opus 4.6 --- .../aiot/controller/AiAlertController.java | 15 ++ .../iot/vmp/aiot/service/IAiAlertService.java | 8 + .../aiot/service/impl/AiAlertServiceImpl.java | 44 ++++ .../vmp/conf/security/WebSecurityConfig.java | 1 + web/src/api/aiAlert.js | 51 +++++ web/src/router/index.js | 11 + web/src/views/alertList/index.vue | 216 ++++++++++++++++++ 7 files changed, 346 insertions(+) create mode 100644 web/src/api/aiAlert.js create mode 100644 web/src/views/alertList/index.vue diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java index 7b6d8c0ad..a332ee736 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java @@ -9,6 +9,8 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -60,6 +62,19 @@ public class AiAlertController { return alertService.statistics(startTime); } + @Operation(summary = "告警图片代理(服务端从 COS 下载后返回)") + @GetMapping("/image") + public ResponseEntity getAlertImage(@RequestParam String imagePath) { + byte[] image = alertService.proxyAlertImage(imagePath); + if (image == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_JPEG) + .header("Cache-Control", "public, max-age=3600") + .body(image); + } + // ==================== Edge 告警上报 ==================== @Operation(summary = "Edge 告警上报") diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java index d257b18dd..75b088027 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java @@ -21,4 +21,12 @@ public interface IAiAlertService { Map statistics(String startTime); void updateDuration(String alertId, double durationMinutes); + + /** + * 代理获取告警图片(通过 COS presigned URL 下载后返回字节) + * + * @param imagePath COS 对象路径 + * @return JPEG 图片字节,失败返回 null + */ + byte[] proxyAlertImage(String imagePath); } 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 index 9cb3972a2..126079588 100644 --- 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 @@ -3,12 +3,15 @@ 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.genersoft.iot.vmp.aiot.util.CosUtil; 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 org.springframework.web.client.RestTemplate; +import java.net.URI; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; @@ -20,6 +23,9 @@ public class AiAlertServiceImpl implements IAiAlertService { @Autowired private AiAlertMapper alertMapper; + @Autowired + private CosUtil cosUtil; + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @Override @@ -72,4 +78,42 @@ public class AiAlertServiceImpl implements IAiAlertService { public void updateDuration(String alertId, double durationMinutes) { alertMapper.updateDuration(alertId, durationMinutes); } + + @Override + public byte[] proxyAlertImage(String imagePath) { + if (imagePath == null || imagePath.isEmpty()) { + return null; + } + + // 如果是完整 URL(https://),直接下载 + if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { + try { + RestTemplate restTemplate = new RestTemplate(); + return restTemplate.getForObject(URI.create(imagePath), byte[].class); + } catch (Exception e) { + log.error("[AiAlert] 直接下载告警图片失败: path={}, error={}", imagePath, e.getMessage()); + return null; + } + } + + // COS object key → 生成 presigned URL → 下载 + if (!cosUtil.isAvailable()) { + log.warn("[AiAlert] COS 客户端未初始化,无法代理告警图片"); + return null; + } + + String presignedUrl = cosUtil.generatePresignedUrl(imagePath); + if (presignedUrl == null) { + log.error("[AiAlert] 生成 presigned URL 失败: imagePath={}", imagePath); + return null; + } + + try { + RestTemplate restTemplate = new RestTemplate(); + return restTemplate.getForObject(URI.create(presignedUrl), byte[].class); + } catch (Exception e) { + log.error("[AiAlert] 代理告警图片失败: path={}, error={}", imagePath, e.getMessage()); + return null; + } + } } diff --git a/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java b/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java index 5d5208eb5..5de05670e 100644 --- a/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java +++ b/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java @@ -106,6 +106,7 @@ public class WebSecurityConfig { defaultExcludes.add("/api/ai/roi/snap/image"); defaultExcludes.add("/api/ai/camera/get"); defaultExcludes.add("/api/ai/alert/edge/**"); + defaultExcludes.add("/api/ai/alert/image"); defaultExcludes.add("/api/ai/device/edge/**"); if (userSetting.getInterfaceAuthentication() && !userSetting.getInterfaceAuthenticationExcludes().isEmpty()) { diff --git a/web/src/api/aiAlert.js b/web/src/api/aiAlert.js new file mode 100644 index 000000000..64e1f8a98 --- /dev/null +++ b/web/src/api/aiAlert.js @@ -0,0 +1,51 @@ +import request from '@/utils/request' + +export function queryAlertList(params) { + const { page, count, cameraId, alertType, startTime, endTime } = params + return request({ + method: 'get', + url: '/api/ai/alert/list', + params: { page, count, cameraId, alertType, startTime, endTime } + }) +} + +export function queryAlertDetail(alertId) { + return request({ + method: 'get', + url: `/api/ai/alert/${alertId}` + }) +} + +export function deleteAlert(alertId) { + return request({ + method: 'delete', + url: '/api/ai/alert/delete', + params: { alertId } + }) +} + +export function deleteAlertBatch(alertIds) { + return request({ + method: 'delete', + url: '/api/ai/alert/delete', + params: { alertIds } + }) +} + +export function queryAlertStatistics(startTime) { + return request({ + method: 'get', + url: '/api/ai/alert/statistics', + params: { startTime } + }) +} + +/** + * 构造告警图片代理 URL + * @param {string} imagePath COS 对象路径 + * @returns {string} 图片代理 URL + */ +export function getAlertImageUrl(imagePath) { + if (!imagePath) return '' + return '/api/ai/alert/image?imagePath=' + encodeURIComponent(imagePath) +} diff --git a/web/src/router/index.js b/web/src/router/index.js index 6a8eee5f3..4d50442fe 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -233,6 +233,17 @@ export const constantRoutes = [ } ] }, + { + path: '/alertList', + component: Layout, + redirect: '/alertList', + children: [{ + path: '', + name: 'AlertList', + component: () => import('@/views/alertList/index'), + meta: { title: '告警记录', icon: 'alarm' } + }] + }, { path: '/cameraConfig', component: Layout, diff --git a/web/src/views/alertList/index.vue b/web/src/views/alertList/index.vue new file mode 100644 index 000000000..b06a8cf38 --- /dev/null +++ b/web/src/views/alertList/index.vue @@ -0,0 +1,216 @@ + + + + + From f0466e84d489e56d669be126f030880813368322 Mon Sep 17 00:00:00 2001 From: 16337 <1633794139@qq.com> Date: Wed, 4 Mar 2026 11:10:26 +0800 Subject: [PATCH 31/31] =?UTF-8?q?perf(aiot):=20=E6=88=AA=E5=9B=BE=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20=E2=80=94=20DB=E5=85=9C=E5=BA=95=20+=20=E7=BC=93?= =?UTF-8?q?=E5=AD=98TTL=E5=A2=9E=E5=88=B030=E5=88=86=E9=92=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:Redis缓存5分钟过期后,每次都重新触发Edge截图(15秒) 优化: - Redis缓存TTL从5分钟增到30分钟(COS presigned URL有效1小时) - Redis未命中时先查DB的cos_key,用CosUtil生成新presigned URL - 只有DB也没有记录(首次截图)才触发Edge 效果: - 首次截图:仍需触发Edge(不可避免) - 后续访问:30分钟内走Redis缓存(毫秒级) - 30分钟后:走DB → COS presign(秒级,无需Edge) Co-Authored-By: Claude Opus 4.6 --- .../service/impl/AiScreenshotServiceImpl.java | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java index 4a46aa391..2c22fcc9c 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java @@ -33,8 +33,8 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { private static final String SNAP_CACHE_KEY_PREFIX = "snap:cache:"; private static final String SNAP_RESULT_KEY_PREFIX = "snap:result:"; - /** 缓存 TTL(秒) */ - private static final long SNAP_CACHE_TTL = 300; + /** 缓存 TTL(秒)— 与 COS presigned URL 有效期(1小时)匹配,设为 30 分钟 */ + private static final long SNAP_CACHE_TTL = 1800; /** 最大等待时间(秒) */ private static final long MAX_WAIT_SECONDS = 15; /** 降级 Redis 结果 TTL(秒) */ @@ -80,6 +80,21 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService { log.warn("[AI截图] 缓存解析失败: {}", e.getMessage()); } } + + // 1.5 Redis 缓存未命中 → 从 DB 读取持久化的 cos_key,生成新 presigned URL + // 这样 Redis 过期后不需要重新触发 Edge 截图 + String cosKey = snapshotMapper.getCosKey(cameraCode); + if (cosKey != null && cosUtil.isAvailable()) { + String presignedUrl = cosUtil.generatePresignedUrl(cosKey); + if (presignedUrl != null) { + writeCache(cameraCode, presignedUrl); + result.put("status", "ok"); + result.put("url", presignedUrl); + result.put("cached", true); + log.info("[AI截图] DB兜底命中: cameraCode={}, cosKey={}", cameraCode, cosKey); + return result; + } + } } // 2. 生成 request_id 和 COS 路径