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:
6
sql/wvp_ai_camera_snapshot.sql
Normal file
6
sql/wvp_ai_camera_snapshot.sql
Normal 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摄像头截图持久化';
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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 URL(5分钟有效)
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user