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