feat(aiot): 截图持久化 — cos_key 存入 DB,proxyImage 支持 DB 兜底

- 新建 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 20:08:47 +08:00
parent bdd69ce268
commit 650894b4e4
4 changed files with 91 additions and 14 deletions

View File

@@ -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摄像头截图持久化';

View File

@@ -124,7 +124,7 @@ public class AiRoiController {
} }
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(MediaType.IMAGE_JPEG) .contentType(MediaType.IMAGE_JPEG)
.header("Cache-Control", "max-age=60") .header("Cache-Control", "public, max-age=300")
.body(image); .body(image);
} }
} }

View File

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

View File

@@ -2,7 +2,9 @@ package com.genersoft.iot.vmp.aiot.service.impl;
import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject; 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.service.IAiScreenshotService;
import com.genersoft.iot.vmp.aiot.util.CosUtil;
import com.genersoft.iot.vmp.streamProxy.bean.StreamProxy; import com.genersoft.iot.vmp.streamProxy.bean.StreamProxy;
import com.genersoft.iot.vmp.streamProxy.dao.StreamProxyMapper; import com.genersoft.iot.vmp.streamProxy.dao.StreamProxyMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -41,12 +43,21 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
/** 等待 Edge 回调的 pending 请求表 */ /** 等待 Edge 回调的 pending 请求表 */
private final ConcurrentHashMap<String, CompletableFuture<Map<String, Object>>> pendingRequests = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, CompletableFuture<Map<String, Object>>> pendingRequests = new ConcurrentHashMap<>();
/** requestId → cosPath 映射,截图回调成功后持久化到 DB */
private final ConcurrentHashMap<String, String> pendingCosKeys = new ConcurrentHashMap<>();
@Autowired @Autowired
private StringRedisTemplate stringRedisTemplate; private StringRedisTemplate stringRedisTemplate;
@Autowired @Autowired
private StreamProxyMapper streamProxyMapper; private StreamProxyMapper streamProxyMapper;
@Autowired
private AiCameraSnapshotMapper snapshotMapper;
@Autowired
private CosUtil cosUtil;
@Value("${ai.screenshot.callback-url:}") @Value("${ai.screenshot.callback-url:}")
private String callbackUrl; private String callbackUrl;
@@ -100,6 +111,7 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
try { try {
MapRecord<String, String, String> record = MapRecord.create(SNAP_REQUEST_STREAM, fields); MapRecord<String, String, String> record = MapRecord.create(SNAP_REQUEST_STREAM, fields);
RecordId recordId = stringRedisTemplate.opsForStream().add(record); RecordId recordId = stringRedisTemplate.opsForStream().add(record);
pendingCosKeys.put(requestId, cosPath);
log.info("[AI截图] 发送截图请求: requestId={}, cameraCode={}, streamId={}", requestId, cameraCode, recordId); log.info("[AI截图] 发送截图请求: requestId={}, cameraCode={}, streamId={}", requestId, cameraCode, recordId);
} catch (Exception e) { } catch (Exception e) {
log.error("[AI截图] 发送截图请求失败: {}", e.getMessage()); log.error("[AI截图] 发送截图请求失败: {}", e.getMessage());
@@ -182,6 +194,19 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
if ("ok".equals(status) && cameraCode != null) { if ("ok".equals(status) && cameraCode != null) {
String url = (String) data.get("url"); String url = (String) data.get("url");
writeCache(cameraCode, 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<Map<String, Object>> future = pendingRequests.get(requestId); CompletableFuture<Map<String, Object>> future = pendingRequests.get(requestId);
@@ -218,27 +243,58 @@ public class AiScreenshotServiceImpl implements IAiScreenshotService {
@Override @Override
public byte[] proxyScreenshotImage(String cameraCode) { public byte[] proxyScreenshotImage(String cameraCode) {
// 1. 先查 Redis 缓存中的 presigned URL5分钟有效
String cacheJson = stringRedisTemplate.opsForValue().get(SNAP_CACHE_KEY_PREFIX + cameraCode); String cacheJson = stringRedisTemplate.opsForValue().get(SNAP_CACHE_KEY_PREFIX + cameraCode);
if (cacheJson == null) { if (cacheJson != null) {
log.warn("[AI截图] 代理图片: 缓存不存在 cameraCode={}", cameraCode); 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; return null;
} }
try { try {
JSONObject cached = JSON.parseObject(cacheJson); // 4. 下载图片
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(); RestTemplate restTemplate = new RestTemplate();
byte[] imageBytes = restTemplate.getForObject(URI.create(cosUrl), byte[].class); byte[] imageBytes = restTemplate.getForObject(URI.create(presignedUrl), byte[].class);
log.debug("[AI截图] 代理图片成功: cameraCode={}, size={}", cameraCode, imageBytes != null ? imageBytes.length : 0);
// 5. 更新 Redis 缓存(加速后续请求)
if (imageBytes != null && imageBytes.length > 0) {
writeCache(cameraCode, presignedUrl);
log.debug("[AI截图] 代理图片(DB→COS): cameraCode={}, size={}", cameraCode, imageBytes.length);
}
return imageBytes; return imageBytes;
} catch (Exception e) { } 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; return null;
} }
} }