Merge branch 'feature'
This commit is contained in:
45
AGENTS.md
Normal file
45
AGENTS.md
Normal file
@@ -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-<profile>.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.
|
||||
308
QUICKSTART.md
Normal file
308
QUICKSTART.md
Normal file
@@ -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 <PID>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 关键配置项 (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
|
||||
7
pom.xml
7
pom.xml
@@ -422,6 +422,13 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- 腾讯云 COS SDK(截图/告警图片 presign) -->
|
||||
<dependency>
|
||||
<groupId>com.qcloud</groupId>
|
||||
<artifactId>cos_api</artifactId>
|
||||
<version>5.6.227</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
36
scripts/edge_local_sync.py
Normal file
36
scripts/edge_local_sync.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("usage: edge_local_sync.py <edge_url> <payload_json_path>")
|
||||
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())
|
||||
6
sql/wvp_ai_camera_snapshot.sql
Normal file
6
sql/wvp_ai_camera_snapshot.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- 截图持久化表:保存摄像头最新截图的 COS object key
|
||||
CREATE TABLE IF NOT EXISTS wvp_ai_camera_snapshot (
|
||||
camera_code VARCHAR(64) PRIMARY KEY COMMENT '摄像头编码',
|
||||
cos_key VARCHAR(512) NOT NULL COMMENT 'COS 对象键(永久有效)',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI摄像头截图持久化';
|
||||
@@ -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<byte[]> 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<String, Object> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<PageInfo<AiEdgeDevice>> queryPage(
|
||||
@RequestParam(defaultValue = "1") int pageNo,
|
||||
@RequestParam(defaultValue = "20") int pageSize,
|
||||
@RequestParam(required = false) String status) {
|
||||
PageInfo<AiEdgeDevice> pageInfo = edgeDeviceService.queryPage(pageNo, pageSize, status);
|
||||
return WVPResult.success(pageInfo);
|
||||
}
|
||||
|
||||
@Operation(summary = "按设备ID查询")
|
||||
@GetMapping("/get")
|
||||
public WVPResult<AiEdgeDevice> queryByParam(@RequestParam String deviceId) {
|
||||
AiEdgeDevice device = edgeDeviceService.queryByDeviceId(deviceId);
|
||||
return WVPResult.success(device);
|
||||
}
|
||||
|
||||
@Operation(summary = "设备统计")
|
||||
@GetMapping("/statistics")
|
||||
public WVPResult<Map<String, Object>> statistics() {
|
||||
return WVPResult.success(edgeDeviceService.getStatistics());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Object> 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);
|
||||
// <img> 标签请求(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<String, Object> body) {
|
||||
String requestId = (String) body.get("request_id");
|
||||
screenshotService.handleCallback(requestId, body);
|
||||
}
|
||||
|
||||
@Operation(summary = "截图图片代理(服务端从 COS 下载后返回)")
|
||||
@GetMapping("/snap/image")
|
||||
public ResponseEntity<byte[]> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {"<script>" +
|
||||
"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 " +
|
||||
@@ -67,4 +70,7 @@ public interface AiAlertMapper {
|
||||
@Select("SELECT camera_id, COUNT(*) as cnt FROM wvp_ai_alert " +
|
||||
"WHERE received_at >= #{startTime} GROUP BY camera_id ORDER BY cnt DESC")
|
||||
List<java.util.Map<String, Object>> statisticsByCamera(@Param("startTime") String startTime);
|
||||
|
||||
@Update("UPDATE wvp_ai_alert SET duration_minutes=#{durationMinutes} WHERE alert_id=#{alertId}")
|
||||
int updateDuration(@Param("alertId") String alertId, @Param("durationMinutes") double durationMinutes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.genersoft.iot.vmp.aiot.dao;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
@Mapper
|
||||
public interface AiCameraSnapshotMapper {
|
||||
|
||||
@Select("SELECT cos_key FROM wvp_ai_camera_snapshot WHERE camera_code = #{cameraCode}")
|
||||
String getCosKey(@Param("cameraCode") String cameraCode);
|
||||
|
||||
@Insert("INSERT INTO wvp_ai_camera_snapshot (camera_code, cos_key) " +
|
||||
"VALUES (#{cameraCode}, #{cosKey}) " +
|
||||
"ON DUPLICATE KEY UPDATE cos_key = #{cosKey}, updated_at = NOW()")
|
||||
int upsert(@Param("cameraCode") String cameraCode, @Param("cosKey") String cosKey);
|
||||
}
|
||||
@@ -36,4 +36,21 @@ public interface AiEdgeDeviceMapper {
|
||||
|
||||
@Update("UPDATE wvp_ai_edge_device SET device_id=#{newDeviceId} WHERE id=#{id}")
|
||||
int renameDeviceId(@Param("id") Integer id, @Param("newDeviceId") String newDeviceId);
|
||||
|
||||
@Update("UPDATE wvp_ai_edge_device SET device_id=#{newDeviceId} WHERE device_id=#{oldDeviceId}")
|
||||
int renameByDeviceId(@Param("oldDeviceId") String oldDeviceId, @Param("newDeviceId") String newDeviceId);
|
||||
|
||||
@Select({"<script>" +
|
||||
"SELECT * FROM wvp_ai_edge_device " +
|
||||
"WHERE 1=1 " +
|
||||
"<if test='status != null'> AND status=#{status}</if> " +
|
||||
"ORDER BY updated_at DESC" +
|
||||
"</script>"})
|
||||
List<AiEdgeDevice> 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();
|
||||
}
|
||||
|
||||
@@ -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<AiRoi> queryWithLegacyCameraId();
|
||||
}
|
||||
|
||||
@@ -19,4 +19,14 @@ public interface IAiAlertService {
|
||||
void deleteBatch(List<String> alertIds);
|
||||
|
||||
Map<String, Object> statistics(String startTime);
|
||||
|
||||
void updateDuration(String alertId, double durationMinutes);
|
||||
|
||||
/**
|
||||
* 代理获取告警图片(通过 COS presigned URL 下载后返回字节)
|
||||
*
|
||||
* @param imagePath COS 对象路径
|
||||
* @return JPEG 图片字节,失败返回 null
|
||||
*/
|
||||
byte[] proxyAlertImage(String imagePath);
|
||||
}
|
||||
|
||||
@@ -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<AiEdgeDevice> queryAll();
|
||||
|
||||
void checkOffline();
|
||||
|
||||
PageInfo<AiEdgeDevice> queryPage(int page, int count, String status);
|
||||
|
||||
Map<String, Object> getStatistics();
|
||||
}
|
||||
|
||||
@@ -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<String, Object> requestScreenshot(String cameraCode, boolean force);
|
||||
|
||||
/**
|
||||
* 处理 Edge 截图回调
|
||||
*
|
||||
* @param requestId 请求ID
|
||||
* @param data 回调数据 {request_id, camera_code, status, url/message}
|
||||
*/
|
||||
void handleCallback(String requestId, Map<String, Object> data);
|
||||
|
||||
/**
|
||||
* 代理获取截图图片(服务端从 COS 下载后返回字节)
|
||||
*
|
||||
* @param cameraCode 摄像头编码
|
||||
* @return JPEG 图片字节,缓存不存在或下载失败返回 null
|
||||
*/
|
||||
byte[] proxyScreenshotImage(String cameraCode);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<StreamProxy> 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<AiRoi> 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<String, Object> exportConfig(String cameraId) {
|
||||
Map<String, Object> 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<String, Object> 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<String> 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<String, Object> 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<AiEdgeDevice> 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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AiEdgeDevice> queryPage(int page, int count, String status) {
|
||||
PageHelper.startPage(page, count);
|
||||
List<AiEdgeDevice> list = deviceMapper.queryPage(status);
|
||||
return new PageInfo<>(list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getStatistics() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.put("total", deviceMapper.countAll());
|
||||
stats.put("online", deviceMapper.countByStatus("online"));
|
||||
stats.put("offline", deviceMapper.countByStatus("offline"));
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,15 +492,19 @@ public class AiRedisConfigServiceImpl implements IAiRedisConfigService {
|
||||
// 摄像头信息
|
||||
Map<String, Object> 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<AiRoi> 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 和绑定
|
||||
|
||||
@@ -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<String, CompletableFuture<Map<String, Object>>> pendingRequests = new ConcurrentHashMap<>();
|
||||
|
||||
/** requestId → cosPath 映射,截图回调成功后持久化到 DB */
|
||||
private final ConcurrentHashMap<String, String> 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<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());
|
||||
}
|
||||
}
|
||||
|
||||
// 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<Map<String, Object>> future = new CompletableFuture<>();
|
||||
pendingRequests.put(requestId, future);
|
||||
|
||||
// 4. XADD 到 Stream(含 callback_url)
|
||||
Map<String, String> 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<String, String, String> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
96
src/main/java/com/genersoft/iot/vmp/aiot/util/CosUtil.java
Normal file
96
src/main/java/com/genersoft/iot/vmp/aiot/util/CosUtil.java
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ public class GlobalResponseAdvice implements ResponseBodyAdvice<Object> {
|
||||
return body;
|
||||
}
|
||||
|
||||
// 排除二进制响应(如图片代理返回的 byte[])
|
||||
if (body instanceof byte[]) {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (body instanceof WVPResult) {
|
||||
return body;
|
||||
}
|
||||
@@ -57,7 +62,8 @@ public class GlobalResponseAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
if (body instanceof LinkedHashMap) {
|
||||
LinkedHashMap<String, Object> bodyMap = (LinkedHashMap<String, Object>) 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<StreamProxy> 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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
72
tools/maven.zip
Normal file
72
tools/maven.zip
Normal file
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>About us</title>
|
||||
<link rel='stylesheet' href='/stylesheets/style.css' />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrapper">
|
||||
<div id="header">
|
||||
<h3>Ali-OSM</h3>
|
||||
<a href="/" title="Alibaba open source mirror site"> Alibaba Open Source Mirror Site</a>
|
||||
</div>
|
||||
|
||||
<div id="navbar">
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/about">About</a></li>
|
||||
<li><a href="http://job.alibaba.com/zhaopin/index">Join Us</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="main">
|
||||
<div id="content">
|
||||
<h1 class="about-us">About Us</h1>
|
||||
<p class="about-us-p">
|
||||
阿里云镜像由阿里巴巴技术保障部基础系统组提供支持。
|
||||
</p>
|
||||
<p class="about-us-p">
|
||||
覆盖了Debian、Ubuntu、 Fedora、Arch Linux、 CentOS、openSUSE、Scientific Linux、Gentoo 等多个发行版的软件源镜像。
|
||||
</p>
|
||||
<p class="about-us-p">
|
||||
搭建此开源镜像的目的在于宣传自由软件的价值,提高自由软件社区文化氛围, 推广自由软件在国内应用。
|
||||
</p>
|
||||
</div>
|
||||
<div id="siderbar">
|
||||
<h1 class="mirror-help">镜像设置</h1>
|
||||
<p>
|
||||
如果您不了解如何配置 Linux 发行版 / 软件的安装源, 您可以通过<a href="/">首页</a>的文件列表中相应源的 Help 链接寻求帮助
|
||||
</p>
|
||||
|
||||
<h1 class="mirror-help-tips">友情提示</h1>
|
||||
<p>
|
||||
同步频率为每天一次,每天凌晨2:00-4:00为镜像的同步时间
|
||||
</p>
|
||||
<p>
|
||||
若使用阿里云服务器,将源的域名从mirrors.aliyun.com改为mirrors.cloud.aliyuncs.com,不占用公网流量。
|
||||
</p>
|
||||
<p>
|
||||
如果需要下载ISO镜像,请直接使用Chrome、Firefox浏览器下载,勿使用P2P下载工具。
|
||||
</p>
|
||||
<h1 class="mirror-link">常用链接</h1>
|
||||
<ul>
|
||||
<li><a href="/about">About us</a></li>
|
||||
<li><a href=http://buy.aliyun.com>购买云服务器</a></li>
|
||||
</ul>
|
||||
<p style="margin-left:15px;">
|
||||
</p>
|
||||
</br>
|
||||
</br>
|
||||
<div class="laiwang">
|
||||
<h1 class="mirror-link" style="margin-top:30px;">联系我们</h1>
|
||||
<p>ali-yum@alibaba-inc.com</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="clear"></div>
|
||||
<div id="footer">
|
||||
<div class="about">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
51
web/src/api/aiAlert.js
Normal file
51
web/src/api/aiAlert.js
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
216
web/src/views/alertList/index.vue
Normal file
216
web/src/views/alertList/index.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div class="alert-list-page">
|
||||
<div class="page-header">
|
||||
<h3>告警记录</h3>
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="filter.alertType" placeholder="告警类型" clearable size="small" style="width: 140px">
|
||||
<el-option label="离岗检测" value="leave_post"></el-option>
|
||||
<el-option label="入侵检测" value="intrusion"></el-option>
|
||||
<el-option label="人群聚集" value="crowd_detection"></el-option>
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="filter.dateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
size="small"
|
||||
value-format="yyyy-MM-dd HH:mm:ss"
|
||||
style="width: 360px"
|
||||
></el-date-picker>
|
||||
<el-button size="small" type="primary" icon="el-icon-search" @click="fetchList">查询</el-button>
|
||||
<el-button size="small" icon="el-icon-refresh" @click="resetFilter">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" stripe border style="width: 100%" @sort-change="handleSortChange">
|
||||
<el-table-column label="告警图片" width="120" align="center">
|
||||
<template slot-scope="{ row }">
|
||||
<el-image
|
||||
v-if="row.imagePath"
|
||||
:src="getImageUrl(row.imagePath)"
|
||||
:preview-src-list="[getImageUrl(row.imagePath)]"
|
||||
style="width: 80px; height: 60px"
|
||||
fit="cover"
|
||||
>
|
||||
<div slot="error" class="image-error">
|
||||
<i class="el-icon-picture-outline"></i>
|
||||
</div>
|
||||
</el-image>
|
||||
<span v-else class="no-image">无图片</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="alertId" label="告警ID" width="140" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="alertType" label="告警类型" width="120">
|
||||
<template slot-scope="{ row }">
|
||||
<el-tag :type="getAlertTagType(row.alertType)" size="mini">{{ getAlertTypeName(row.alertType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cameraId" label="摄像头" width="160" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="roiId" label="ROI区域" width="120" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="confidence" label="置信度" width="90" align="center">
|
||||
<template slot-scope="{ row }">
|
||||
<span v-if="row.confidence">{{ (row.confidence * 100).toFixed(1) }}%</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="durationMinutes" label="持续时长" width="100" align="center">
|
||||
<template slot-scope="{ row }">
|
||||
<span v-if="row.durationMinutes">{{ row.durationMinutes.toFixed(1) }}分</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="receivedAt" label="告警时间" width="170" sortable="custom"></el-table-column>
|
||||
<el-table-column label="操作" width="100" align="center" fixed="right">
|
||||
<template slot-scope="{ row }">
|
||||
<el-button size="mini" type="text" icon="el-icon-view" @click="viewDetail(row)">详情</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-delete" style="color: #F56C6C" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
style="margin-top: 15px; text-align: right"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
:current-page="pagination.page"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:page-size="pagination.count"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
></el-pagination>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog title="告警详情" :visible.sync="detailVisible" width="600px">
|
||||
<div v-if="currentAlert" class="alert-detail">
|
||||
<el-image
|
||||
v-if="currentAlert.imagePath"
|
||||
:src="getImageUrl(currentAlert.imagePath)"
|
||||
style="width: 100%; max-height: 400px; margin-bottom: 15px"
|
||||
fit="contain"
|
||||
></el-image>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="告警ID">{{ currentAlert.alertId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="告警类型">{{ getAlertTypeName(currentAlert.alertType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="摄像头">{{ currentAlert.cameraId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="ROI区域">{{ currentAlert.roiId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="置信度">{{ currentAlert.confidence ? (currentAlert.confidence * 100).toFixed(1) + '%' : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="持续时长">{{ currentAlert.durationMinutes ? currentAlert.durationMinutes.toFixed(1) + '分' : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="告警时间" :span="2">{{ currentAlert.receivedAt }}</el-descriptions-item>
|
||||
<el-descriptions-item label="告警消息" :span="2">{{ currentAlert.message || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { queryAlertList, deleteAlert, getAlertImageUrl } from '@/api/aiAlert'
|
||||
|
||||
const ALERT_TYPE_MAP = {
|
||||
leave_post: '离岗检测',
|
||||
intrusion: '入侵检测',
|
||||
crowd_detection: '人群聚集'
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'AlertList',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
tableData: [],
|
||||
filter: {
|
||||
alertType: '',
|
||||
dateRange: null
|
||||
},
|
||||
pagination: {
|
||||
page: 1,
|
||||
count: 20,
|
||||
total: 0
|
||||
},
|
||||
detailVisible: false,
|
||||
currentAlert: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchList()
|
||||
},
|
||||
methods: {
|
||||
fetchList() {
|
||||
this.loading = true
|
||||
const params = {
|
||||
page: this.pagination.page,
|
||||
count: this.pagination.count,
|
||||
alertType: this.filter.alertType || undefined,
|
||||
startTime: this.filter.dateRange ? this.filter.dateRange[0] : undefined,
|
||||
endTime: this.filter.dateRange ? this.filter.dateRange[1] : undefined
|
||||
}
|
||||
queryAlertList(params).then(res => {
|
||||
const data = res.data || res
|
||||
this.tableData = data.list || []
|
||||
this.pagination.total = data.total || 0
|
||||
}).catch(() => {
|
||||
this.$message.error('查询告警列表失败')
|
||||
}).finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
resetFilter() {
|
||||
this.filter.alertType = ''
|
||||
this.filter.dateRange = null
|
||||
this.pagination.page = 1
|
||||
this.fetchList()
|
||||
},
|
||||
handlePageChange(page) {
|
||||
this.pagination.page = page
|
||||
this.fetchList()
|
||||
},
|
||||
handleSizeChange(size) {
|
||||
this.pagination.count = size
|
||||
this.pagination.page = 1
|
||||
this.fetchList()
|
||||
},
|
||||
handleSortChange() {
|
||||
this.fetchList()
|
||||
},
|
||||
getImageUrl(imagePath) {
|
||||
return getAlertImageUrl(imagePath)
|
||||
},
|
||||
getAlertTypeName(type) {
|
||||
return ALERT_TYPE_MAP[type] || type || '未知'
|
||||
},
|
||||
getAlertTagType(type) {
|
||||
switch (type) {
|
||||
case 'leave_post': return 'warning'
|
||||
case 'intrusion': return 'danger'
|
||||
case 'crowd_detection': return ''
|
||||
default: return 'info'
|
||||
}
|
||||
},
|
||||
viewDetail(row) {
|
||||
this.currentAlert = row
|
||||
this.detailVisible = true
|
||||
},
|
||||
handleDelete(row) {
|
||||
this.$confirm('确定删除该告警记录?', '提示', { type: 'warning' }).then(() => {
|
||||
deleteAlert(row.alertId).then(() => {
|
||||
this.$message.success('已删除')
|
||||
this.fetchList()
|
||||
}).catch(() => {
|
||||
this.$message.error('删除失败')
|
||||
})
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alert-list-page { padding: 15px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
|
||||
.page-header h3 { margin: 0; }
|
||||
.filter-bar { display: flex; gap: 10px; align-items: center; }
|
||||
.no-image { color: #999; font-size: 12px; }
|
||||
.image-error { display: flex; align-items: center; justify-content: center; width: 80px; height: 60px; background: #f5f7fa; color: #c0c4cc; font-size: 20px; }
|
||||
.alert-detail { padding: 10px 0; }
|
||||
</style>
|
||||
@@ -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,19 @@ 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(url => {
|
||||
this.snapUrl = url
|
||||
}).catch(() => {
|
||||
this.$message.error('截图请求失败,请检查网络')
|
||||
}).finally(() => {
|
||||
this.snapLoading = false
|
||||
})
|
||||
},
|
||||
refreshSnap() {
|
||||
this.buildSnapUrl()
|
||||
this.fetchSnap(true)
|
||||
},
|
||||
onRoiDrawn(data) {
|
||||
this.drawMode = null
|
||||
|
||||
@@ -43,7 +43,8 @@ export default {
|
||||
currentPoint: null,
|
||||
polygonPoints: [],
|
||||
loading: true,
|
||||
errorMsg: ''
|
||||
errorMsg: '',
|
||||
resizeObserver: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -57,16 +58,30 @@ export default {
|
||||
snapUrl() {
|
||||
this.loading = true
|
||||
this.errorMsg = ''
|
||||
this.$nextTick(() => this.initCanvas())
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.initCanvas()
|
||||
// ResizeObserver 确保容器尺寸变化时重新初始化 canvas
|
||||
if (this.$refs.wrapper && typeof ResizeObserver !== 'undefined') {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
if (this.$refs.wrapper && this.$refs.wrapper.clientWidth > 0) {
|
||||
this.initCanvas()
|
||||
}
|
||||
})
|
||||
this.resizeObserver.observe(this.$refs.wrapper)
|
||||
}
|
||||
window.addEventListener('resize', this.handleResize)
|
||||
})
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.handleResize)
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect()
|
||||
this.resizeObserver = null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onImageLoad() {
|
||||
@@ -78,6 +93,8 @@ export default {
|
||||
onImageError() {
|
||||
this.loading = false
|
||||
this.errorMsg = '截图加载失败,请确认摄像头正在拉流'
|
||||
// 关键:截图失败也初始化 canvas,使 ROI 区域可见可操作
|
||||
this.$nextTick(() => this.initCanvas())
|
||||
},
|
||||
initCanvas() {
|
||||
const canvas = this.$refs.canvas
|
||||
|
||||
275
启动指南-Java17.md
Normal file
275
启动指南-Java17.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# WVP-PRO 启动指南(使用 Java 17)
|
||||
|
||||
## 当前环境
|
||||
- ✅ Java 17 已安装
|
||||
- ✅ Node.js 已安装
|
||||
- ✅ Docker 已安装
|
||||
- ✅ 基础服务已启动(Redis、MySQL、ZLMediaKit)
|
||||
- ✅ pom.xml 已修改为 Java 17
|
||||
|
||||
## 方案一:使用 PowerShell 编译(推荐)
|
||||
|
||||
### 1. 打开 PowerShell(管理员权限)
|
||||
|
||||
```powershell
|
||||
cd C:\workspace\wvp-platform
|
||||
```
|
||||
|
||||
### 2. 使用 Docker 编译项目
|
||||
|
||||
```powershell
|
||||
docker run --rm -v "${PWD}:/app" -w /app eclipse-temurin:17-jdk cmd /c "apt-get update && apt-get install -y maven && mvn clean package -Dmaven.test.skip=true"
|
||||
```
|
||||
|
||||
### 3. 检查编译结果
|
||||
|
||||
```powershell
|
||||
ls target\wvp-pro*.jar
|
||||
```
|
||||
|
||||
### 4. 配置连接参数
|
||||
|
||||
编辑 `src\main\resources\application-dev.yml`,确保以下配置正确:
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
data:
|
||||
redis:
|
||||
host: 127.0.0.1
|
||||
port: 6379
|
||||
datasource:
|
||||
url: jdbc:mysql://127.0.0.1:3306/wvp?useUnicode=true&characterEncoding=UTF8&rewriteBatchedStatements=true&serverTimezone=PRC&useSSL=false&allowMultiQueries=true&allowPublicKeyRetrieval=true
|
||||
username: wvp_user
|
||||
password: wvp_password
|
||||
|
||||
media:
|
||||
ip: 127.0.0.1
|
||||
http-port: 8080
|
||||
```
|
||||
|
||||
### 5. 启动应用
|
||||
|
||||
```powershell
|
||||
java -jar target\wvp-pro-2.7.4-*.jar --spring.profiles.active=dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方案二:安装 Maven 后编译
|
||||
|
||||
### 1. 下载 Maven
|
||||
|
||||
访问:https://maven.apache.org/download.cgi
|
||||
下载:apache-maven-3.9.6-bin.zip
|
||||
|
||||
### 2. 解压并配置环境变量
|
||||
|
||||
```powershell
|
||||
# 解压到 C:\Program Files\apache-maven-3.9.6
|
||||
|
||||
# 添加到系统PATH
|
||||
setx /M PATH "%PATH%;C:\Program Files\apache-maven-3.9.6\bin"
|
||||
|
||||
# 重启终端验证
|
||||
mvn -version
|
||||
```
|
||||
|
||||
### 3. 编译项目
|
||||
|
||||
```powershell
|
||||
cd C:\workspace\wvp-platform
|
||||
|
||||
# 编译前端
|
||||
cd web
|
||||
npm install --registry=https://registry.npmmirror.com
|
||||
npm run build:prod
|
||||
cd ..
|
||||
|
||||
# 编译后端
|
||||
mvn clean package -Dmaven.test.skip=true
|
||||
```
|
||||
|
||||
### 4. 启动应用
|
||||
|
||||
```powershell
|
||||
java -jar target\wvp-pro-2.7.4-*.jar --spring.profiles.active=dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方案三:使用已编译的版本(最快)
|
||||
|
||||
如果您之前编译过项目,可以直接运行:
|
||||
|
||||
```powershell
|
||||
# 找到已编译的 jar 文件
|
||||
ls target\*.jar
|
||||
|
||||
# 直接运行
|
||||
java -jar target\wvp-pro-2.7.4-<timestamp>.jar --spring.profiles.active=dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证服务
|
||||
|
||||
### 1. 检查基础服务状态
|
||||
|
||||
```powershell
|
||||
docker ps --filter "name=polaris-"
|
||||
```
|
||||
|
||||
应该看到:
|
||||
- docker-polaris-redis-1 (健康运行)
|
||||
- docker-polaris-mysql-1 (健康运行)
|
||||
- docker-polaris-media-1 (运行中)
|
||||
|
||||
### 2. 测试数据库连接
|
||||
|
||||
```powershell
|
||||
docker exec -it docker-polaris-mysql-1 mysql -u wvp_user -pwvp_password -e "show databases;"
|
||||
```
|
||||
|
||||
应该看到 `wvp` 数据库。
|
||||
|
||||
### 3. 访问应用
|
||||
|
||||
启动后访问:
|
||||
- **管理后台**: http://localhost:18080
|
||||
- **API 文档**: http://localhost:18080/doc.html
|
||||
- **默认账号**: admin / admin
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 端口冲突
|
||||
|
||||
如果 18080 端口被占用:
|
||||
|
||||
```yaml
|
||||
# 修改 src/main/resources/application-dev.yml
|
||||
server:
|
||||
port: 28080 # 改为其他端口
|
||||
```
|
||||
|
||||
### 2. 数据库连接失败
|
||||
|
||||
检查 MySQL 是否健康:
|
||||
|
||||
```powershell
|
||||
docker logs docker-polaris-mysql-1
|
||||
```
|
||||
|
||||
### 3. Redis 连接失败
|
||||
|
||||
检查 Redis 状态:
|
||||
|
||||
```powershell
|
||||
docker exec -it docker-polaris-redis-1 redis-cli ping
|
||||
```
|
||||
|
||||
应该返回 `PONG`
|
||||
|
||||
### 4. ZLMediaKit 连接失败
|
||||
|
||||
检查 ZLMediaKit 日志:
|
||||
|
||||
```powershell
|
||||
docker logs docker-polaris-media-1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 功能模块位置
|
||||
|
||||
### 主码流/子码流切换
|
||||
- **控制器**: `src/main/java/com/genersoft/iot/vmp/gb28181/controller/DeviceQuery.java`
|
||||
- **服务**: `src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/PlayServiceImpl.java`
|
||||
- **API**: `/api/play/{deviceId}/{channelId}/start` (带 streamType 参数)
|
||||
|
||||
### 云台控制(转向、拉近、拉远)
|
||||
- **控制器**: `src/main/java/com/genersoft/iot/vmp/gb28181/controller/PtzController.java`
|
||||
- **服务**: `src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/PTZServiceImpl.java`
|
||||
- **命令**: `src/main/java/com/genersoft/iot/vmp/gb28181/transmit/cmd/impl/SIPCommander.java`
|
||||
- **API**:
|
||||
- `/api/front-end/{deviceId}/{channelId}/ptz` - PTZ控制
|
||||
- `/api/front-end/{deviceId}/{channelId}/left` - 左转
|
||||
- `/api/front-end/{deviceId}/{channelId}/right` - 右转
|
||||
- `/api/front-end/{deviceId}/{channelId}/up` - 上转
|
||||
- `/api/front-end/{deviceId}/{channelId}/down` - 下转
|
||||
- `/api/front-end/{deviceId}/{channelId}/zoom_in` - 拉近
|
||||
- `/api/front-end/{deviceId}/{channelId}/zoom_out` - 拉远
|
||||
|
||||
### 设备接入管理
|
||||
- **设备服务**: `src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/DeviceServiceImpl.java`
|
||||
- **注册处理**: `src/main/java/com/genersoft/iot/vmp/gb28181/transmit/event/request/impl/RegisterRequestProcessor.java`
|
||||
- **心跳处理**: `src/main/java/com/genersoft/iot/vmp/gb28181/transmit/event/request/impl/MessageRequestProcessor.java`
|
||||
- **SIP层**: `src/main/java/com/genersoft/iot/vmp/gb28181/SipLayer.java`
|
||||
|
||||
---
|
||||
|
||||
## 停止服务
|
||||
|
||||
### 停止 WVP 应用
|
||||
在运行窗口按 `Ctrl + C`
|
||||
|
||||
### 停止 Docker 服务
|
||||
```powershell
|
||||
cd docker
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### 只停止 WVP,保留基础服务
|
||||
```powershell
|
||||
cd docker
|
||||
docker compose stop polaris-wvp polaris-nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 日志查看
|
||||
|
||||
### WVP 应用日志
|
||||
应用启动后,日志会输出到控制台和 `logs/` 目录
|
||||
|
||||
### Docker 服务日志
|
||||
```powershell
|
||||
# 查看所有服务日志
|
||||
cd docker
|
||||
docker compose logs -f
|
||||
|
||||
# 查看特定服务日志
|
||||
docker compose logs -f polaris-mysql
|
||||
docker compose logs -f polaris-redis
|
||||
docker compose logs -f polaris-media
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 下一步
|
||||
|
||||
### 1. 测试设备接入
|
||||
|
||||
配置国标设备连接到 WVP:
|
||||
- SIP 服务器: `你的IP地址`
|
||||
- SIP 端口: `8160`
|
||||
- SIP 域: `3502000000`
|
||||
- 设备ID: 20位国标编码
|
||||
- 密码: `wvp_sip_password`
|
||||
|
||||
### 2. 查看设备列表
|
||||
|
||||
访问 http://localhost:18080 后,在"国标设备"菜单查看已接入设备
|
||||
|
||||
### 3. 测试云台控制
|
||||
|
||||
在设备通道列表中,点击"云台控制"按钮测试
|
||||
|
||||
### 4. 测试视频播放
|
||||
|
||||
在通道列表中点击"播放"按钮,查看视频流
|
||||
|
||||
---
|
||||
|
||||
最后更新: 2026-02-02
|
||||
324
开始使用.md
Normal file
324
开始使用.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# 快速开始(使用 Java 17)
|
||||
|
||||
## 📋 前置要求
|
||||
|
||||
✅ 已安装:
|
||||
- Java 17(已确认)
|
||||
- Node.js(已确认)
|
||||
- Docker(已确认)
|
||||
|
||||
⚠️ 需要安装:
|
||||
- **Maven 3.3+**
|
||||
|
||||
## 🔧 安装 Maven
|
||||
|
||||
### 方法一:手动安装(推荐)
|
||||
|
||||
1. **下载 Maven**
|
||||
- 访问:https://maven.apache.org/download.cgi
|
||||
- 下载:`apache-maven-3.9.9-bin.zip`
|
||||
- 国内镜像:https://mirrors.aliyun.com/apache/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.zip
|
||||
|
||||
2. **解压到指定目录**
|
||||
```
|
||||
解压到:C:\Program Files\apache-maven-3.9.9
|
||||
```
|
||||
|
||||
3. **配置环境变量**
|
||||
- 打开"系统属性" → "高级" → "环境变量"
|
||||
- 在"系统变量"中找到 `Path`
|
||||
- 点击"编辑" → "新建"
|
||||
- 添加:`C:\Program Files\apache-maven-3.9.9\bin`
|
||||
- 点击"确定"保存
|
||||
|
||||
4. **验证安装**
|
||||
|
||||
打开**新的** CMD 或 PowerShell:
|
||||
```cmd
|
||||
mvn -version
|
||||
```
|
||||
|
||||
应该显示:
|
||||
```
|
||||
Apache Maven 3.9.9
|
||||
Maven home: C:\Program Files\apache-maven-3.9.9
|
||||
Java version: 17.0.x
|
||||
```
|
||||
|
||||
### 方法二:使用 Chocolatey(如果已安装)
|
||||
|
||||
```powershell
|
||||
# 以管理员身份运行 PowerShell
|
||||
choco install maven
|
||||
```
|
||||
|
||||
### 方法三:使用 Scoop
|
||||
|
||||
```powershell
|
||||
scoop install maven
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 启动步骤
|
||||
|
||||
### 第一次使用
|
||||
|
||||
1. **确保基础服务运行**
|
||||
```cmd
|
||||
cd docker
|
||||
docker compose up -d polaris-redis polaris-mysql polaris-media
|
||||
cd ..
|
||||
```
|
||||
|
||||
2. **编译项目**
|
||||
|
||||
双击运行:`编译.bat`
|
||||
|
||||
或在 CMD 中:
|
||||
```cmd
|
||||
编译.bat
|
||||
```
|
||||
|
||||
脚本会自动:
|
||||
- ✅ 检查 Java 和 Maven
|
||||
- ✅ 编译前端(Vue)
|
||||
- ✅ 编译后端(Spring Boot)
|
||||
- ✅ 检查基础服务状态
|
||||
|
||||
3. **启动应用**
|
||||
|
||||
双击运行:`启动.bat`
|
||||
|
||||
或在 CMD 中:
|
||||
```cmd
|
||||
启动.bat
|
||||
```
|
||||
|
||||
4. **访问应用**
|
||||
- 管理后台:http://localhost:18080
|
||||
- API 文档:http://localhost:18080/doc.html
|
||||
- 默认账号:`admin` / `admin`
|
||||
|
||||
### 后续使用
|
||||
|
||||
修改代码后,只需:
|
||||
```cmd
|
||||
编译.bat
|
||||
启动.bat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📂 功能模块位置
|
||||
|
||||
### 1. 主码流/子码流切换
|
||||
```
|
||||
src/main/java/com/genersoft/iot/vmp/gb28181/
|
||||
├── service/impl/PlayServiceImpl.java # 播放服务(包含码流切换逻辑)
|
||||
├── controller/DeviceQuery.java # 设备查询控制器
|
||||
└── bean/DeviceChannel.java # 通道实体(streamType字段)
|
||||
```
|
||||
|
||||
**API 端点:**
|
||||
- 主码流:`GET /api/play/{deviceId}/{channelId}/start?streamType=0`
|
||||
- 子码流:`GET /api/play/{deviceId}/{channelId}/start?streamType=1`
|
||||
|
||||
### 2. 云台控制(转向、拉近、拉远)
|
||||
```
|
||||
src/main/java/com/genersoft/iot/vmp/gb28181/
|
||||
├── controller/PtzController.java # 云台控制主接口
|
||||
├── service/
|
||||
│ ├── IPTZService.java # PTZ 服务接口
|
||||
│ └── impl/PTZServiceImpl.java # PTZ 服务实现
|
||||
├── transmit/cmd/impl/SIPCommander.java # 发送 SIP 命令
|
||||
└── bean/
|
||||
├── FrontEndControlCodeForPTZ.java # PTZ 控制码
|
||||
└── FrontEndCode.java # 前端控制码
|
||||
```
|
||||
|
||||
**API 端点:**
|
||||
- 左转:`GET /api/front-end/{deviceId}/{channelId}/left`
|
||||
- 右转:`GET /api/front-end/{deviceId}/{channelId}/right`
|
||||
- 上转:`GET /api/front-end/{deviceId}/{channelId}/up`
|
||||
- 下转:`GET /api/front-end/{deviceId}/{channelId}/down`
|
||||
- 拉近:`GET /api/front-end/{deviceId}/{channelId}/zoom_in`
|
||||
- 拉远:`GET /api/front-end/{deviceId}/{channelId}/zoom_out`
|
||||
|
||||
### 3. 无限制设备接入
|
||||
```
|
||||
src/main/java/com/genersoft/iot/vmp/gb28181/
|
||||
├── service/impl/DeviceServiceImpl.java # 设备管理服务
|
||||
├── transmit/event/request/impl/
|
||||
│ ├── RegisterRequestProcessor.java # 设备注册处理
|
||||
│ └── MessageRequestProcessor.java # 心跳和消息处理
|
||||
├── SipLayer.java # SIP 协议层
|
||||
└── dao/
|
||||
├── DeviceMapper.java # 设备数据访问
|
||||
└── DeviceChannelMapper.java # 通道数据访问
|
||||
```
|
||||
|
||||
**性能配置:**
|
||||
```yaml
|
||||
# src/main/resources/application.yml
|
||||
spring:
|
||||
datasource:
|
||||
hikari:
|
||||
maximum-pool-size: 200 # 连接池大小
|
||||
minimum-idle: 10
|
||||
|
||||
sip:
|
||||
pool-size: 100 # SIP 线程池
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 常见问题
|
||||
|
||||
### 1. 编译失败:Maven 命令未找到
|
||||
|
||||
**原因**:Maven 未安装或环境变量未配置
|
||||
|
||||
**解决**:按照上面的"安装 Maven"步骤操作
|
||||
|
||||
### 2. 端口被占用
|
||||
|
||||
**错误**:`Address already in use: bind`
|
||||
|
||||
**解决**:修改端口
|
||||
```yaml
|
||||
# src/main/resources/application-dev.yml
|
||||
server:
|
||||
port: 28080 # 改为其他端口
|
||||
```
|
||||
|
||||
### 3. 数据库连接失败
|
||||
|
||||
**检查 MySQL 状态**:
|
||||
```cmd
|
||||
docker ps | findstr mysql
|
||||
docker logs docker-polaris-mysql-1
|
||||
```
|
||||
|
||||
**测试连接**:
|
||||
```cmd
|
||||
docker exec -it docker-polaris-mysql-1 mysql -u wvp_user -pwvp_password -e "show databases;"
|
||||
```
|
||||
|
||||
### 4. Redis 连接失败
|
||||
|
||||
**检查 Redis**:
|
||||
```cmd
|
||||
docker exec -it docker-polaris-redis-1 redis-cli ping
|
||||
```
|
||||
|
||||
应该返回 `PONG`
|
||||
|
||||
---
|
||||
|
||||
## 📝 配置文件说明
|
||||
|
||||
### 开发环境配置
|
||||
文件:`src/main/resources/application-dev.yml`
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
data:
|
||||
redis:
|
||||
host: 127.0.0.1
|
||||
port: 6379
|
||||
datasource:
|
||||
url: jdbc:mysql://127.0.0.1:3306/wvp?...
|
||||
username: wvp_user
|
||||
password: wvp_password
|
||||
|
||||
server:
|
||||
port: 18080
|
||||
|
||||
media:
|
||||
ip: 127.0.0.1
|
||||
http-port: 8080
|
||||
```
|
||||
|
||||
### Docker 配置
|
||||
文件:`docker/.env`
|
||||
|
||||
```env
|
||||
# 流媒体端口
|
||||
MediaRtmp=10001
|
||||
MediaRtsp=10002
|
||||
MediaRtp=10003
|
||||
|
||||
# Web 端口
|
||||
WebHttp=8080
|
||||
|
||||
# IP 配置(公网部署时需修改)
|
||||
Stream_IP=127.0.0.1
|
||||
SDP_IP=127.0.0.1
|
||||
|
||||
# SIP 配置
|
||||
SIP_Port=8160
|
||||
SIP_Domain=3502000000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 服务管理
|
||||
|
||||
### 查看服务状态
|
||||
```cmd
|
||||
docker ps --filter "name=polaris-"
|
||||
```
|
||||
|
||||
### 启动基础服务
|
||||
```cmd
|
||||
cd docker
|
||||
docker compose up -d polaris-redis polaris-mysql polaris-media
|
||||
```
|
||||
|
||||
### 停止所有服务
|
||||
```cmd
|
||||
cd docker
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### 查看日志
|
||||
```cmd
|
||||
# WVP 日志(运行时控制台输出)
|
||||
|
||||
# Docker 服务日志
|
||||
docker compose logs -f polaris-redis
|
||||
docker compose logs -f polaris-mysql
|
||||
docker compose logs -f polaris-media
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [完整启动指南](启动指南-Java17.md)
|
||||
- [快速启动说明](QUICKSTART.md)
|
||||
- [官方文档](https://doc.wvp-pro.cn)
|
||||
- [GitHub](https://github.com/648540858/wvp-GB28181-pro)
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 快捷命令
|
||||
|
||||
```cmd
|
||||
# 一键编译
|
||||
编译.bat
|
||||
|
||||
# 一键启动
|
||||
启动.bat
|
||||
|
||||
# 查看服务状态
|
||||
docker ps --filter "name=polaris-"
|
||||
|
||||
# 重新编译并启动
|
||||
编译.bat && 启动.bat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
最后更新:2026-02-02
|
||||
Reference in New Issue
Block a user