diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java index c41a528f2..41902af70 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiRoiController.java @@ -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 getSnap( + @RequestParam String cameraCode, + @RequestParam(defaultValue = "false") boolean force) { + return screenshotService.requestScreenshot(cameraCode, force); } } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiScreenshotService.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiScreenshotService.java new file mode 100644 index 000000000..702e8e37a --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiScreenshotService.java @@ -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 requestScreenshot(String cameraCode, boolean force); +} diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java new file mode 100644 index 000000000..1c1903c46 --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java @@ -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 requestScreenshot(String cameraCode, boolean force) { + Map 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 fields = new HashMap<>(); + fields.put("request_id", requestId); + fields.put("camera_code", cameraCode); + fields.put("cos_path", cosPath); + + try { + MapRecord 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); + } +} diff --git a/web/src/api/aiRoi.js b/web/src/api/aiRoi.js index dcc39a54d..6898efdf2 100644 --- a/web/src/api/aiRoi.js +++ b/web/src/api/aiRoi.js @@ -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 } + }) +} diff --git a/web/src/views/cameraConfig/roiConfig.vue b/web/src/views/cameraConfig/roiConfig.vue index c4b978345..d689682d7 100644 --- a/web/src/views/cameraConfig/roiConfig.vue +++ b/web/src/views/cameraConfig/roiConfig.vue @@ -8,7 +8,7 @@
画矩形 画多边形 - 刷新截图 + 刷新截图 推送到边缘端
@@ -87,7 +87,7 @@