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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -62,3 +62,11 @@ export function updateAlgoParams(data) {
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
export function getSnapUrl(cameraCode, force = false) {
|
||||
return request({
|
||||
method: 'get',
|
||||
url: '/api/ai/roi/snap',
|
||||
params: { cameraCode, force }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="header-right">
|
||||
<el-button size="small" type="primary" icon="el-icon-plus" @click="startDraw('rectangle')">画矩形</el-button>
|
||||
<el-button size="small" type="primary" icon="el-icon-plus" @click="startDraw('polygon')">画多边形</el-button>
|
||||
<el-button size="small" icon="el-icon-refresh" @click="refreshSnap">刷新截图</el-button>
|
||||
<el-button size="small" icon="el-icon-refresh" :loading="snapLoading" @click="refreshSnap">刷新截图</el-button>
|
||||
<el-button size="small" type="info" @click="handlePush">推送到边缘端</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,7 +87,7 @@
|
||||
<script>
|
||||
import RoiCanvas from '@/views/roiConfig/components/RoiCanvas.vue'
|
||||
import RoiAlgorithmBind from '@/views/roiConfig/components/RoiAlgorithmBind.vue'
|
||||
import { queryRoiByCameraId, saveRoi, deleteRoi, queryRoiDetail } from '@/api/aiRoi'
|
||||
import { queryRoiByCameraId, saveRoi, deleteRoi, queryRoiDetail, getSnapUrl } from '@/api/aiRoi'
|
||||
import { pushConfig } from '@/api/aiConfig'
|
||||
|
||||
export default {
|
||||
@@ -103,7 +103,8 @@ export default {
|
||||
roiList: [],
|
||||
selectedRoiId: null,
|
||||
selectedRoiBindings: [],
|
||||
snapUrl: ''
|
||||
snapUrl: '',
|
||||
snapLoading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -117,7 +118,7 @@ export default {
|
||||
this.srcUrl = this.$route.query.srcUrl || ''
|
||||
this.app = this.$route.query.app || ''
|
||||
this.stream = this.$route.query.stream || ''
|
||||
this.buildSnapUrl()
|
||||
this.fetchSnap()
|
||||
this.loadRois()
|
||||
},
|
||||
methods: {
|
||||
@@ -146,14 +147,29 @@ export default {
|
||||
startDraw(mode) {
|
||||
this.drawMode = mode
|
||||
},
|
||||
buildSnapUrl() {
|
||||
if (this.app && this.stream) {
|
||||
const base = process.env.NODE_ENV === 'development' ? process.env.VUE_APP_BASE_API : ''
|
||||
this.snapUrl = `${base}/api/ai/roi/snap?app=${encodeURIComponent(this.app)}&stream=${encodeURIComponent(this.stream)}&t=${Date.now()}`
|
||||
fetchSnap(force = false) {
|
||||
if (!this.cameraId) return
|
||||
this.snapLoading = true
|
||||
getSnapUrl(this.cameraId, force).then(res => {
|
||||
const data = res.data || res
|
||||
if (data.status === 'ok' && data.url) {
|
||||
this.snapUrl = data.url
|
||||
if (data.stale) {
|
||||
this.$message.warning('截图为缓存数据,边缘设备可能离线')
|
||||
}
|
||||
} else if (data.status === 'timeout') {
|
||||
this.$message.warning(data.message || '边缘设备响应超时')
|
||||
} else {
|
||||
this.$message.error(data.message || '截图失败')
|
||||
}
|
||||
}).catch(() => {
|
||||
this.$message.error('截图请求失败,请检查网络')
|
||||
}).finally(() => {
|
||||
this.snapLoading = false
|
||||
})
|
||||
},
|
||||
refreshSnap() {
|
||||
this.buildSnapUrl()
|
||||
this.fetchSnap(true)
|
||||
},
|
||||
onRoiDrawn(data) {
|
||||
this.drawMode = null
|
||||
|
||||
Reference in New Issue
Block a user