diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..ead3b00b4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +`# Repository Guidelines + +## Project Structure & Module Organization +- `src/main/java`: Spring Boot backend source. +- `src/main/resources`: application config and resources (e.g., `application.yml`, `application-*.yml`). +- `src/test/java`: backend tests (currently minimal; example: `JT1078ServerTest.java`). +- `web/`: Vue 2 frontend app (Element UI, Vue CLI). +- `docker/`: Docker Compose files and environment configuration (`docker/.env`). +- `docs/` and `doc/`: project documentation and media assets. +- `libs/`, `tools/`, `数据库/`: local dependencies, tooling, and database assets. + +## Build, Test, and Development Commands +Backend (local dev): +- `mvn clean package -Dmaven.test.skip=true`: build backend jar quickly. +- `java -jar target/wvp-pro-2.7.4-*.jar --spring.profiles.active=dev`: run with dev profile. +- `mvn -DskipTests=false test`: run backend tests (tests are skipped by default in `pom.xml`). + +Frontend (from `web/`): +- `npm run dev`: local dev server. +- `npm run build:prod`: production build. +- `npm run lint`: lint Vue/JS code. +- `npm run test:unit`: run Jest unit tests. + +Docker (from `docker/`): +- `docker compose build`: build images. +- `docker compose up -d`: start full stack. +- `docker compose logs -f polaris-wvp`: tail backend logs. + +## Coding Style & Naming Conventions +- Java: follow existing Spring Boot style in `src/main/java` (4-space indentation, standard Java naming). +- Vue/JS: follow ESLint rules in `web/` (`npm run lint`). +- Config files: use `application-.yml` naming for Spring profiles. + +## Testing Guidelines +- Backend: tests live under `src/test/java` and typically use `*Test` class suffix. +- Frontend: Jest via `npm run test:unit`. +- There is no enforced coverage gate in the repo; keep tests focused on new behavior. + +## Commit & Pull Request Guidelines +- Commit messages follow Conventional Commit-style prefixes: `feat`, `fix`, `chore`, `refactor`, optionally with scopes (e.g., `feat(aiot): ...`). +- PRs should include: a concise summary, relevant config changes (`application-*.yml`, `docker/.env`), and screenshots for UI changes in `web/`. + +## Configuration Tips +- Local dev settings live in `src/main/resources/application-dev.yml`. +- Docker configuration lives in `docker/.env` (ports, SIP settings, IPs). Update these when changing network topology. diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 000000000..25639502a --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,308 @@ +# WVP-PRO 快速启动指南 + +## 当前状态 + +### ✅ 已启动的服务 +以下 Docker 容器已成功运行: +- **Redis** (docker-polaris-redis-1) - 端口 6379 - 健康运行 +- **MySQL 8** (docker-polaris-mysql-1) - 端口 3306 - 健康运行,数据库已自动初始化 +- **ZLMediaKit** (docker-polaris-media-1) - 流媒体服务 - 运行正常 + - RTMP: 10001 + - RTSP: 10002 + - RTP: 10003 + +### ⚠️ 待解决问题 +- WVP-PRO 应用和 Nginx 前端尚未启动(Docker 镜像拉取失败) + +--- + +## 方案 A:Docker 完整部署(推荐) + +### 前提条件 +需要解决 Docker Hub 网络连接问题。 + +### 步骤 1:配置 Docker 镜像加速 + +#### Windows/Mac (Docker Desktop): +1. 打开 Docker Desktop +2. 进入 Settings > Docker Engine +3. 添加以下配置: +```json +{ + "registry-mirrors": [ + "https://docker.m.daocloud.io", + "https://docker.mirrors.ustc.edu.cn" + ] +} +``` +4. 点击 "Apply & Restart" + +### 步骤 2:拉取所需镜像 +```bash +cd docker + +# 拉取 JDK 镜像(用于构建) +docker pull eclipse-temurin:21-jdk +docker pull eclipse-temurin:21-jre + +# 拉取 Node 镜像(用于前端构建) +docker pull ubuntu:24.04 +docker pull nginx:alpine +``` + +### 步骤 3:构建并启动所有服务 +```bash +# 构建 WVP 和 Nginx +docker compose build + +# 启动所有服务 +docker compose up -d +``` + +### 步骤 4:验证服务状态 +```bash +# 查看所有服务状态 +docker compose ps + +# 查看 WVP 日志 +docker compose logs -f polaris-wvp +``` + +### 步骤 5:访问服务 +- **管理后台**: http://localhost:8080 +- **API 文档**: http://localhost:18978/doc.html +- **默认账号**: admin / admin + +--- + +## 方案 B:本地开发模式 + +适用于需要频繁修改代码的开发场景。 + +### 前提条件 +- JDK 21 ([下载地址](https://adoptium.net/temurin/releases/?version=21)) +- Maven 3.3+ ([下载地址](https://maven.apache.org/download.cgi)) +- Node.js 16+ ([下载地址](https://nodejs.org/)) + +### 步骤 1:保持基础服务运行 +```bash +# 验证基础服务状态 +docker ps --filter "name=polaris-redis|polaris-mysql|polaris-media" + +# 如果服务未运行,启动它们 +cd docker +docker compose up -d polaris-redis polaris-mysql polaris-media +``` + +### 步骤 2:编译前端 +```bash +cd web +npm install --registry=https://registry.npmmirror.com +npm run build:prod +cd .. +``` + +### 步骤 3:配置应用 +编辑 `src/main/resources/application-dev.yml`(已有模板),确认配置: +```yaml +spring: + data: + redis: + host: 127.0.0.1 # 或 localhost + port: 6379 + datasource: + url: jdbc:mysql://127.0.0.1:3306/wvp?... + username: wvp_user + password: wvp_password + +media: + ip: 127.0.0.1 + http-port: 8080 +``` + +### 步骤 4:编译后端 +```bash +mvn clean package -Dmaven.test.skip=true +``` + +### 步骤 5:运行应用 +```bash +# 指定使用 dev 配置 +java -jar target/wvp-pro-2.7.4-*.jar --spring.profiles.active=dev +``` + +### 步骤 6:访问服务 +- **管理后台**: http://localhost:18080 (注意端口) +- **API 文档**: http://localhost:18080/doc.html + +--- + +## 方案 C:混合模式(最快) + +基础服务用 Docker,应用本地运行(无需编译)。 + +### 步骤 1:下载预编译包 +如果网络允许,可以从项目 Release 页面下载预编译的 jar 包。 + +### 步骤 2:使用现有的 Docker 配置运行 +```bash +cd docker/wvp +# 将预编译的 jar 包重命名为 wvp.jar +# 修改 wvp/application-docker.yml 中的数据库配置指向 localhost +``` + +--- + +## 常见问题 + +### 1. Docker 镜像拉取失败 +**问题**: `failed to fetch anonymous token` 或 `EOF` +**解决**: +- 配置国内镜像源(见方案 A 步骤 1) +- 或使用代理 +- 或使用方案 B 本地运行 + +### 2. MySQL 连接失败 +**问题**: `Communications link failure` +**解决**: +```bash +# 检查 MySQL 是否健康 +docker ps | grep mysql + +# 查看 MySQL 日志 +docker logs docker-polaris-mysql-1 + +# 测试连接 +docker exec -it docker-polaris-mysql-1 mysql -u wvp_user -pwvp_password -e "show databases;" +``` + +### 3. 端口冲突 +**问题**: `bind: address already in use` +**解决**: 修改 `docker/.env` 文件中的端口配置: +```env +WebHttp=18080 # 改为其他端口 +SIP_Port=8160 # 改为其他端口 +``` + +### 4. 编译失败 - Java 版本不对 +**问题**: `release version 21 not supported` +**解决**: +```bash +# 检查 Java 版本 +java -version + +# 必须使用 JDK 21,如果不是,请升级 +``` + +--- + +## 服务管理命令 + +### Docker 方式 +```bash +cd docker + +# 启动所有服务 +docker compose up -d + +# 停止所有服务 +docker compose down + +# 重启服务 +docker compose restart polaris-wvp + +# 查看日志 +docker compose logs -f polaris-wvp + +# 查看服务状态 +docker compose ps + +# 清理并重建 +docker compose down -v +docker compose build --no-cache +docker compose up -d +``` + +### 本地运行方式 +```bash +# 停止应用:Ctrl + C + +# 后台运行 +nohup java -jar target/wvp-pro-*.jar & + +# 查看日志 +tail -f nohup.out + +# 停止后台进程 +ps aux | grep wvp-pro +kill +``` + +--- + +## 配置说明 + +### 关键配置项 (docker/.env) +```env +# 流媒体端口 +MediaRtmp=10001 # RTMP 推拉流端口 +MediaRtsp=10002 # RTSP 推拉流端口 +MediaRtp=10003 # RTP 端口 + +# Web 服务端口 +WebHttp=8080 # HTTP 端口 + +# IP 配置(重要!) +Stream_IP=<你的服务器IP> # 流地址中的 IP,客户端需要能访问 +SDP_IP=<你的服务器IP> # SDP 中的 IP,设备需要能访问 +SIP_ShowIP=<你的服务器IP> # SIP 显示的 IP + +# SIP 配置(国标设备接入) +SIP_Port=8160 # SIP 端口 +SIP_Domain=3502000000 # SIP 域(根据实际修改) +SIP_Id=35020000002000000001 # SIP ID +SIP_Password=wvp_sip_password # 设备认证密码 +``` + +### 公网部署注意事项 +1. 将所有 `127.0.0.1` 改为你的**公网 IP** 或**内网 IP**(根据部署场景) +2. 确保防火墙开放所需端口 +3. 建议启用 HTTPS(需要配置证书) + +--- + +## 下一步 + +### 测试设备接入 +1. 配置国标设备(IPC/NVR)的 SIP 服务器信息: + - 服务器 IP: `<你的 SIP_ShowIP>` + - 服务器端口: `8160`(或你配置的 SIP_Port) + - 服务器域: `3502000000`(或你配置的 SIP_Domain) + - 设备 ID: 20位国标编码 + - 密码: `wvp_sip_password`(或你配置的密码) + +2. 设备注册成功后,在 WVP 管理后台可以看到设备列表 + +### 推流测试 +```bash +# 使用 FFmpeg 推流测试 +ffmpeg -re -i test.mp4 -c copy -f rtsp rtsp://localhost:10002/test + +# 播放地址 +# HTTP-FLV: http://localhost:8080/live/test.flv +# WebSocket-FLV: ws://localhost:8080/live/test.flv +# HLS: http://localhost:8080/live/test.m3u8 +``` + +--- + +## 获取帮助 + +- 官方文档: https://doc.wvp-pro.cn +- GitHub: https://github.com/648540858/wvp-GB28181-pro +- Gitee: https://gitee.com/pan648540858/wvp-GB28181-pro +- 问题反馈: 提交 GitHub Issue + +--- + +最后更新: 2026-02-02 diff --git a/pom.xml b/pom.xml index 573c936a8..3616f1cfd 100644 --- a/pom.xml +++ b/pom.xml @@ -422,6 +422,13 @@ spring-boot-starter-test test + + + + com.qcloud + cos_api + 5.6.227 + diff --git a/scripts/edge_local_sync.py b/scripts/edge_local_sync.py new file mode 100644 index 000000000..e5f46f45b --- /dev/null +++ b/scripts/edge_local_sync.py @@ -0,0 +1,36 @@ +import json +import sys +import urllib.request + + +def main(): + if len(sys.argv) < 3: + print("usage: edge_local_sync.py ") + return 2 + + edge_url = sys.argv[1].rstrip("/") + "/debug/sync" + payload_path = sys.argv[2] + + with open(payload_path, "r", encoding="utf-8") as f: + payload = f.read() + + data = payload.encode("utf-8") + req = urllib.request.Request( + edge_url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=10) as resp: + body = resp.read().decode("utf-8", errors="ignore") + print(body) + return 0 if resp.status == 200 else 1 + except Exception as e: + print(f"error: {e}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sql/wvp_ai_camera_snapshot.sql b/sql/wvp_ai_camera_snapshot.sql new file mode 100644 index 000000000..2f87a1e8a --- /dev/null +++ b/sql/wvp_ai_camera_snapshot.sql @@ -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摄像头截图持久化'; diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java index 6eb75c1c2..a332ee736 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiAlertController.java @@ -1,5 +1,6 @@ package com.genersoft.iot.vmp.aiot.controller; +import com.alibaba.fastjson2.JSON; import com.genersoft.iot.vmp.aiot.bean.AiAlert; import com.genersoft.iot.vmp.aiot.service.IAiAlertService; import com.github.pagehelper.PageInfo; @@ -8,6 +9,8 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -58,4 +61,67 @@ public class AiAlertController { @Parameter(description = "统计起始时间") @RequestParam(required = false, defaultValue = "2020-01-01 00:00:00") String startTime) { return alertService.statistics(startTime); } + + @Operation(summary = "告警图片代理(服务端从 COS 下载后返回)") + @GetMapping("/image") + public ResponseEntity getAlertImage(@RequestParam String imagePath) { + byte[] image = alertService.proxyAlertImage(imagePath); + if (image == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_JPEG) + .header("Cache-Control", "public, max-age=3600") + .body(image); + } + + // ==================== Edge 告警上报 ==================== + + @Operation(summary = "Edge 告警上报") + @PostMapping("/edge/report") + public void edgeReport(@RequestBody Map body) { + String alarmId = (String) body.get("alarm_id"); + if (alarmId == null || alarmId.isEmpty()) { + log.warn("[AiAlert] Edge 上报缺少 alarm_id"); + return; + } + + AiAlert alert = new AiAlert(); + alert.setAlertId(alarmId); + alert.setCameraId((String) body.get("device_id")); + alert.setRoiId((String) body.get("scene_id")); + alert.setAlertType((String) body.get("algorithm_code")); + alert.setImagePath((String) body.get("snapshot_url")); + alert.setReceivedAt((String) body.get("event_time")); + + Object conf = body.get("confidence_score"); + if (conf instanceof Number) { + alert.setConfidence(((Number) conf).doubleValue()); + } + + Object extData = body.get("ext_data"); + if (extData != null) { + alert.setExtraData(JSON.toJSONString(extData)); + } + + alertService.save(alert); + log.info("[AiAlert] Edge 告警入库: alarmId={}, camera={}", alarmId, alert.getCameraId()); + } + + @Operation(summary = "Edge 告警结束上报") + @PostMapping("/edge/resolve") + public void edgeResolve(@RequestBody Map body) { + String alarmId = (String) body.get("alarm_id"); + if (alarmId == null || alarmId.isEmpty()) { + log.warn("[AiAlert] Edge resolve 缺少 alarm_id"); + return; + } + + Object durationMs = body.get("duration_ms"); + if (durationMs instanceof Number) { + double minutes = ((Number) durationMs).doubleValue() / 60_000.0; + alertService.updateDuration(alarmId, minutes); + log.info("[AiAlert] Edge 告警结束: alarmId={}, durationMin={}", alarmId, minutes); + } + } } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiConfigController.java b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiConfigController.java index a65103dc4..9d54858fc 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiConfigController.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiConfigController.java @@ -142,7 +142,7 @@ public class AiConfigController { // 将第一个设备改名为 "edge" AiEdgeDevice first = devices.get(0); String oldId = first.getDeviceId(); - edgeDeviceMapper.renameDeviceId(first.getId(), targetDeviceId); + edgeDeviceMapper.renameByDeviceId(oldId, targetDeviceId); result.put("device_action", "renamed"); result.put("old_device_id", oldId); log.info("[AiConfig] 已将设备 {} 改名为 {}", oldId, targetDeviceId); @@ -162,7 +162,7 @@ public class AiConfigController { int updatedRois = roiMapper.updateAllDeviceId(targetDeviceId); result.put("rois_updated", updatedRois); - result.put("status", "success"); + result.put("result", "success"); result.put("device_id", targetDeviceId); log.info("[AiConfig] 统一 device_id 完成: deviceId={}, deletedDevices={}, updatedRois={}", targetDeviceId, deleted, updatedRois); diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiEdgeDeviceController.java b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiEdgeDeviceController.java index 57218d9fc..038f7877e 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiEdgeDeviceController.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/controller/AiEdgeDeviceController.java @@ -2,6 +2,8 @@ package com.genersoft.iot.vmp.aiot.controller; import com.genersoft.iot.vmp.aiot.bean.AiEdgeDevice; import com.genersoft.iot.vmp.aiot.service.IAiEdgeDeviceService; +import com.genersoft.iot.vmp.vmanager.bean.WVPResult; +import com.github.pagehelper.PageInfo; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; @@ -9,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; @Slf4j @RestController @@ -30,4 +33,27 @@ public class AiEdgeDeviceController { public AiEdgeDevice queryDetail(@PathVariable String deviceId) { return edgeDeviceService.queryByDeviceId(deviceId); } + + @Operation(summary = "边缘设备分页查询") + @GetMapping("/page") + public WVPResult> queryPage( + @RequestParam(defaultValue = "1") int pageNo, + @RequestParam(defaultValue = "20") int pageSize, + @RequestParam(required = false) String status) { + PageInfo pageInfo = edgeDeviceService.queryPage(pageNo, pageSize, status); + return WVPResult.success(pageInfo); + } + + @Operation(summary = "按设备ID查询") + @GetMapping("/get") + public WVPResult queryByParam(@RequestParam String deviceId) { + AiEdgeDevice device = edgeDeviceService.queryByDeviceId(deviceId); + return WVPResult.success(device); + } + + @Operation(summary = "设备统计") + @GetMapping("/statistics") + public WVPResult> statistics() { + return WVPResult.success(edgeDeviceService.getStatistics()); + } } 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..aa9b29ac0 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,21 @@ 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.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import java.util.List; -import java.util.concurrent.TimeUnit; +import java.util.Map; @Slf4j @RestController @@ -35,10 +30,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 +85,46 @@ 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); + public ResponseEntity getSnap( + @RequestParam String cameraCode, + @RequestParam(defaultValue = "false") boolean force, + @RequestHeader(value = "Accept", defaultValue = "application/json") String accept, + HttpServletResponse httpResponse) throws IOException { - if (proxy == null) { - log.warn("[AI截图] 未找到camera_code对应的StreamProxy: {}", cameraCode); - resp.setStatus(HttpServletResponse.SC_NOT_FOUND); - return; - } + Map result = screenshotService.requestScreenshot(cameraCode, force); - // 使用 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); + // 标签请求(Accept: image/*):302 重定向到 COS 公共 URL + if (accept.contains("image/") && "ok".equals(result.get("status"))) { + String cosUrl = (String) result.get("url"); + if (cosUrl != null && cosUrl.startsWith("https://")) { + httpResponse.sendRedirect(cosUrl); + return null; } - response.close(); - } catch (Exception e) { - log.warn("[AI截图] 截图异常: {}", e.getMessage()); - resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } + + // JSON 请求(XHR/fetch):返回 JSON + return ResponseEntity.ok(result); + } + + @Operation(summary = "Edge 截图回调(Edge 主动调用)") + @PostMapping("/snap/callback") + public void snapCallback(@RequestBody Map body) { + String requestId = (String) body.get("request_id"); + screenshotService.handleCallback(requestId, body); + } + + @Operation(summary = "截图图片代理(服务端从 COS 下载后返回)") + @GetMapping("/snap/image") + public ResponseEntity getSnapImage(@RequestParam String cameraCode) { + byte[] image = screenshotService.proxyScreenshotImage(cameraCode); + if (image == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_JPEG) + .header("Cache-Control", "public, max-age=300") + .body(image); } } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiAlertMapper.java b/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiAlertMapper.java index 36e587894..cbd328d3c 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiAlertMapper.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiAlertMapper.java @@ -15,8 +15,11 @@ public interface AiAlertMapper { @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") int add(AiAlert alert); + @Select("SELECT COUNT(1) FROM wvp_ai_alert WHERE alert_id=#{alertId}") + int countByAlertId(@Param("alertId") String alertId); + @Select("SELECT a.*, r.name AS roi_name, " + - "COALESCE(SUBSTRING_INDEX(sp.gb_name, '/', 1), sp.name, sp.app) AS camera_name " + + "COALESCE(sp.name, sp.app) AS camera_name " + "FROM wvp_ai_alert a " + "LEFT JOIN wvp_ai_roi r ON a.roi_id = r.roi_id " + "LEFT JOIN wvp_stream_proxy sp ON a.camera_id = sp.camera_code " + @@ -29,7 +32,7 @@ public interface AiAlertMapper { @Select(value = {""}) + List queryPage(@Param("status") String status); + + @Select("SELECT COUNT(*) FROM wvp_ai_edge_device WHERE status=#{status}") + int countByStatus(@Param("status") String status); + + @Select("SELECT COUNT(*) FROM wvp_ai_edge_device") + int countAll(); } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiRoiMapper.java b/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiRoiMapper.java index 86b01a559..b7289c785 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiRoiMapper.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/dao/AiRoiMapper.java @@ -61,4 +61,16 @@ public interface AiRoiMapper { @Update("UPDATE wvp_ai_roi SET device_id=#{deviceId}") int updateAllDeviceId(@Param("deviceId") String deviceId); + + /** + * 将 ROI 表中 camera_id 从 app/stream 格式更新为 camera_code + */ + @Update("UPDATE wvp_ai_roi SET camera_id = #{cameraCode} WHERE camera_id = #{oldCameraId}") + int updateCameraId(@Param("oldCameraId") String oldCameraId, @Param("cameraCode") String cameraCode); + + /** + * 查询使用非 camera_code 格式的 ROI(即 camera_id 不以 cam_ 开头的记录) + */ + @Select("SELECT * FROM wvp_ai_roi WHERE camera_id NOT LIKE 'cam_%'") + List queryWithLegacyCameraId(); } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java index a8791340e..75b088027 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiAlertService.java @@ -19,4 +19,14 @@ public interface IAiAlertService { void deleteBatch(List alertIds); Map statistics(String startTime); + + void updateDuration(String alertId, double durationMinutes); + + /** + * 代理获取告警图片(通过 COS presigned URL 下载后返回字节) + * + * @param imagePath COS 对象路径 + * @return JPEG 图片字节,失败返回 null + */ + byte[] proxyAlertImage(String imagePath); } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiEdgeDeviceService.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiEdgeDeviceService.java index 48aa405e8..228d53fdb 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiEdgeDeviceService.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiEdgeDeviceService.java @@ -1,8 +1,10 @@ package com.genersoft.iot.vmp.aiot.service; import com.genersoft.iot.vmp.aiot.bean.AiEdgeDevice; +import com.github.pagehelper.PageInfo; import java.util.List; +import java.util.Map; public interface IAiEdgeDeviceService { @@ -13,4 +15,8 @@ public interface IAiEdgeDeviceService { List queryAll(); void checkOffline(); + + PageInfo queryPage(int page, int count, String status); + + Map getStatistics(); } 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..f318ad9f5 --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/IAiScreenshotService.java @@ -0,0 +1,31 @@ +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); + + /** + * 处理 Edge 截图回调 + * + * @param requestId 请求ID + * @param data 回调数据 {request_id, camera_code, status, url/message} + */ + void handleCallback(String requestId, Map data); + + /** + * 代理获取截图图片(服务端从 COS 下载后返回字节) + * + * @param cameraCode 摄像头编码 + * @return JPEG 图片字节,缓存不存在或下载失败返回 null + */ + byte[] proxyScreenshotImage(String cameraCode); +} diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiAlertServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiAlertServiceImpl.java index 1c462c6db..126079588 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiAlertServiceImpl.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiAlertServiceImpl.java @@ -3,12 +3,15 @@ package com.genersoft.iot.vmp.aiot.service.impl; import com.genersoft.iot.vmp.aiot.bean.AiAlert; import com.genersoft.iot.vmp.aiot.dao.AiAlertMapper; import com.genersoft.iot.vmp.aiot.service.IAiAlertService; +import com.genersoft.iot.vmp.aiot.util.CosUtil; import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import java.net.URI; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; @@ -20,6 +23,9 @@ public class AiAlertServiceImpl implements IAiAlertService { @Autowired private AiAlertMapper alertMapper; + @Autowired + private CosUtil cosUtil; + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @Override @@ -27,9 +33,8 @@ public class AiAlertServiceImpl implements IAiAlertService { if (alert.getReceivedAt() == null) { alert.setReceivedAt(LocalDateTime.now().format(FORMATTER)); } - // 防止重复插入 - AiAlert existing = alertMapper.queryByAlertId(alert.getAlertId()); - if (existing != null) { + // 防止重复插入(轻量查询,不做 JOIN) + if (alertMapper.countByAlertId(alert.getAlertId()) > 0) { log.debug("[AiAlert] 告警已存在, 跳过: alertId={}", alert.getAlertId()); return; } @@ -68,4 +73,47 @@ public class AiAlertServiceImpl implements IAiAlertService { result.put("by_camera", alertMapper.statisticsByCamera(startTime)); return result; } + + @Override + public void updateDuration(String alertId, double durationMinutes) { + alertMapper.updateDuration(alertId, durationMinutes); + } + + @Override + public byte[] proxyAlertImage(String imagePath) { + if (imagePath == null || imagePath.isEmpty()) { + return null; + } + + // 如果是完整 URL(https://),直接下载 + if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { + try { + RestTemplate restTemplate = new RestTemplate(); + return restTemplate.getForObject(URI.create(imagePath), byte[].class); + } catch (Exception e) { + log.error("[AiAlert] 直接下载告警图片失败: path={}, error={}", imagePath, e.getMessage()); + return null; + } + } + + // COS object key → 生成 presigned URL → 下载 + if (!cosUtil.isAvailable()) { + log.warn("[AiAlert] COS 客户端未初始化,无法代理告警图片"); + return null; + } + + String presignedUrl = cosUtil.generatePresignedUrl(imagePath); + if (presignedUrl == null) { + log.error("[AiAlert] 生成 presigned URL 失败: imagePath={}", imagePath); + return null; + } + + try { + RestTemplate restTemplate = new RestTemplate(); + return restTemplate.getForObject(URI.create(presignedUrl), byte[].class); + } catch (Exception e) { + log.error("[AiAlert] 代理告警图片失败: path={}, error={}", imagePath, e.getMessage()); + return null; + } + } } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiConfigServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiConfigServiceImpl.java index 293ef7306..134f64bff 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiConfigServiceImpl.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiConfigServiceImpl.java @@ -7,7 +7,9 @@ import com.genersoft.iot.vmp.aiot.bean.AiConfigSnapshot; import com.genersoft.iot.vmp.aiot.bean.AiRoi; import com.genersoft.iot.vmp.aiot.bean.AiRoiAlgoBind; import com.genersoft.iot.vmp.aiot.config.AiServiceConfig; +import com.genersoft.iot.vmp.aiot.bean.AiEdgeDevice; import com.genersoft.iot.vmp.aiot.dao.AiAlgorithmMapper; +import com.genersoft.iot.vmp.aiot.dao.AiEdgeDeviceMapper; import com.genersoft.iot.vmp.aiot.dao.AiRoiAlgoBindMapper; import com.genersoft.iot.vmp.aiot.dao.AiRoiMapper; import com.genersoft.iot.vmp.aiot.service.IAiConfigService; @@ -15,6 +17,7 @@ import com.genersoft.iot.vmp.aiot.service.IAiConfigSnapshotService; import com.genersoft.iot.vmp.aiot.service.IAiRedisConfigService; import com.genersoft.iot.vmp.streamProxy.bean.StreamProxy; import com.genersoft.iot.vmp.streamProxy.dao.StreamProxyMapper; +import java.util.UUID; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; @@ -30,6 +33,7 @@ import org.springframework.http.MediaType; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpEntity; +import jakarta.annotation.PostConstruct; import java.util.*; @Slf4j @@ -45,6 +49,9 @@ public class AiConfigServiceImpl implements IAiConfigService { @Autowired private AiAlgorithmMapper algorithmMapper; + @Autowired + private AiEdgeDeviceMapper edgeDeviceMapper; + @Autowired private IAiRedisConfigService redisConfigService; @@ -60,6 +67,94 @@ public class AiConfigServiceImpl implements IAiConfigService { private final ObjectMapper objectMapper = new ObjectMapper(); + /** + * 启动时自动修复数据一致性: + * 1. 统一 ROI 表 device_id + * 2. 回填 stream_proxy 缺失的 camera_code + * 3. 修复 ROI 表中仍使用 app/stream 格式的 camera_id + */ + @PostConstruct + public void normalizeOnStartup() { + // 1. 统一 device_id + try { + String defaultDeviceId = getDefaultDeviceId(); + int updated = roiMapper.updateAllDeviceId(defaultDeviceId); + if (updated > 0) { + log.info("[AiConfig] 启动时统一 {} 条 ROI 的 device_id → {}", updated, defaultDeviceId); + } + } catch (Exception e) { + log.warn("[AiConfig] 启动时修复 device_id 失败: {}", e.getMessage()); + } + + // 2. 回填缺失的 camera_code + backfillCameraCode(); + + // 3. 修复 ROI 表中旧格式的 camera_id(app/stream → camera_code) + fixLegacyRoiCameraId(); + } + + /** + * 为 stream_proxy 表中 camera_code 为 NULL 的记录自动生成 camera_code + */ + private void backfillCameraCode() { + try { + List nullCodeProxies = streamProxyMapper.selectWithNullCameraCode(); + if (nullCodeProxies == null || nullCodeProxies.isEmpty()) { + return; + } + int backfilled = 0; + for (StreamProxy proxy : nullCodeProxies) { + String cameraCode = "cam_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12); + streamProxyMapper.updateCameraCode(proxy.getId(), cameraCode); + backfilled++; + log.info("[AiConfig] 回填 camera_code: id={}, app={}, stream={} → {}", + proxy.getId(), proxy.getApp(), proxy.getStream(), cameraCode); + } + log.info("[AiConfig] 启动时回填 {} 条 stream_proxy 的 camera_code", backfilled); + } catch (Exception e) { + log.warn("[AiConfig] 回填 camera_code 失败: {}", e.getMessage()); + } + } + + /** + * 修复 ROI 表中仍使用 app/stream 格式的 camera_id,替换为对应的 camera_code + * 例如: camera_id="live/camera01" → camera_id="cam_a1b2c3d4e5f6" + */ + private void fixLegacyRoiCameraId() { + try { + List legacyRois = roiMapper.queryWithLegacyCameraId(); + if (legacyRois == null || legacyRois.isEmpty()) { + return; + } + int fixed = 0; + for (AiRoi roi : legacyRois) { + String oldCameraId = roi.getCameraId(); + if (oldCameraId == null || !oldCameraId.contains("/")) { + continue; + } + // 尝试通过 app/stream 查找对应的 stream_proxy + String[] parts = oldCameraId.split("/", 2); + if (parts.length != 2) { + continue; + } + StreamProxy proxy = streamProxyMapper.selectOneByAppAndStream(parts[0], parts[1]); + if (proxy != null && proxy.getCameraCode() != null && !proxy.getCameraCode().isEmpty()) { + roiMapper.updateCameraId(oldCameraId, proxy.getCameraCode()); + fixed++; + log.info("[AiConfig] 修复 ROI camera_id: {} → {} (roi={})", + oldCameraId, proxy.getCameraCode(), roi.getRoiId()); + } else { + log.warn("[AiConfig] ROI camera_id={} 无法找到对应的 stream_proxy 记录", oldCameraId); + } + } + if (fixed > 0) { + log.info("[AiConfig] 启动时修复 {} 条 ROI 的 camera_id(app/stream → camera_code)", fixed); + } + } catch (Exception e) { + log.warn("[AiConfig] 修复 ROI camera_id 失败: {}", e.getMessage()); + } + } + @Override public Map exportConfig(String cameraId) { Map config = new LinkedHashMap<>(); @@ -132,10 +227,17 @@ public class AiConfigServiceImpl implements IAiConfigService { // 5. 写入设备聚合配置 + Stream 通知(新格式,对接 Edge config_sync) String deviceId = roiMapper.queryDeviceIdByCameraId(cameraId); + if (deviceId == null || deviceId.isEmpty()) { + // 回退:从 edge 设备表获取默认设备 + deviceId = getDefaultDeviceId(); + log.info("[AiConfig] 摄像头 {} 未关联设备,使用默认设备: {}", cameraId, deviceId); + } + boolean redisSyncOk = false; if (deviceId != null && !deviceId.isEmpty()) { redisConfigService.writeDeviceAggregatedConfig(deviceId, "UPDATE"); + redisSyncOk = true; } else { - log.warn("[AiConfig] 摄像头 {} 未关联边缘设备,跳过聚合配置推送", cameraId); + log.warn("[AiConfig] 无法确定设备ID,跳过 Redis 聚合配置推送。请先注册边缘设备或为摄像头关联 device_id"); } // 6. 本地调试:同步到 Edge HTTP 接口(保留原 Redis 流程) @@ -145,9 +247,12 @@ public class AiConfigServiceImpl implements IAiConfigService { Map result = new LinkedHashMap<>(); result.put("camera_id", cameraId); result.put("version", snapshot.getVersion()); - result.put("push_status", "success"); - result.put("message", "配置已推送到Redis并通知边缘端"); + result.put("push_status", redisSyncOk ? "success" : "partial"); + result.put("message", redisSyncOk + ? "配置已推送到Redis并通知边缘端" + : "配置已保存,但未能同步到边缘端(缺少 device_id)"); result.put("http_sync", httpSyncOk); + result.put("redis_sync", redisSyncOk); log.info("[AiConfig] 配置推送完成: cameraId={}, version={}", cameraId, snapshot.getVersion()); return result; @@ -163,12 +268,34 @@ public class AiConfigServiceImpl implements IAiConfigService { payload.put("sync_mode", "full"); boolean httpSyncOk = pushPayloadToLocalEdge(payload); + // 写入 Redis 聚合配置 + Stream 事件(供 CONFIG_SYNC_MODE=REDIS 模式的 Edge 使用) + Set deviceIds = new LinkedHashSet<>(); + for (AiRoi roi : rois) { + String deviceId = roi.getDeviceId(); + if (deviceId != null && !deviceId.isEmpty()) { + deviceIds.add(deviceId); + } + } + // 回退:如果没有任何 ROI 关联设备,使用默认设备 + if (deviceIds.isEmpty()) { + String defaultId = getDefaultDeviceId(); + if (defaultId != null && !defaultId.isEmpty()) { + deviceIds.add(defaultId); + log.info("[AiConfig] ROI 未关联设备,使用默认设备: {}", defaultId); + } + } + for (String deviceId : deviceIds) { + redisConfigService.writeDeviceAggregatedConfig(deviceId, "UPDATE"); + } + log.info("[AiConfig] 全量推送 Redis 聚合配置完成, deviceIds={}", deviceIds); + Map result = new LinkedHashMap<>(); result.put("rois", rois.size()); result.put("binds", binds.size()); result.put("http_sync", httpSyncOk); - result.put("message", "已推送全部ROI和算法绑定到Edge"); - log.info("[AiConfig] 全量推送完成 rois={}, binds={}, httpSync={}", rois.size(), binds.size(), httpSyncOk); + result.put("redis_sync_devices", deviceIds.size()); + result.put("message", "已推送全部ROI和算法绑定到Edge(HTTP+Redis双通道)"); + log.info("[AiConfig] 全量推送完成 rois={}, binds={}, httpSync={}, redisDevices={}", rois.size(), binds.size(), httpSyncOk, deviceIds.size()); return result; } @@ -416,4 +543,20 @@ public class AiConfigServiceImpl implements IAiConfigService { return false; } } + + /** + * 获取默认边缘设备 ID(从设备表查第一个,否则返回 "edge") + */ + private String getDefaultDeviceId() { + try { + List devices = edgeDeviceMapper.queryAll(); + if (devices != null && !devices.isEmpty()) { + return devices.get(0).getDeviceId(); + } + } catch (Exception e) { + log.warn("[AiConfig] 查询默认设备失败: {}", e.getMessage()); + } + // 硬编码回退值,与 Edge 端 EDGE_DEVICE_ID 默认值一致 + return "edge"; + } } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiEdgeDeviceServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiEdgeDeviceServiceImpl.java index 474bd117b..4d055f640 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiEdgeDeviceServiceImpl.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiEdgeDeviceServiceImpl.java @@ -5,6 +5,8 @@ import com.alibaba.fastjson2.JSONObject; import com.genersoft.iot.vmp.aiot.bean.AiEdgeDevice; import com.genersoft.iot.vmp.aiot.dao.AiEdgeDeviceMapper; import com.genersoft.iot.vmp.aiot.service.IAiEdgeDeviceService; +import com.github.pagehelper.PageHelper; +import com.github.pagehelper.PageInfo; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; @@ -12,7 +14,9 @@ import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.HashMap; import java.util.List; +import java.util.Map; @Slf4j @Service @@ -82,4 +86,20 @@ public class AiEdgeDeviceServiceImpl implements IAiEdgeDeviceService { log.warn("[AiEdgeDevice] 标记{}台设备为离线", count); } } + + @Override + public PageInfo queryPage(int page, int count, String status) { + PageHelper.startPage(page, count); + List list = deviceMapper.queryPage(status); + return new PageInfo<>(list); + } + + @Override + public Map getStatistics() { + Map stats = new HashMap<>(); + stats.put("total", deviceMapper.countAll()); + stats.put("online", deviceMapper.countByStatus("online")); + stats.put("offline", deviceMapper.countByStatus("offline")); + return stats; + } } diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiRedisConfigServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiRedisConfigServiceImpl.java index f6048a321..f6e82a602 100644 --- a/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiRedisConfigServiceImpl.java +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiRedisConfigServiceImpl.java @@ -492,15 +492,19 @@ public class AiRedisConfigServiceImpl implements IAiRedisConfigService { // 摄像头信息 Map cameraMap = new LinkedHashMap<>(); - // cameraId 现在是 camera_code 格式(从 ROI 表) - // 使用 camera_code 查询 StreamProxy 获取详细信息 + // 使用 camera_code 查询 StreamProxy 获取 RTSP 源地址 StreamProxy proxy = streamProxyMapper.selectByCameraCode(cameraId); - cameraMap.put("camera_code", cameraId); // 新字段名 - cameraMap.put("camera_id", cameraId); // 保持向后兼容 + if (proxy == null) { + log.warn("[AiRedis] 摄像头 {} 在 stream_proxy 表中未找到(camera_code 不匹配),跳过。" + + "请确认该摄像头的 camera_code 已正确写入 wvp_stream_proxy 表", cameraId); + continue; + } - // 获取 RTSP URL 和摄像头名称 - String rtspUrl = ""; + cameraMap.put("camera_code", cameraId); + cameraMap.put("camera_id", cameraId); + + // 获取摄像头名称 String cameraName = ""; List cameraRois = roiMapper.queryAllByCameraId(cameraId); @@ -518,33 +522,44 @@ public class AiRedisConfigServiceImpl implements IAiRedisConfigService { } } - // 优先使用 StreamProxy 中的源 URL - if (proxy != null && proxy.getSrcUrl() != null && !proxy.getSrcUrl().isEmpty()) { + // 获取 RTSP URL:优先使用 StreamProxy 的源 URL + String rtspUrl = ""; + if (proxy.getSrcUrl() != null && !proxy.getSrcUrl().isEmpty()) { rtspUrl = proxy.getSrcUrl(); log.debug("[AiRedis] 使用StreamProxy源URL: cameraCode={}, srcUrl={}", cameraId, rtspUrl); } else { - // 降级方案:构建 RTSP 代理地址(通过ZLM媒体服务器) - // cameraId格式为 {app}/{stream},ZLM的RTSP路径直接使用该格式 + // 降级方案:用 proxy 的 app/stream 通过 ZLM 媒体服务器构建代理地址 try { MediaServer mediaServer = mediaServerService.getDefaultMediaServer(); - if (mediaServer != null && mediaServer.getRtspPort() != 0) { - rtspUrl = String.format("rtsp://%s:%s/%s", - mediaServer.getStreamIp() != null ? mediaServer.getStreamIp() : mediaServer.getIp(), - mediaServer.getRtspPort(), cameraId); - } else if (mediaServer != null) { - rtspUrl = String.format("http://%s:%s/%s.live.flv", - mediaServer.getStreamIp() != null ? mediaServer.getStreamIp() : mediaServer.getIp(), - mediaServer.getHttpPort(), cameraId); + if (mediaServer != null && proxy.getApp() != null && proxy.getStream() != null) { + if (mediaServer.getRtspPort() != 0) { + rtspUrl = String.format("rtsp://%s:%s/%s/%s", + mediaServer.getStreamIp() != null ? mediaServer.getStreamIp() : mediaServer.getIp(), + mediaServer.getRtspPort(), proxy.getApp(), proxy.getStream()); + log.debug("[AiRedis] 使用ZLM代理构建RTSP: {}", rtspUrl); + } else if (mediaServer.getHttpPort() != 0) { + rtspUrl = String.format("http://%s:%s/%s/%s.live.flv", + mediaServer.getStreamIp() != null ? mediaServer.getStreamIp() : mediaServer.getIp(), + mediaServer.getHttpPort(), proxy.getApp(), proxy.getStream()); + log.debug("[AiRedis] 使用ZLM代理构建HTTP-FLV: {}", rtspUrl); + } } } catch (Exception e) { log.warn("[AiRedis] 获取媒体服务器信息失败: {}", e.getMessage()); } } - cameraMap.put("rtsp_url", rtspUrl); + // 无有效 RTSP URL 则跳过,不推送空地址给 Edge + if (rtspUrl == null || rtspUrl.isEmpty()) { + log.warn("[AiRedis] 摄像头 {} 有 stream_proxy 记录但无有效 RTSP 地址(srcUrl 为空且 ZLM 不可用),跳过", cameraId); + continue; + } + cameraMap.put("camera_name", cameraName); cameraMap.put("enabled", true); cameraMap.put("location", ""); + cameraMap.put("rtsp_url", rtspUrl); + cameraMap.put("rtsp_url_valid", true); cameras.add(cameraMap); // 该摄像头下的 ROI 和绑定 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..2c22fcc9c --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/service/impl/AiScreenshotServiceImpl.java @@ -0,0 +1,316 @@ +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; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +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 org.springframework.web.client.RestTemplate; + +import java.net.URI; +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.*; + +@Slf4j +@Service +public class AiScreenshotServiceImpl implements IAiScreenshotService { + + private static final String SNAP_REQUEST_STREAM = "edge_snap_request"; + private static final String SNAP_CACHE_KEY_PREFIX = "snap:cache:"; + private static final String SNAP_RESULT_KEY_PREFIX = "snap:result:"; + + /** 缓存 TTL(秒)— 与 COS presigned URL 有效期(1小时)匹配,设为 30 分钟 */ + private static final long SNAP_CACHE_TTL = 1800; + /** 最大等待时间(秒) */ + private static final long MAX_WAIT_SECONDS = 15; + /** 降级 Redis 结果 TTL(秒) */ + private static final long SNAP_RESULT_TTL = 60; + + /** 等待 Edge 回调的 pending 请求表 */ + private final ConcurrentHashMap>> pendingRequests = new ConcurrentHashMap<>(); + + /** requestId → cosPath 映射,截图回调成功后持久化到 DB */ + private final ConcurrentHashMap 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; + + @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()); + } + } + + // 1.5 Redis 缓存未命中 → 从 DB 读取持久化的 cos_key,生成新 presigned URL + // 这样 Redis 过期后不需要重新触发 Edge 截图 + String cosKey = snapshotMapper.getCosKey(cameraCode); + if (cosKey != null && cosUtil.isAvailable()) { + String presignedUrl = cosUtil.generatePresignedUrl(cosKey); + if (presignedUrl != null) { + writeCache(cameraCode, presignedUrl); + result.put("status", "ok"); + result.put("url", presignedUrl); + result.put("cached", true); + log.info("[AI截图] DB兜底命中: cameraCode={}, cosKey={}", cameraCode, cosKey); + return result; + } + } + } + + // 2. 生成 request_id 和 COS 路径 + String requestId = UUID.randomUUID().toString().replace("-", "").substring(0, 12); + String cosPath = buildCosPath(cameraCode, requestId); + + // 3. 创建 CompletableFuture 并注册到 pending 表 + CompletableFuture> future = new CompletableFuture<>(); + pendingRequests.put(requestId, future); + + // 4. XADD 到 Stream(含 callback_url) + Map fields = new HashMap<>(); + fields.put("request_id", requestId); + fields.put("camera_code", cameraCode); + fields.put("cos_path", cosPath); + if (callbackUrl != null && !callbackUrl.isEmpty()) { + fields.put("callback_url", callbackUrl); + } + + // 查询 rtsp_url 放入请求,供 Edge 对无 ROI 摄像头临时连接截图 + StreamProxy proxy = streamProxyMapper.selectByCameraCode(cameraCode); + if (proxy != null) { + String rtspUrl = proxy.getSrcUrl(); + if (rtspUrl != null && !rtspUrl.isEmpty()) { + fields.put("rtsp_url", rtspUrl); + } + } + + try { + MapRecord 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()); + pendingRequests.remove(requestId); + result.put("status", "error"); + result.put("message", "发送截图请求失败"); + return result; + } + + // 5. 等待回调结果 + try { + Map callbackData = future.get(MAX_WAIT_SECONDS, TimeUnit.SECONDS); + String status = (String) callbackData.get("status"); + result.put("status", status); + if ("ok".equals(status)) { + result.put("url", callbackData.get("url")); + } else { + result.put("message", callbackData.get("message")); + } + return result; + } catch (TimeoutException e) { + // 超时 → 降级检查 Redis 结果(Edge 回调失败时可能回退写 Redis) + log.warn("[AI截图] 回调超时,检查 Redis 降级: requestId={}", requestId); + String resultJson = stringRedisTemplate.opsForValue().get(SNAP_RESULT_KEY_PREFIX + requestId); + 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")); + // 降级成功,写入缓存 + writeCache(cameraCode, res.getString("url")); + } else { + result.put("message", res.getString("message")); + } + stringRedisTemplate.delete(SNAP_RESULT_KEY_PREFIX + requestId); + return result; + } catch (Exception ex) { + log.warn("[AI截图] 降级结果解析失败: {}", ex.getMessage()); + } + } + + // Redis 降级也没有 → 尝试返回过期缓存 + 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; + } catch (Exception e) { + log.error("[AI截图] 等待回调异常: {}", e.getMessage()); + result.put("status", "error"); + result.put("message", "截图请求异常"); + return result; + } finally { + pendingRequests.remove(requestId); + } + } + + @Override + public void handleCallback(String requestId, Map data) { + if (requestId == null || requestId.isEmpty()) { + log.warn("[AI截图] 回调缺少 request_id"); + return; + } + + // 先写 Redis 缓存,再唤醒等待线程(避免竞态:线程被唤醒后立即读缓存但缓存还没写入) + String cameraCode = (String) data.get("camera_code"); + String status = (String) data.get("status"); + 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> future = pendingRequests.get(requestId); + if (future != null) { + future.complete(data); + log.info("[AI截图] 回调完成: requestId={}", requestId); + } else { + log.warn("[AI截图] 回调未找到对应请求(可能已超时): requestId={}", requestId); + } + } + + /** + * 写入截图缓存 + */ + private void writeCache(String cameraCode, String url) { + String cacheKey = SNAP_CACHE_KEY_PREFIX + cameraCode; + String cacheData = JSON.toJSONString(Map.of("url", url, "timestamp", System.currentTimeMillis() / 1000)); + try { + stringRedisTemplate.opsForValue().set(cacheKey, cacheData, SNAP_CACHE_TTL, TimeUnit.SECONDS); + } catch (Exception e) { + log.warn("[AI截图] 写入缓存失败: {}", e.getMessage()); + } + } + + /** + * 构建 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); + } + + @Override + public byte[] proxyScreenshotImage(String cameraCode) { + // 1. 先查 Redis 缓存中的 presigned URL(5分钟有效) + String cacheJson = stringRedisTemplate.opsForValue().get(SNAP_CACHE_KEY_PREFIX + 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 { + // 4. 下载图片 + RestTemplate restTemplate = new RestTemplate(); + 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截图] 通过 DB cos_key 下载图片失败: cameraCode={}, cosKey={}, error={}", + cameraCode, cosKey, e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/com/genersoft/iot/vmp/aiot/util/CosUtil.java b/src/main/java/com/genersoft/iot/vmp/aiot/util/CosUtil.java new file mode 100644 index 000000000..0b0d1aaac --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/aiot/util/CosUtil.java @@ -0,0 +1,96 @@ +package com.genersoft.iot.vmp.aiot.util; + +import com.qcloud.cos.COSClient; +import com.qcloud.cos.ClientConfig; +import com.qcloud.cos.auth.BasicCOSCredentials; +import com.qcloud.cos.auth.COSCredentials; +import com.qcloud.cos.http.HttpProtocol; +import com.qcloud.cos.region.Region; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.net.URL; +import java.util.Date; + +@Slf4j +@Component +public class CosUtil { + + @Value("${ai.cos.secret-id:${COS_SECRET_ID:}}") + private String secretId; + + @Value("${ai.cos.secret-key:${COS_SECRET_KEY:}}") + private String secretKey; + + @Value("${ai.cos.region:${COS_REGION:ap-beijing}}") + private String region; + + @Value("${ai.cos.bucket:${COS_BUCKET:}}") + private String bucket; + + private COSClient cosClient; + + @PostConstruct + public void init() { + if (secretId == null || secretId.isEmpty() || secretKey == null || secretKey.isEmpty()) { + log.warn("[COS] 未配置 COS 凭证(COS_SECRET_ID/COS_SECRET_KEY),图片代理功能不可用"); + return; + } + if (bucket == null || bucket.isEmpty()) { + log.warn("[COS] 未配置 COS_BUCKET,图片代理功能不可用"); + return; + } + try { + COSCredentials cred = new BasicCOSCredentials(secretId, secretKey); + ClientConfig config = new ClientConfig(new Region(region)); + config.setHttpProtocol(HttpProtocol.https); + cosClient = new COSClient(cred, config); + log.info("[COS] 客户端初始化成功: region={}, bucket={}", region, bucket); + } catch (Exception e) { + log.error("[COS] 客户端初始化失败: {}", e.getMessage()); + } + } + + @PreDestroy + public void destroy() { + if (cosClient != null) { + cosClient.shutdown(); + } + } + + /** + * 生成预签名下载 URL + * + * @param objectKey COS 对象路径 + * @param expireSeconds 有效期(秒) + * @return presigned URL,失败返回 null + */ + public String generatePresignedUrl(String objectKey, int expireSeconds) { + if (cosClient == null) { + log.warn("[COS] 客户端未初始化,无法生成 presigned URL"); + return null; + } + try { + Date expiration = new Date(System.currentTimeMillis() + expireSeconds * 1000L); + URL url = cosClient.generatePresignedUrl(bucket, objectKey, expiration); + return url.toString(); + } catch (Exception e) { + log.error("[COS] 生成 presigned URL 失败: key={}, error={}", objectKey, e.getMessage()); + return null; + } + } + + /** + * 生成预签名下载 URL(默认 1 小时有效) + */ + public String generatePresignedUrl(String objectKey) { + return generatePresignedUrl(objectKey, 3600); + } + + public boolean isAvailable() { + return cosClient != null; + } +} diff --git a/src/main/java/com/genersoft/iot/vmp/conf/GlobalResponseAdvice.java b/src/main/java/com/genersoft/iot/vmp/conf/GlobalResponseAdvice.java index 2db3bb528..f1d1f7a31 100644 --- a/src/main/java/com/genersoft/iot/vmp/conf/GlobalResponseAdvice.java +++ b/src/main/java/com/genersoft/iot/vmp/conf/GlobalResponseAdvice.java @@ -42,6 +42,11 @@ public class GlobalResponseAdvice implements ResponseBodyAdvice { return body; } + // 排除二进制响应(如图片代理返回的 byte[]) + if (body instanceof byte[]) { + return body; + } + if (body instanceof WVPResult) { return body; } @@ -57,7 +62,8 @@ public class GlobalResponseAdvice implements ResponseBodyAdvice { if (body instanceof LinkedHashMap) { LinkedHashMap bodyMap = (LinkedHashMap) body; - if (bodyMap.get("status") != null && (Integer)bodyMap.get("status") != 200) { + Object statusVal = bodyMap.get("status"); + if (statusVal instanceof Integer && (Integer) statusVal != 200) { return body; } } diff --git a/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java b/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java index e1c5ccc3c..5de05670e 100644 --- a/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java +++ b/src/main/java/com/genersoft/iot/vmp/conf/security/WebSecurityConfig.java @@ -102,7 +102,12 @@ public class WebSecurityConfig { defaultExcludes.add("/api/jt1078/playback/download"); defaultExcludes.add("/api/jt1078/snap"); defaultExcludes.add("/api/ai/roi/snap"); + defaultExcludes.add("/api/ai/roi/snap/callback"); + defaultExcludes.add("/api/ai/roi/snap/image"); defaultExcludes.add("/api/ai/camera/get"); + defaultExcludes.add("/api/ai/alert/edge/**"); + defaultExcludes.add("/api/ai/alert/image"); + defaultExcludes.add("/api/ai/device/edge/**"); if (userSetting.getInterfaceAuthentication() && !userSetting.getInterfaceAuthenticationExcludes().isEmpty()) { defaultExcludes.addAll(userSetting.getInterfaceAuthenticationExcludes()); diff --git a/src/main/java/com/genersoft/iot/vmp/streamProxy/dao/StreamProxyMapper.java b/src/main/java/com/genersoft/iot/vmp/streamProxy/dao/StreamProxyMapper.java index 648319cb0..98167b754 100755 --- a/src/main/java/com/genersoft/iot/vmp/streamProxy/dao/StreamProxyMapper.java +++ b/src/main/java/com/genersoft/iot/vmp/streamProxy/dao/StreamProxyMapper.java @@ -100,4 +100,16 @@ public interface StreamProxyMapper { */ @Select("SELECT * FROM wvp_stream_proxy WHERE camera_code = #{cameraCode}") StreamProxy selectByCameraCode(@Param("cameraCode") String cameraCode); + + /** + * 查询 camera_code 为 NULL 或空的记录(需要回填) + */ + @Select("SELECT * FROM wvp_stream_proxy WHERE camera_code IS NULL OR camera_code = ''") + List selectWithNullCameraCode(); + + /** + * 更新指定记录的 camera_code + */ + @Update("UPDATE wvp_stream_proxy SET camera_code = #{cameraCode} WHERE id = #{id}") + int updateCameraCode(@Param("id") int id, @Param("cameraCode") String cameraCode); } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index f8dc569ed..671512501 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -121,6 +121,14 @@ ai: push-timeout: 10000 # 暂未对接时设为false enabled: true + screenshot: + # Edge截图回调地址(WVP外部可访问地址,Edge通过此地址回调截图结果) + callback-url: http://124.221.55.225:18080 + cos: + secret-id: + secret-key: + region: ap-beijing + bucket: mqtt: # MQTT推送开关 enabled: false diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 5c565dbb6..fb16a68b0 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -84,6 +84,13 @@ ai: url: ${AI_SERVICE_URL:http://localhost:8090} push-timeout: ${AI_PUSH_TIMEOUT:10000} enabled: ${AI_SERVICE_ENABLED:false} + screenshot: + callback-url: ${AI_SCREENSHOT_CALLBACK_URL:} + cos: + secret-id: ${COS_SECRET_ID:} + secret-key: ${COS_SECRET_KEY:} + region: ${COS_REGION:ap-beijing} + bucket: ${COS_BUCKET:} mqtt: enabled: ${AI_MQTT_ENABLED:false} broker: ${AI_MQTT_BROKER:tcp://127.0.0.1:1883} diff --git a/tools/maven.zip b/tools/maven.zip new file mode 100644 index 000000000..fe6f3cb67 --- /dev/null +++ b/tools/maven.zip @@ -0,0 +1,72 @@ + + + + About us + + + + +
+ + + +
+
+

About Us

+

+ 阿里云镜像由阿里巴巴技术保障部基础系统组提供支持。 +

+

+ 覆盖了Debian、Ubuntu、 Fedora、Arch Linux、 CentOS、openSUSE、Scientific Linux、Gentoo 等多个发行版的软件源镜像。 +

+

+ 搭建此开源镜像的目的在于宣传自由软件的价值,提高自由软件社区文化氛围, 推广自由软件在国内应用。 +

+
+
+

镜像设置

+

+ 如果您不了解如何配置 Linux 发行版 / 软件的安装源, 您可以通过首页的文件列表中相应源的 Help 链接寻求帮助 +

+ +

友情提示

+

+ 同步频率为每天一次,每天凌晨2:00-4:00为镜像的同步时间 +

+

+ 若使用阿里云服务器,将源的域名从mirrors.aliyun.com改为mirrors.cloud.aliyuncs.com,不占用公网流量。 +

+

+ 如果需要下载ISO镜像,请直接使用Chrome、Firefox浏览器下载,勿使用P2P下载工具。 +

+

常用链接

+ +

+

+
+
+
+

联系我们

+

ali-yum@alibaba-inc.com

+
+
+ +
+
+ + diff --git a/web/src/api/aiAlert.js b/web/src/api/aiAlert.js new file mode 100644 index 000000000..64e1f8a98 --- /dev/null +++ b/web/src/api/aiAlert.js @@ -0,0 +1,51 @@ +import request from '@/utils/request' + +export function queryAlertList(params) { + const { page, count, cameraId, alertType, startTime, endTime } = params + return request({ + method: 'get', + url: '/api/ai/alert/list', + params: { page, count, cameraId, alertType, startTime, endTime } + }) +} + +export function queryAlertDetail(alertId) { + return request({ + method: 'get', + url: `/api/ai/alert/${alertId}` + }) +} + +export function deleteAlert(alertId) { + return request({ + method: 'delete', + url: '/api/ai/alert/delete', + params: { alertId } + }) +} + +export function deleteAlertBatch(alertIds) { + return request({ + method: 'delete', + url: '/api/ai/alert/delete', + params: { alertIds } + }) +} + +export function queryAlertStatistics(startTime) { + return request({ + method: 'get', + url: '/api/ai/alert/statistics', + params: { startTime } + }) +} + +/** + * 构造告警图片代理 URL + * @param {string} imagePath COS 对象路径 + * @returns {string} 图片代理 URL + */ +export function getAlertImageUrl(imagePath) { + if (!imagePath) return '' + return '/api/ai/alert/image?imagePath=' + encodeURIComponent(imagePath) +} diff --git a/web/src/api/aiRoi.js b/web/src/api/aiRoi.js index dcc39a54d..a3bcbc032 100644 --- a/web/src/api/aiRoi.js +++ b/web/src/api/aiRoi.js @@ -62,3 +62,21 @@ export function updateAlgoParams(data) { data: data }) } + +export function getSnapUrl(cameraCode, force = false) { + if (force) { + // force 模式:先触发 Edge 截图(更新 DB),再返回代理 URL + return request({ + method: 'get', + url: '/api/ai/roi/snap', + params: { cameraCode, force: true } + }).then(() => { + return '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) + '&t=' + Date.now() + }).catch(() => { + // 截图请求失败也返回代理 URL(可能 DB 有旧数据) + return '/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode) + '&t=' + Date.now() + }) + } + // 非 force:直接返回代理 URL(从 DB 读取持久化截图,不触发 Edge) + return Promise.resolve('/api/ai/roi/snap/image?cameraCode=' + encodeURIComponent(cameraCode)) +} diff --git a/web/src/router/index.js b/web/src/router/index.js index 6a8eee5f3..4d50442fe 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -233,6 +233,17 @@ export const constantRoutes = [ } ] }, + { + path: '/alertList', + component: Layout, + redirect: '/alertList', + children: [{ + path: '', + name: 'AlertList', + component: () => import('@/views/alertList/index'), + meta: { title: '告警记录', icon: 'alarm' } + }] + }, { path: '/cameraConfig', component: Layout, diff --git a/web/src/views/alertList/index.vue b/web/src/views/alertList/index.vue new file mode 100644 index 000000000..b06a8cf38 --- /dev/null +++ b/web/src/views/alertList/index.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/web/src/views/cameraConfig/roiConfig.vue b/web/src/views/cameraConfig/roiConfig.vue index c4b978345..a3dfc0711 100644 --- a/web/src/views/cameraConfig/roiConfig.vue +++ b/web/src/views/cameraConfig/roiConfig.vue @@ -8,7 +8,7 @@
画矩形 画多边形 - 刷新截图 + 刷新截图 推送到边缘端
@@ -87,7 +87,7 @@