feat(aiot): 截图图片改为WVP服务端代理,解决浏览器直连COS跨域问题

前端<img>直接加载COS预签名URL会遇到ERR_CONNECTION_CLOSED,
改为WVP服务端代理:新增/api/ai/roi/snap/image端点,
WVP从Redis读取缓存的COS URL后在服务端下载图片返回给前端,
前端只做同源请求,彻底避免CORS问题。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 14:22:53 +08:00
parent 4aae7ee459
commit b47d01fc30
4 changed files with 56 additions and 4 deletions

View File

@@ -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<byte[]> 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);
}
}

View File

@@ -20,4 +20,12 @@ public interface IAiScreenshotService {
* @param data 回调数据 {request_id, camera_code, status, url/message}
*/
void handleCallback(String requestId, Map<String, Object> data);
/**
* 代理获取截图图片(服务端从 COS 下载后返回字节)
*
* @param cameraCode 摄像头编码
* @return JPEG 图片字节,缓存不存在或下载失败返回 null
*/
byte[] proxyScreenshotImage(String cameraCode);
}

View File

@@ -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;
}
}
}

View File

@@ -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()) {