feat(video): 实现报警记录详情查看与处理功能

- 新增查询报警记录详细接口- 修改处理报警记录接口为 PUT 方法- 新增导出报警记录接口
- 前端页面增加报警视频播放功能
-优化报警记录处理状态显示逻辑- 完善报警详情展示内容,支持图片和视频预览
- 后端实现会话聚合逻辑,支持截图和视频证据保存
- 新增模型修改接口
- 调整权限注解配置
- 完善 MinIO 文件上传和回填逻辑
This commit is contained in:
2025-09-29 13:25:54 +08:00
parent bb325bcfbf
commit 9a682f4ff2
11 changed files with 609 additions and 53 deletions

View File

@@ -9,12 +9,20 @@ export function listAlarm(query) {
})
}
// 查询报警记录详细
export function getAlarm(alarmId) {
return request({
url: '/video/alarm/' + alarmId,
method: 'get'
})
}
// 处理报警记录
export function handleAlarm(data) {
return request({
url: '/video/alarm/handle',
method: 'post',
params: data
method: 'put',
data: data
})
}
@@ -22,7 +30,16 @@ export function handleAlarm(data) {
export function batchHandleAlarm(data) {
return request({
url: '/video/alarm/batchHandle',
method: 'post',
params: data
method: 'put',
data: data
})
}
// 导出报警记录
export function exportAlarm(query) {
return request({
url: '/video/alarm/export',
method: 'post',
params: query
})
}

View File

@@ -79,7 +79,7 @@
v-hasPermi="['video:alarm:export']"
>导出</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
<right-toolbar v-model="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="alarmList" @selection-change="handleSelectionChange">
@@ -112,6 +112,17 @@
/>
</template>
</el-table-column>
<el-table-column label="报警视频" align="center" prop="videoPath">
<template #default="scope">
<el-button
v-if="scope.row.videoPath"
link
type="primary"
icon="VideoPlay"
@click="handleView(scope.row)"
>播放</el-button>
</template>
</el-table-column>
<el-table-column label="报警时间" align="center" prop="alarmTime" width="180">
<template #default="scope">
<span>{{ parseTime(scope.row.alarmTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
@@ -133,20 +144,20 @@
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template #default="scope">
<el-button
v-if="scope.row.handleStatus === '0'"
link
type="success"
type="primary"
icon="Check"
@click="handleProcess(scope.row, '1')"
v-hasPermi="['video:alarm:handle']"
v-if="scope.row.handleStatus === '0'"
>处理</el-button>
<el-button
v-if="scope.row.handleStatus === '0'"
link
type="info"
icon="Close"
@click="handleProcess(scope.row, '2')"
v-hasPermi="['video:alarm:handle']"
v-if="scope.row.handleStatus === '0'"
>忽略</el-button>
<el-button
link
@@ -159,21 +170,17 @@
</el-table>
<pagination
v-show="total>0"
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
:page="queryParams.pageNum"
:limit="queryParams.pageSize"
@pagination="getList"
/>
<!-- 处理报警对话框 -->
<el-dialog :title="processTitle" v-model="processOpen" width="500px" append-to-body>
<el-form ref="processRef" :model="processForm" label-width="80px">
<el-form-item label="处理状态">
<el-tag v-if="processForm.handleStatus === '1'" type="success">已处理</el-tag>
<el-tag v-else-if="processForm.handleStatus === '2'" type="info">已忽略</el-tag>
</el-form-item>
<el-form-item label="处理备注">
<el-form-item label="处理备注" prop="handleRemark">
<el-input
v-model="processForm.handleRemark"
type="textarea"
@@ -190,7 +197,7 @@
</template>
</el-dialog>
<!-- 查看报警详情对话框 -->
<!-- 查看详情对话框 -->
<el-dialog title="报警详情" v-model="viewOpen" width="800px" append-to-body>
<el-descriptions :column="2" border>
<el-descriptions-item label="报警ID">{{ viewForm.alarmId }}</el-descriptions-item>
@@ -203,16 +210,20 @@
<el-tag v-else-if="viewForm.alarmLevel === '3'" type="danger"></el-tag>
</el-descriptions-item>
<el-descriptions-item label="置信度">{{ (viewForm.confidence * 100).toFixed(1) }}%</el-descriptions-item>
<el-descriptions-item label="报警时间" :span="2">{{ parseTime(viewForm.alarmTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</el-descriptions-item>
<el-descriptions-item label="报警时间" :span="2">
{{ parseTime(viewForm.alarmTime, '{y}-{m}-{d} {h}:{i}:{s}') }}
</el-descriptions-item>
<el-descriptions-item label="报警描述" :span="2">{{ viewForm.alarmDesc }}</el-descriptions-item>
<el-descriptions-item label="处理状态">
<el-tag v-if="viewForm.handleStatus === '0'" type="danger">未处理</el-tag>
<el-tag v-else-if="viewForm.handleStatus === '1'" type="success">已处理</el-tag>
<el-tag v-else-if="viewForm.handleStatus === '2'" type="info">已忽略</el-tag>
</el-descriptions-item>
<el-descriptions-item label="处理人">{{ viewForm.handleBy || '' }}</el-descriptions-item>
<el-descriptions-item label="处理时间" :span="2">{{ parseTime(viewForm.handleTime, '{y}-{m}-{d} {h}:{i}:{s}') || '无' }}</el-descriptions-item>
<el-descriptions-item label="处理备注" :span="2">{{ viewForm.handleRemark || '' }}</el-descriptions-item>
<el-descriptions-item label="处理人">{{ viewForm.handleBy || '-' }}</el-descriptions-item>
<el-descriptions-item label="处理时间" :span="2">
{{ viewForm.handleTime ? parseTime(viewForm.handleTime, '{y}-{m}-{d} {h}:{i}:{s}') : '-' }}
</el-descriptions-item>
<el-descriptions-item label="处理备注" :span="2">{{ viewForm.handleRemark || '-' }}</el-descriptions-item>
</el-descriptions>
<div v-if="viewForm.imagePath" style="margin-top: 20px;">
@@ -224,6 +235,15 @@
fit="contain"
/>
</div>
<div v-if="viewForm.videoPath" style="margin-top: 20px;">
<h4>报警视频</h4>
<video
:src="viewForm.videoPath"
style="width: 100%; max-height: 420px; background: #000"
controls
preload="metadata"
/>
</div>
</el-dialog>
</div>
</template>

View File

@@ -99,6 +99,10 @@
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
</dependency>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-framework</artifactId>
</dependency>
</dependencies>

View File

@@ -28,7 +28,6 @@ public class ModelController extends BaseController {
}
/** 新增模型JSON */
@PreAuthorize("@ss.hasPermi('video:model:add')")
@PostMapping
public AjaxResult create(@RequestBody VModel model) {
if (StringUtils.isEmpty(model.getModelName())) {
@@ -44,8 +43,23 @@ public class ModelController extends BaseController {
return rows > 0 ? AjaxResult.success(model) : AjaxResult.error("新增失败");
}
/** 修改模型 */
@PutMapping
public AjaxResult update(@RequestBody VModel model) {
if (model.getModelId() == null) {
return AjaxResult.error("模型ID不能为空");
}
if (StringUtils.isEmpty(model.getModelName())) {
return AjaxResult.error("模型名称不能为空");
}
if (StringUtils.isEmpty(model.getFramework())) {
model.setFramework("onnx");
}
int rows = modelService.update(model);
return rows > 0 ? AjaxResult.success("修改成功") : AjaxResult.error("修改失败");
}
/** 根据ID查询 */
@PreAuthorize("@ss.hasPermi('video:model:query')")
@GetMapping("/{id}")
public AjaxResult get(@PathVariable("id") Long id) {
VModel model = modelService.selectById(id);
@@ -53,7 +67,6 @@ public class ModelController extends BaseController {
}
/** 列表查询(可选条件) */
@PreAuthorize("@ss.hasPermi('video:model:list')")
@GetMapping("/list")
public TableDataInfo list(@RequestParam(value = "modelName", required = false) String modelName,
@RequestParam(value = "framework", required = false) String framework,
@@ -70,7 +83,6 @@ public class ModelController extends BaseController {
}
/** 删除 */
@PreAuthorize("@ss.hasPermi('video:model:remove')")
@DeleteMapping("/{id}")
public AjaxResult delete(@PathVariable("id") Long id) {
int rows = modelService.deleteById(id);
@@ -78,7 +90,6 @@ public class ModelController extends BaseController {
}
/** 启用/禁用 */
@PreAuthorize("@ss.hasPermi('video:model:edit')")
@PutMapping("/{id}/enable")
public AjaxResult enable(@PathVariable("id") Long id,
@RequestParam("enabled") Integer enabled) {
@@ -90,7 +101,6 @@ public class ModelController extends BaseController {
}
/** 下载:直接 302 重定向到模型URL确保可点击下载 */
@PreAuthorize("@ss.hasPermi('video:model:download')")
@GetMapping("/download/{id}")
public void download(@PathVariable("id") Long id, HttpServletResponse response) throws IOException {
VModel model = modelService.selectById(id);

View File

@@ -1,4 +1,4 @@
package com.ruoyi.web.controller.system;
package com.ruoyi.video.controller;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.StringUtils;

View File

@@ -9,6 +9,7 @@ import java.util.Map;
public interface VModelMapper {
int insertModel(VModel model);
VModel selectModelById(@Param("id") Long id);
int updateModel(VModel model);
int deleteModelById(@Param("id") Long id);
int updateEnabled(@Param("id") Long id, @Param("enabled") Integer enabled);

View File

@@ -8,6 +8,7 @@ import java.util.Map;
public interface IVModelService {
int insert(VModel model);
VModel selectById(Long id);
int update(VModel model);
int deleteById(Long id);
int updateEnabled(Long id, Integer enabled);
List<VModel> selectList(Map<String, Object> params);

View File

@@ -11,6 +11,14 @@ import com.ruoyi.video.service.InspectionTaskService;
import com.ruoyi.video.thread.MediaTransferFlvByJavacv;
import com.ruoyi.video.common.ModelManager;
import com.ruoyi.video.thread.detector.YoloDetector;
import com.ruoyi.common.utils.spring.SpringUtils;
import com.ruoyi.framework.service.MinioService;
import com.ruoyi.video.service.IVMinioObjectService;
import com.ruoyi.video.domain.VMinioObject;
import com.ruoyi.video.utils.CustomMultipartFile;
import com.ruoyi.video.utils.Overlay;
import org.bytedeco.javacv.FFmpegFrameRecorder;
import org.bytedeco.javacv.Java2DFrameConverter;
import lombok.extern.slf4j.Slf4j;
import org.bytedeco.javacv.Frame;
import org.bytedeco.javacv.FrameGrabber;
@@ -177,6 +185,8 @@ public class InspectionTaskServiceImpl implements InspectionTaskService {
}
FFmpegFrameGrabber grabber = null;
FFmpegFrameRecorder sessionRecorder = null;
File sessionVideoTmp = null;
try {
// 初始化模型管理器
if (modelManager == null) {
@@ -201,12 +211,32 @@ public class InspectionTaskServiceImpl implements InspectionTaskService {
int frameCount = 0;
List<Detection> allDetections = new ArrayList<>();
// 会话聚合参数与状态
final long minGapMs = 3000L; // 目标消失超过该值视为结束
final long maxDurationMs = 30000L; // 单次会话最长30s
final float detectionThreshold = task.getThreshold() != null ? task.getThreshold().floatValue() : 0.7f;
boolean sessionActive = false;
long sessionStartMs = 0L;
long lastSeenMs = 0L;
sessionRecorder = null;
sessionVideoTmp = null;
Long currentAlarmId = null;
while (System.currentTimeMillis() - startTime < duration) {
Frame frame = grabber.grabImage();
if (frame == null) continue;
frameCount++;
// 录像帧追加
if (sessionActive && sessionRecorder != null) {
try {
sessionRecorder.record(frame);
} catch (Exception ignore) {
}
}
// 每隔一定帧数进行一次检测(避免过于频繁)
if (frameCount % 25 == 0) { // 假设25fps每秒检测一次
try {
@@ -220,10 +250,166 @@ public class InspectionTaskServiceImpl implements InspectionTaskService {
List<Detection> detections = performDetection(mat);
allDetections.addAll(detections);
// 如果检测到异常,立即保存图片并记录报警
if (!detections.isEmpty()) {
String imagePath = saveFrameAsImage(frame, task.getTaskId());
handleDetectionResults(task.getTaskId(), detections, imagePath);
// 会话聚合:阈值触发开始,目标消失/超过30s结束开始时叠框截图+告警,结束后上传视频并回填
long now = System.currentTimeMillis();
// 叠框,便于生成可核验证据图
try {
if (detections != null && !detections.isEmpty() && mat != null && !mat.empty()) {
Overlay.draw(detections, mat);
}
} catch (Exception ignore) {
}
// 最高置信度
Detection best = detections.stream()
.max(Comparator.comparingDouble(Detection::conf))
.orElse(null);
boolean hasTarget = best != null && best.conf() >= detectionThreshold;
if (hasTarget) {
lastSeenMs = now;
if (!sessionActive) {
// 1) 叠框后截图上传 + 插入告警(写 image_path
try {
BufferedImage img = null;
try {
Java2DFrameConverter conv = new Java2DFrameConverter();
if (mat != null && !mat.empty()) {
img = conv.convert(new OpenCVFrameConverter.ToMat().convert(mat));
}
if (img == null) {
img = conv.convert(frame);
}
} catch (Exception ___) {
}
if (img != null) {
File tmp = File.createTempFile("snapshot_", ".jpg");
ImageIO.write(img, "jpg", tmp);
String originalName = "task_" + task.getTaskId() + "_" + now + ".jpg";
String uniqueObjectName = UUID.randomUUID().toString().replace("-", "") + ".jpg";
MinioService minio = SpringUtils.getBean(MinioService.class);
IVMinioObjectService objSvc = SpringUtils.getBean(IVMinioObjectService.class);
MinioService.UploadResult up = minio.uploadWithName(new CustomMultipartFile(tmp, "image/jpeg"), uniqueObjectName);
VMinioObject rec = new VMinioObject();
rec.setObjectName(up.getObjectName());
rec.setUrl(up.getUrl());
rec.setOriginalName(originalName);
objSvc.insert(rec);
AlarmRecord alarm = new AlarmRecord();
alarm.setTaskId(task.getTaskId());
alarm.setTaskName(task.getTaskName());
alarm.setDeviceId(task.getDeviceId());
alarm.setDeviceName(task.getDeviceName());
alarm.setAlarmType(best.cls());
alarm.setAlarmLevel(getAlarmLevel(best.conf()));
alarm.setAlarmDesc(String.format("检测到%s置信度: %.2f", best.cls(), best.conf()));
alarm.setConfidence((double) best.conf());
alarm.setImagePath(up.getUrl());
alarm.setAlarmTime(new Date(now));
alarm.setHandleStatus("0");
alarm.setCreateBy(SecurityUtils.getUsername());
saveAlarmRecord(alarm);
currentAlarmId = alarm.getAlarmId();
try {
tmp.delete();
} catch (Exception ignore) {
}
}
} catch (Exception ee) {
log.warn("巡检会话-保存截图失败: {}", ee.getMessage());
}
// 2) 启动会话录像mp4
try {
sessionVideoTmp = File.createTempFile("evidence_", ".mp4");
sessionRecorder = new FFmpegFrameRecorder(
sessionVideoTmp,
grabber.getImageWidth(),
grabber.getImageHeight(),
grabber.getAudioChannels()
);
sessionRecorder.setFormat("mp4");
sessionRecorder.setInterleaved(true);
sessionRecorder.setVideoOption("preset", "ultrafast");
sessionRecorder.setVideoOption("tune", "zerolatency");
sessionRecorder.setFrameRate(25);
sessionRecorder.setGopSize(25);
sessionRecorder.start();
} catch (Exception sre) {
log.warn("巡检会话-录像器启动失败: {}", sre.getMessage());
sessionRecorder = null;
sessionVideoTmp = null;
}
sessionActive = true;
sessionStartMs = now;
}
}
// 会话结束判定:目标消失超过阈值或会话超过最大时长
if (sessionActive) {
boolean gapTimeout = (now - lastSeenMs) >= minGapMs;
boolean maxed = (now - sessionStartMs) >= maxDurationMs;
if (gapTimeout || maxed) {
File local = sessionVideoTmp;
FFmpegFrameRecorder rec = sessionRecorder;
sessionVideoTmp = null;
sessionRecorder = null;
try {
if (rec != null) {
try {
rec.stop();
} catch (Exception ignore) {
}
try {
rec.release();
} catch (Exception ignore) {
}
}
} catch (Exception se) {
log.debug("巡检会话-停止录像异常: {}", se.getMessage());
}
if (local != null && local.exists() && currentAlarmId != null) {
try {
MinioService minio = SpringUtils.getBean(MinioService.class);
IVMinioObjectService objSvc = SpringUtils.getBean(IVMinioObjectService.class);
String originalName = "evidence_" + now + ".mp4";
String uniqueName = UUID.randomUUID().toString().replace("-", "") + ".mp4";
MinioService.UploadResult upv = minio.uploadWithName(new CustomMultipartFile(local, "video/mp4"), uniqueName);
VMinioObject vrec = new VMinioObject();
vrec.setObjectName(upv.getObjectName());
vrec.setUrl(upv.getUrl());
vrec.setOriginalName(originalName);
objSvc.insert(vrec);
AlarmRecord patch = new AlarmRecord();
patch.setAlarmId(currentAlarmId);
patch.setVideoPath(upv.getUrl());
alarmRecordMapper.updateAlarmRecord(patch);
} catch (Exception ue) {
log.warn("巡检会话-上传/回填视频失败: {}", ue.getMessage());
} finally {
try {
local.delete();
} catch (Exception ignore) {
}
}
}
sessionActive = false;
sessionStartMs = 0L;
lastSeenMs = 0L;
currentAlarmId = null;
}
}
}
} catch (Exception e) {
@@ -240,6 +426,31 @@ public class InspectionTaskServiceImpl implements InspectionTaskService {
} catch (Exception e) {
log.error("视频分析失败: {}", e.getMessage(), e);
} finally {
// 兜底清理会话资源(缓存并置空,避免并发/重复close问题
FFmpegFrameRecorder rec = sessionRecorder;
File local = sessionVideoTmp;
sessionRecorder = null;
sessionVideoTmp = null;
try {
if (rec != null) {
try {
rec.stop();
} catch (Exception ignore) {
}
try {
rec.release();
} catch (Exception ignore) {
}
}
} catch (Exception ignore) {
}
if (local != null) {
try {
local.delete();
} catch (Exception ignore) {
}
}
if (grabber != null) {
try {
grabber.stop();
@@ -285,7 +496,7 @@ public class InspectionTaskServiceImpl implements InspectionTaskService {
String filePath = saveDir + "/" + fileName;
// 转换并保存图片
BufferedImage bufferedImage = new org.bytedeco.javacv.Java2DFrameConverter().convert(frame);
BufferedImage bufferedImage = new Java2DFrameConverter().convert(frame);
ImageIO.write(bufferedImage, "jpg", new File(filePath));
return filePath;
@@ -315,7 +526,7 @@ public class InspectionTaskServiceImpl implements InspectionTaskService {
alarmRecord.setAlarmLevel(getAlarmLevel(detection.conf()));
alarmRecord.setAlarmDesc(String.format("检测到%s置信度: %.2f",
detection.cls(), detection.conf()));
alarmRecord.setConfidence((double)detection.conf());
alarmRecord.setConfidence((double) detection.conf());
alarmRecord.setImagePath(imagePath);
alarmRecord.setAlarmTime(new Date());
alarmRecord.setHandleStatus("0"); // 未处理

View File

@@ -28,6 +28,11 @@ public class VModelServiceImpl implements IVModelService {
return mapper.selectModelById(id);
}
@Override
public int update(VModel model) {
return mapper.updateModel(model);
}
@Override
public int deleteById(Long id) {
return mapper.deleteModelById(id);

View File

@@ -8,6 +8,13 @@ import com.ruoyi.video.service.MediaService;
import com.ruoyi.video.thread.detector.CompositeDetector;
import com.ruoyi.video.thread.detector.YoloDetector;
import com.ruoyi.video.utils.Overlay;
import com.ruoyi.common.utils.spring.SpringUtils;
import com.ruoyi.framework.service.MinioService;
import com.ruoyi.video.service.IVMinioObjectService;
import com.ruoyi.video.domain.VMinioObject;
import com.ruoyi.video.domain.AlarmRecord;
import com.ruoyi.video.mapper.AlarmRecordMapper;
import com.ruoyi.video.utils.CustomMultipartFile;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
@@ -24,14 +31,21 @@ import org.bytedeco.opencv.opencv_core.Mat;
import org.springframework.util.CollectionUtils;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.nio.ByteBuffer;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import java.util.Collections;
import java.util.UUID;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import static org.bytedeco.opencv.global.opencv_core.CV_8UC3;
@@ -57,6 +71,200 @@ public class MediaTransferFlvByJavacv extends MediaTransfer implements Runnable
void onWindowFinished(Long jobId, Long deviceId, WindowStats stats);
}
/** 启动会话视频录制本地mp4临时文件结束后上传MinIO */
private void startSessionVideoRecorder() {
try {
// 以系统临时目录创建mp4
sessionVideoTmp = File.createTempFile("evidence_", ".mp4");
int w = (grabber != null) ? grabber.getImageWidth() : 1280;
int h = (grabber != null) ? grabber.getImageHeight() : 720;
int ac = (grabber != null) ? grabber.getAudioChannels() : 0;
sessionRecorder = new FFmpegFrameRecorder(sessionVideoTmp, w, h, ac);
sessionRecorder.setFormat("mp4");
sessionRecorder.setInterleaved(true);
sessionRecorder.setVideoOption("preset", "ultrafast");
sessionRecorder.setVideoOption("tune", "zerolatency");
sessionRecorder.setFrameRate(25);
sessionRecorder.setGopSize(25);
sessionRecorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
sessionRecorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);
if (ac > 0) sessionRecorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC);
sessionRecorder.start();
log.debug("会话录像器启动: {}", sessionVideoTmp);
} catch (Exception e) {
log.warn("会话录像器启动失败: {}", e.getMessage());
try { if (sessionRecorder != null) sessionRecorder.close(); } catch (Exception ignore) {}
sessionRecorder = null;
sessionVideoTmp = null;
}
}
/** 结束会话录像并上传到 MinIO然后更新 AlarmRecord.videoPath */
private void stopAndUploadSessionVideo(long nowMs) {
File local = sessionVideoTmp;
FFmpegFrameRecorder rec = sessionRecorder;
sessionVideoTmp = null;
sessionRecorder = null;
try {
if (rec != null) {
try { rec.stop(); } catch (Exception ignore) {}
try { rec.release(); } catch (Exception ignore) {}
}
} catch (Exception e) {
log.debug("会话录像停止异常: {}", e.getMessage());
}
if (local == null || !local.exists()) return;
try {
// 上传
String originalName = buildVideoOriginalName(nowMs);
String uniqueObjectName = buildUniqueVideoObjectName(originalName);
MinioService minio = SpringUtils.getBean(MinioService.class);
IVMinioObjectService objSvc = SpringUtils.getBean(IVMinioObjectService.class);
AlarmRecordMapper alarmMapper = SpringUtils.getBean(AlarmRecordMapper.class);
CustomMultipartFile mf = new CustomMultipartFile(local, "video/mp4");
MinioService.UploadResult up = minio.uploadWithName(mf, uniqueObjectName);
// 入库 v_minio_object
VMinioObject recObj = new VMinioObject();
recObj.setObjectName(up.getObjectName());
recObj.setUrl(up.getUrl());
recObj.setOriginalName(originalName);
objSvc.insert(recObj);
// 回填 AlarmRecord 的 videoPath
if (currentAlarmId != null) {
AlarmRecord patch = new AlarmRecord();
patch.setAlarmId(currentAlarmId);
patch.setVideoPath(up.getUrl());
alarmMapper.updateAlarmRecord(patch);
log.info("会话视频上传并回填告警: alarmId={}, url={}", currentAlarmId, up.getUrl());
}
} catch (Exception e) {
log.warn("上传/回填会话视频失败: {}", e.getMessage());
} finally {
try { local.delete(); } catch (Exception ignore) {}
currentAlarmId = null;
}
}
private String buildVideoOriginalName(long nowMs) {
return "evidence_" + nowMs + ".mp4";
}
private String buildUniqueVideoObjectName(String original) {
String ext = ".mp4";
int i = original.lastIndexOf('.');
if (i > 0) ext = original.substring(i);
return UUID.randomUUID().toString().replace("-", "") + ext;
}
/** 外部可调整置信度阈值 */
public void setDetectionThreshold(float threshold) {
this.detectionThreshold = threshold;
}
/**
* 保存一张叠好框的截图到本地 → 上传到 MinIO → v_minio_object 入库 → 写入一条报警记录。
*/
private void saveSnapshotAndCreateAlarm(List<Detection> detections, long nowMs) {
// 拿到叠好框的帧
Mat annotated = getLatestAnnotatedFrameCopy();
if (annotated == null || annotated.empty()) {
log.debug("没有可用的叠框帧,跳过截图保存");
return;
}
try {
// 置信度阈值过滤(取最高分作为本次代表)
Detection best = detections.stream()
.max((a,b) -> Float.compare(a.conf(), b.conf()))
.orElse(null);
if (best == null || best.conf() < detectionThreshold) {
log.debug("最高置信度 {} 低于阈值 {},跳过落库与上传", best == null ? 0f : best.conf(), detectionThreshold);
return;
}
// 转为 BufferedImage
Java2DFrameConverter conv = new Java2DFrameConverter();
OpenCVFrameConverter.ToMat toMat = new OpenCVFrameConverter.ToMat();
Frame f = toMat.convert(annotated);
BufferedImage img = conv.convert(f);
if (img == null) {
log.debug("帧转图片失败");
return;
}
// 写到临时文件
String originalName = buildSnapshotOriginalName(nowMs);
File tmp = File.createTempFile("snapshot_", ".jpg");
ImageIO.write(img, "jpg", tmp);
// 上传到 MinIO唯一名
String uniqueObjectName = buildUniqueObjectName(originalName);
MinioService minio = SpringUtils.getBean(MinioService.class);
IVMinioObjectService objSvc = SpringUtils.getBean(IVMinioObjectService.class);
AlarmRecordMapper alarmMapper = SpringUtils.getBean(AlarmRecordMapper.class);
CustomMultipartFile mf = new CustomMultipartFile(tmp, "image/jpeg");
MinioService.UploadResult up = minio.uploadWithName(mf, uniqueObjectName);
// v_minio_object 入库
VMinioObject rec = new VMinioObject();
rec.setObjectName(up.getObjectName());
rec.setUrl(up.getUrl());
rec.setOriginalName(originalName);
objSvc.insert(rec);
// 写报警记录(仅图片)
AlarmRecord alarm = new AlarmRecord();
alarm.setAlarmType(best.cls());
alarm.setAlarmLevel(getLevel(best.conf()));
alarm.setAlarmDesc(String.format("检测到%s置信度: %.2f",
best.cls(), best.conf()));
alarm.setConfidence((double)best.conf());
alarm.setImagePath(up.getUrl());
alarm.setAlarmTime(new Date(nowMs));
alarm.setHandleStatus("0");
// 绑定任务/设备:优先窗口上下文,其次 cameraDto 备注/URL
if (currentJobId != null) alarm.setTaskId(currentJobId);
if (currentDeviceId != null) alarm.setDeviceId(currentDeviceId);
if (cameraDto != null) {
String dn = cameraDto.getRemark();
if (dn == null || dn.isEmpty()) dn = cameraDto.getUrl();
alarm.setDeviceName(dn);
}
alarmMapper.insertAlarmRecord(alarm);
// 保存本次会话对应的告警ID用于视频回填
currentAlarmId = alarm.getAlarmId();
// 清理临时文件
try { tmp.delete(); } catch (Exception ignore) {}
} catch (Exception e) {
log.warn("保存/上传截图失败: {}", e.getMessage());
} finally {
try { annotated.release(); } catch (Exception ignore) {}
}
}
private String buildSnapshotOriginalName(long nowMs) {
String ts = String.valueOf(nowMs);
return "snapshot_" + ts + ".jpg";
}
private String buildUniqueObjectName(String original) {
String ext = ".jpg";
int i = original.lastIndexOf('.');
if (i > 0) ext = original.substring(i);
return UUID.randomUUID().toString().replace("-", "") + ext;
}
private String getLevel(float conf) {
if (conf >= 0.9f) return "3";
if (conf >= 0.7f) return "2";
return "1";
}
@Data
public static class WindowStats {
private int frames;
@@ -123,6 +331,21 @@ public class MediaTransferFlvByJavacv extends MediaTransfer implements Runnable
// 导出最近一次“叠好框的帧”用于截图存证
private final AtomicReference<Mat> latestAnnotatedFrame = new AtomicReference<>();
// =============== 轻量级“截图留存”会话聚合(不录视频) ===============
private boolean snapshotSessionActive = false;
private long sessionStartMs = 0L;
private long lastSeenMs = 0L;
// 连续存在的最小空窗阈值超过视为结束默认3秒
private long minGapMs = 3000L;
// 单次会话最长保存时长默认30秒
private long maxDurationMs = 30000L;
// 置信度阈值(低于该值不落库不截图上传)
private float detectionThreshold = 0.7f;
// 会话视频录制器与上下文
private FFmpegFrameRecorder sessionRecorder;
private File sessionVideoTmp;
private Long currentAlarmId; // 用于会话结束后回填 videoPath
public MediaTransferFlvByJavacv(CameraDto cameraDto) {
super();
this.cameraDto = cameraDto;
@@ -451,6 +674,37 @@ public class MediaTransferFlvByJavacv extends MediaTransfer implements Runnable
// 更新"最近叠好框的帧"用于存证
updateLatestAnnotated(mat);
// ============ 轻量版会话聚合:仅保存一张截图 ============
try {
long now = System.currentTimeMillis();
boolean hasTarget = currentDetections != null && !currentDetections.isEmpty();
if (hasTarget) {
lastSeenMs = now;
if (!snapshotSessionActive) {
// 会话开始:截一张叠好框的图并落库+上传
snapshotSessionActive = true;
sessionStartMs = now;
saveSnapshotAndCreateAlarm(currentDetections, now);
// 启动会话视频录制
startSessionVideoRecorder();
}
}
// 结束条件:目标消失超过阈值,或会话超过最大时长
if (snapshotSessionActive) {
boolean gapTimeout = (now - lastSeenMs) >= minGapMs;
boolean maxed = (now - sessionStartMs) >= maxDurationMs;
if (gapTimeout || maxed) {
// 停止并上传会话视频,回填到本次告警
stopAndUploadSessionVideo(now);
snapshotSessionActive = false;
sessionStartMs = 0L;
lastSeenMs = 0L;
}
}
} catch (Exception ex) {
log.warn("轻量截图会话处理异常: {}", ex.getMessage());
}
// 统计(仅窗口巡检时)
if (windowMode) updateStats(currentDetections);
@@ -486,6 +740,14 @@ public class MediaTransferFlvByJavacv extends MediaTransfer implements Runnable
videoTS = 1000 * (now - startTime);
if (videoTS > recorder.getTimestamp()) recorder.setTimestamp(videoTS);
recorder.record(frame);
// 会话录制:同步追加处理后的帧
if (snapshotSessionActive && sessionRecorder != null) {
try {
sessionRecorder.record(frame);
} catch (Exception recErr) {
log.debug("会话录像写入失败: {}", recErr.getMessage());
}
}
}
}
} catch (FrameGrabber.Exception e) {
@@ -536,6 +798,14 @@ public class MediaTransferFlvByJavacv extends MediaTransfer implements Runnable
if (recorder != null) {
try { recorder.close(); } catch (Exception ignored) {}
}
if (sessionRecorder != null) {
try { sessionRecorder.close(); } catch (Exception ignored) {}
sessionRecorder = null;
}
if (sessionVideoTmp != null) {
try { sessionVideoTmp.delete(); } catch (Exception ignore) {}
sessionVideoTmp = null;
}
if (grabber != null) {
try { grabber.close(); } catch (Exception ignored) {}

View File

@@ -33,6 +33,23 @@
SELECT * FROM v_model WHERE model_id = #{id}
</select>
<update id="updateModel" parameterType="com.ruoyi.video.domain.VModel">
UPDATE v_model
<trim prefix="SET" suffixOverrides=",">
<if test="modelName != null and modelName != ''">model_name = #{modelName},</if>
<if test="version != null">version = #{version},</if>
<if test="framework != null and framework != ''">framework = #{framework},</if>
<if test="url != null and url != ''">url = #{url},</if>
<if test="fileSize != null">file_size = #{fileSize},</if>
<if test="checksum != null">checksum = #{checksum},</if>
<if test="enabled != null">enabled = #{enabled},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
<if test="remark != null">remark = #{remark},</if>
update_time = NOW()
</trim>
WHERE model_id = #{modelId}
</update>
<delete id="deleteModelById" parameterType="long">
DELETE FROM v_model WHERE model_id = #{id}
</delete>