Merge branch 'feature'

This commit is contained in:
2026-03-05 11:26:10 +08:00
36 changed files with 2342 additions and 127 deletions

45
AGENTS.md Normal file
View 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
View 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 镜像拉取失败)
---
## 方案 ADocker 完整部署(推荐)
### 前提条件
需要解决 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

View File

@@ -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>

View 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())

View 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摄像头截图持久化';

View File

@@ -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);
}
}
}

View File

@@ -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);

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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;
}
// 如果是完整 URLhttps://),直接下载
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;
}
}
}

View File

@@ -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_idapp/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_idapp/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和算法绑定到EdgeHTTP+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";
}
}

View File

@@ -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;
}
}

View File

@@ -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 和绑定

View File

@@ -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 URL5分钟有效
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;
}
}
}

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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());

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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
View 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>
同步频率为每天一次每天凌晨200-400为镜像的同步时间
</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
View 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)
}

View File

@@ -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))
}

View File

@@ -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,

View 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>

View File

@@ -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

View File

@@ -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
View 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
View 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