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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<String, CompletableFuture<Map<String, Object>>> pendingRequests = new ConcurrentHashMap<>();
|
||||
|
||||
/** requestId → cosPath 映射,截图回调成功后持久化到 DB */
|
||||
private final ConcurrentHashMap<String, String> 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<String, String, String> 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<Map<String, Object>> 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user