feat(aiot): Edge截图方案替代ZLM截图,支持COS URL返回

- 新增 IAiScreenshotService 接口和实现:通过 Redis Stream 请求 Edge
  截图,轮询等待结果,支持 5 分钟缓存和 force 刷新
- AiRoiController.getSnap() 从 ZLM 二进制截图改为返回 JSON(含 COS URL)
- 前端 aiRoi.js 新增 getSnapUrl 方法
- roiConfig.vue 改为异步加载截图,增加 loading 状态和错误提示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 17:25:32 +08:00
parent 152af7ec90
commit 3a601b37e6
5 changed files with 194 additions and 99 deletions

View File

@@ -4,26 +4,17 @@ import com.genersoft.iot.vmp.aiot.bean.AiRoi;
import com.genersoft.iot.vmp.aiot.bean.AiRoiAlgoBind;
import com.genersoft.iot.vmp.aiot.bean.AiRoiDetail;
import com.genersoft.iot.vmp.aiot.service.IAiRoiService;
import com.genersoft.iot.vmp.media.bean.MediaServer;
import com.genersoft.iot.vmp.media.service.IMediaServerService;
import com.genersoft.iot.vmp.streamProxy.bean.StreamProxy;
import com.genersoft.iot.vmp.streamProxy.service.IStreamProxyService;
import com.genersoft.iot.vmp.aiot.service.IAiScreenshotService;
import com.github.pagehelper.PageInfo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.Map;
@Slf4j
@RestController
@@ -35,10 +26,7 @@ public class AiRoiController {
private IAiRoiService roiService;
@Autowired
private IMediaServerService mediaServerService;
@Autowired
private IStreamProxyService streamProxyService;
private IAiScreenshotService screenshotService;
@Operation(summary = "分页查询ROI列表")
@GetMapping("/list")
@@ -93,80 +81,11 @@ public class AiRoiController {
roiService.updateAlgoParams(bind);
}
@Operation(summary = "获取摄像头截图")
@Operation(summary = "获取摄像头截图Edge截图 → COS URL")
@GetMapping("/snap")
public void getSnap(HttpServletResponse resp,
@RequestParam String cameraCode) {
// 通过 camera_code 查询 StreamProxy
StreamProxy proxy = streamProxyService.getStreamProxyByCameraCode(cameraCode);
if (proxy == null) {
log.warn("[AI截图] 未找到camera_code对应的StreamProxy: {}", cameraCode);
resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
// 使用 proxy 的 app 和 stream
String app = proxy.getApp();
String stream = proxy.getStream();
MediaServer mediaServer = mediaServerService.getDefaultMediaServer();
if (mediaServer == null) {
log.warn("[AI截图] 无可用媒体服务器");
resp.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
return;
}
// 使用ZLM内部RTSP流地址截图流必须在线
String internalUrl;
if (mediaServer.getRtspPort() != 0) {
internalUrl = String.format("rtsp://127.0.0.1:%s/%s/%s", mediaServer.getRtspPort(), app, stream);
} else {
internalUrl = String.format("http://127.0.0.1:%s/%s/%s.live.mp4", mediaServer.getHttpPort(), app, stream);
}
log.info("[AI截图] cameraCode={}, app={}, stream={}, 内部地址={}", cameraCode, app, stream, internalUrl);
String zlmApi = String.format("http://%s:%s/index/api/getSnap",
mediaServer.getIp(), mediaServer.getHttpPort());
HttpUrl httpUrl = HttpUrl.parse(zlmApi);
if (httpUrl == null) {
resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
return;
}
HttpUrl requestUrl = httpUrl.newBuilder()
.addQueryParameter("secret", mediaServer.getSecret())
.addQueryParameter("url", internalUrl)
.addQueryParameter("timeout_sec", "10")
.addQueryParameter("expire_sec", "1")
.build();
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build();
try {
Request request = new Request.Builder().url(requestUrl).build();
Response response = client.newCall(request).execute();
if (response.isSuccessful() && response.body() != null) {
String contentType = response.header("Content-Type", "");
byte[] bytes = response.body().bytes();
// ZLM默认logo是47255字节跳过它
if (contentType.contains("image") && bytes.length != 47255) {
resp.setContentType(MediaType.IMAGE_JPEG_VALUE);
resp.getOutputStream().write(bytes);
resp.getOutputStream().flush();
} else {
log.warn("[AI截图] 截图返回默认logo或非图片流可能未在线size={}", bytes.length);
resp.setStatus(HttpServletResponse.SC_NO_CONTENT);
}
} else {
log.warn("[AI截图] ZLM请求失败: {} {}", response.code(), response.message());
resp.setStatus(HttpServletResponse.SC_BAD_GATEWAY);
}
response.close();
} catch (Exception e) {
log.warn("[AI截图] 截图异常: {}", e.getMessage());
resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
public Map<String, Object> getSnap(
@RequestParam String cameraCode,
@RequestParam(defaultValue = "false") boolean force) {
return screenshotService.requestScreenshot(cameraCode, force);
}
}

View File

@@ -0,0 +1,15 @@
package com.genersoft.iot.vmp.aiot.service;
import java.util.Map;
public interface IAiScreenshotService {
/**
* 请求 Edge 截图
*
* @param cameraCode 摄像头编码(对应 StreamProxy.cameraCode
* @param force true 则忽略缓存强制截图
* @return {status: "ok"/"error"/"timeout", url: "...", stale: true/false, message: "..."}
*/
Map<String, Object> requestScreenshot(String cameraCode, boolean force);
}

View File

@@ -0,0 +1,137 @@
package com.genersoft.iot.vmp.aiot.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.genersoft.iot.vmp.aiot.service.IAiScreenshotService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.stream.MapRecord;
import org.springframework.data.redis.connection.stream.RecordId;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class AiScreenshotServiceImpl implements IAiScreenshotService {
private static final String SNAP_REQUEST_STREAM = "edge_snap_request";
private static final String SNAP_RESULT_KEY_PREFIX = "snap:result:";
private static final String SNAP_CACHE_KEY_PREFIX = "snap:cache:";
/** 轮询间隔 ms */
private static final long POLL_INTERVAL_MS = 500;
/** 最大等待时间 ms */
private static final long MAX_WAIT_MS = 15_000;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Map<String, Object> requestScreenshot(String cameraCode, boolean force) {
Map<String, Object> result = new HashMap<>();
// 1. 检查缓存(非 force 模式)
if (!force) {
String cacheJson = stringRedisTemplate.opsForValue().get(SNAP_CACHE_KEY_PREFIX + cameraCode);
if (cacheJson != null) {
try {
JSONObject cached = JSON.parseObject(cacheJson);
result.put("status", "ok");
result.put("url", cached.getString("url"));
result.put("cached", true);
log.info("[AI截图] 命中缓存: cameraCode={}", cameraCode);
return result;
} catch (Exception e) {
log.warn("[AI截图] 缓存解析失败: {}", e.getMessage());
}
}
}
// 2. 生成 request_id 和 COS 路径
String requestId = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
String cosPath = buildCosPath(cameraCode, requestId);
// 3. XADD 到 Stream
Map<String, String> fields = new HashMap<>();
fields.put("request_id", requestId);
fields.put("camera_code", cameraCode);
fields.put("cos_path", cosPath);
try {
MapRecord<String, String, String> record = MapRecord.create(SNAP_REQUEST_STREAM, fields);
RecordId recordId = stringRedisTemplate.opsForStream().add(record);
log.info("[AI截图] 发送截图请求: requestId={}, cameraCode={}, streamId={}", requestId, cameraCode, recordId);
} catch (Exception e) {
log.error("[AI截图] 发送截图请求失败: {}", e.getMessage());
result.put("status", "error");
result.put("message", "发送截图请求失败");
return result;
}
// 4. 轮询结果
String resultKey = SNAP_RESULT_KEY_PREFIX + requestId;
long deadline = System.currentTimeMillis() + MAX_WAIT_MS;
while (System.currentTimeMillis() < deadline) {
try {
Thread.sleep(POLL_INTERVAL_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
String resultJson = stringRedisTemplate.opsForValue().get(resultKey);
if (resultJson != null) {
try {
JSONObject res = JSON.parseObject(resultJson);
result.put("status", res.getString("status"));
if ("ok".equals(res.getString("status"))) {
result.put("url", res.getString("url"));
} else {
result.put("message", res.getString("message"));
}
// 清理结果 key
stringRedisTemplate.delete(resultKey);
return result;
} catch (Exception e) {
log.warn("[AI截图] 结果解析失败: {}", e.getMessage());
}
}
}
// 5. 超时 → 尝试返回过期缓存
log.warn("[AI截图] 截图超时: cameraCode={}, requestId={}", cameraCode, requestId);
String staleCache = stringRedisTemplate.opsForValue().get(SNAP_CACHE_KEY_PREFIX + cameraCode);
if (staleCache != null) {
try {
JSONObject cached = JSON.parseObject(staleCache);
result.put("status", "ok");
result.put("url", cached.getString("url"));
result.put("stale", true);
return result;
} catch (Exception ignored) {
}
}
result.put("status", "timeout");
result.put("message", "边缘设备响应超时,请确认设备在线");
return result;
}
/**
* 构建 COS 存储路径: snapshots/{camera_code}/{日期}/{时间}_{id}.jpg
*/
private String buildCosPath(String cameraCode, String requestId) {
LocalDateTime now = LocalDateTime.now();
String date = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
String time = now.format(DateTimeFormatter.ofPattern("HH-mm-ss"));
return String.format("snapshots/%s/%s/%s_%s.jpg", cameraCode, date, time, requestId);
}
}