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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user