From b47d01fc301720fa2982f607a95e31658bdacd77 Mon Sep 17 00:00:00 2001
From: 16337 <1633794139@qq.com>
Date: Sat, 28 Feb 2026 14:22:53 +0800
Subject: [PATCH] =?UTF-8?q?feat(aiot):=20=E6=88=AA=E5=9B=BE=E5=9B=BE?=
=?UTF-8?q?=E7=89=87=E6=94=B9=E4=B8=BAWVP=E6=9C=8D=E5=8A=A1=E7=AB=AF?=
=?UTF-8?q?=E4=BB=A3=E7=90=86=EF=BC=8C=E8=A7=A3=E5=86=B3=E6=B5=8F=E8=A7=88?=
=?UTF-8?q?=E5=99=A8=E7=9B=B4=E8=BF=9ECOS=E8=B7=A8=E5=9F=9F=E9=97=AE?=
=?UTF-8?q?=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
前端
直接加载COS预签名URL会遇到ERR_CONNECTION_CLOSED,
改为WVP服务端代理:新增/api/ai/roi/snap/image端点,
WVP从Redis读取缓存的COS URL后在服务端下载图片返回给前端,
前端只做同源请求,彻底避免CORS问题。
Co-Authored-By: Claude Opus 4.6
---
.../vmp/aiot/controller/AiRoiController.java | 15 ++++++++
.../aiot/service/IAiScreenshotService.java | 8 +++++
.../service/impl/AiScreenshotServiceImpl.java | 36 ++++++++++++++++---
.../vmp/conf/security/WebSecurityConfig.java | 1 +
4 files changed, 56 insertions(+), 4 deletions(-)
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 9f51963fa..1b3b6604a 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
@@ -11,6 +11,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;
@@ -95,4 +97,17 @@ public class AiRoiController {
String requestId = (String) body.get("request_id");
screenshotService.handleCallback(requestId, body);
}
+
+ @Operation(summary = "截图图片代理(服务端从 COS 下载后返回)")
+ @GetMapping("/snap/image")
+ public ResponseEntity getSnapImage(@RequestParam String cameraCode) {
+ byte[] image = screenshotService.proxyScreenshotImage(cameraCode);
+ if (image == null) {
+ return ResponseEntity.notFound().build();
+ }
+ return ResponseEntity.ok()
+ .contentType(MediaType.IMAGE_JPEG)
+ .header("Cache-Control", "max-age=60")
+ .body(image);
+ }
}
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
index 566c36801..f318ad9f5 100644
--- a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiScreenshotService.java
+++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiScreenshotService.java
@@ -20,4 +20,12 @@ public interface IAiScreenshotService {
* @param data 回调数据 {request_id, camera_code, status, url/message}
*/
void handleCallback(String requestId, Map data);
+
+ /**
+ * 代理获取截图图片(服务端从 COS 下载后返回字节)
+ *
+ * @param cameraCode 摄像头编码
+ * @return JPEG 图片字节,缓存不存在或下载失败返回 null
+ */
+ byte[] proxyScreenshotImage(String cameraCode);
}
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 ebffc20f8..d8e4a224b 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
@@ -11,6 +11,8 @@ import org.springframework.data.redis.connection.stream.RecordId;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
@@ -53,7 +55,7 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
try {
JSONObject cached = JSON.parseObject(cacheJson);
result.put("status", "ok");
- result.put("url", cached.getString("url"));
+ result.put("url", "/api/ai/roi/snap/image?cameraCode=" + cameraCode);
result.put("cached", true);
log.info("[AI截图] 命中缓存: cameraCode={}", cameraCode);
return result;
@@ -98,7 +100,7 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
String status = (String) callbackData.get("status");
result.put("status", status);
if ("ok".equals(status)) {
- result.put("url", callbackData.get("url"));
+ result.put("url", "/api/ai/roi/snap/image?cameraCode=" + cameraCode);
} else {
result.put("message", callbackData.get("message"));
}
@@ -112,7 +114,7 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
JSONObject res = JSON.parseObject(resultJson);
result.put("status", res.getString("status"));
if ("ok".equals(res.getString("status"))) {
- result.put("url", res.getString("url"));
+ result.put("url", "/api/ai/roi/snap/image?cameraCode=" + cameraCode);
// 降级成功,写入缓存
writeCache(cameraCode, res.getString("url"));
} else {
@@ -132,7 +134,7 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
try {
JSONObject cached = JSON.parseObject(staleCache);
result.put("status", "ok");
- result.put("url", cached.getString("url"));
+ result.put("url", "/api/ai/roi/snap/image?cameraCode=" + cameraCode);
result.put("stale", true);
return result;
} catch (Exception ignored) {
@@ -198,4 +200,30 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
String time = now.format(DateTimeFormatter.ofPattern("HH-mm-ss"));
return String.format("snapshots/%s/%s/%s_%s.jpg", cameraCode, date, time, requestId);
}
+
+ @Override
+ public byte[] proxyScreenshotImage(String cameraCode) {
+ String cacheJson = stringRedisTemplate.opsForValue().get(SNAP_CACHE_KEY_PREFIX + cameraCode);
+ if (cacheJson == null) {
+ log.warn("[AI截图] 代理图片: 缓存不存在 cameraCode={}", cameraCode);
+ 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;
+ }
+
+ RestTemplate restTemplate = new RestTemplate();
+ byte[] imageBytes = restTemplate.getForObject(cosUrl, byte[].class);
+ log.debug("[AI截图] 代理图片成功: cameraCode={}, size={}", cameraCode, imageBytes != null ? imageBytes.length : 0);
+ return imageBytes;
+ } catch (Exception e) {
+ 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 6fe07106c..3ce708936 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
@@ -103,6 +103,7 @@ public class WebSecurityConfig {
defaultExcludes.add("/api/jt1078/snap");
defaultExcludes.add("/api/ai/roi/snap");
defaultExcludes.add("/api/ai/roi/snap/callback");
+ defaultExcludes.add("/api/ai/roi/snap/image");
defaultExcludes.add("/api/ai/camera/get");
if (userSetting.getInterfaceAuthentication() && !userSetting.getInterfaceAuthenticationExcludes().isEmpty()) {