You've already forked FrameTour-BE
feat(device): 实现设备视频连续性检查功能
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 新增设备视频连续性检查控制器 DeviceVideoContinuityController - 提供查询、手动触发和删除检查结果的 REST 接口 - 实现视频连续性检查核心逻辑,支持检测视频间隙 - 添加定时任务 DeviceVideoContinuityCheckTask 自动检查设备视频连续性 - 仅在生产环境(prod)启用,每天9点到18点间每5分钟执行一次 - 支持阿里云OSS和本地存储的视频连续性检查 - 检查结果缓存至 Redis,默认保留24小时 - 新增相关实体类: DeviceVideoContinuityCache、VideoContinuityGap、VideoContinuityResult - 在存储操作接口中增加 checkVideoContinuity 和 checkRecentVideoContinuity 方法 - 为不支持的存储类型提供默认不支持连续性检查的实现
This commit is contained in:
@@ -0,0 +1,106 @@
|
|||||||
|
package com.ycwl.basic.controller.pc;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.ycwl.basic.device.entity.common.DeviceVideoContinuityCache;
|
||||||
|
import com.ycwl.basic.task.DeviceVideoContinuityCheckTask;
|
||||||
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备视频连续性检查控制器
|
||||||
|
* 提供查询设备视频连续性检查结果的接口
|
||||||
|
*
|
||||||
|
* @author Claude Code
|
||||||
|
* @date 2025-09-01
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/device/video-continuity")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DeviceVideoContinuityController {
|
||||||
|
|
||||||
|
private static final String REDIS_KEY_PREFIX = "device:video:continuity:";
|
||||||
|
|
||||||
|
private final RedisTemplate<String, String> redisTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final DeviceVideoContinuityCheckTask checkTask;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询设备最近的视频连续性检查结果
|
||||||
|
*
|
||||||
|
* @param deviceId 设备ID
|
||||||
|
* @return 检查结果
|
||||||
|
*/
|
||||||
|
@GetMapping("/{deviceId}")
|
||||||
|
public ApiResponse<DeviceVideoContinuityCache> getDeviceContinuityResult(@PathVariable Long deviceId) {
|
||||||
|
log.info("查询设备 {} 的视频连续性检查结果", deviceId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String redisKey = REDIS_KEY_PREFIX + deviceId;
|
||||||
|
String cacheJson = redisTemplate.opsForValue().get(redisKey);
|
||||||
|
|
||||||
|
if (cacheJson == null) {
|
||||||
|
log.warn("未找到设备 {} 的视频连续性检查结果", deviceId);
|
||||||
|
return ApiResponse.buildResponse(404, null, "未找到该设备的检查结果,可能设备未配置存储或尚未执行检查");
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceVideoContinuityCache cache = objectMapper.readValue(cacheJson, DeviceVideoContinuityCache.class);
|
||||||
|
return ApiResponse.success(cache);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("查询设备 {} 视频连续性检查结果失败", deviceId, e);
|
||||||
|
return ApiResponse.buildResponse(500, null, "查询失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动触发设备视频连续性检查
|
||||||
|
* 注意:仅用于测试和紧急情况,正常情况下由定时任务自动执行
|
||||||
|
*
|
||||||
|
* @param deviceId 设备ID
|
||||||
|
* @return 检查结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/{deviceId}/check")
|
||||||
|
public ApiResponse<DeviceVideoContinuityCache> manualCheck(@PathVariable Long deviceId) {
|
||||||
|
log.info("手动触发设备 {} 的视频连续性检查", deviceId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
DeviceVideoContinuityCache result = checkTask.manualCheck(deviceId);
|
||||||
|
return ApiResponse.success(result);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("手动检查设备 {} 视频连续性失败", deviceId, e);
|
||||||
|
return ApiResponse.buildResponse(500, null, "检查失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除设备的视频连续性检查缓存
|
||||||
|
* 用于清理过期或错误的缓存数据
|
||||||
|
*
|
||||||
|
* @param deviceId 设备ID
|
||||||
|
* @return 删除结果
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{deviceId}")
|
||||||
|
public ApiResponse<String> deleteContinuityCache(@PathVariable Long deviceId) {
|
||||||
|
log.info("删除设备 {} 的视频连续性检查缓存", deviceId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String redisKey = REDIS_KEY_PREFIX + deviceId;
|
||||||
|
Boolean deleted = redisTemplate.delete(redisKey);
|
||||||
|
|
||||||
|
if (deleted != null && deleted) {
|
||||||
|
return ApiResponse.success("缓存删除成功");
|
||||||
|
} else {
|
||||||
|
return ApiResponse.buildResponse(404, null, "未找到该设备的缓存数据");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("删除设备 {} 视频连续性检查缓存失败", deviceId, e);
|
||||||
|
return ApiResponse.buildResponse(500, null, "删除失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package com.ycwl.basic.device.entity.common;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备视频连续性检查缓存实体
|
||||||
|
* 用于存储在Redis中的检查结果
|
||||||
|
*
|
||||||
|
* @author Claude Code
|
||||||
|
* @date 2025-09-01
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class DeviceVideoContinuityCache implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备ID
|
||||||
|
*/
|
||||||
|
private Long deviceId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查时间
|
||||||
|
*/
|
||||||
|
private Date checkTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查的开始时间
|
||||||
|
*/
|
||||||
|
private Date startTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查的结束时间
|
||||||
|
*/
|
||||||
|
private Date endTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否支持连续性检查
|
||||||
|
*/
|
||||||
|
private Boolean support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频是否连续
|
||||||
|
*/
|
||||||
|
private Boolean continuous;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频总数
|
||||||
|
*/
|
||||||
|
private Integer totalVideos;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总时长(毫秒)
|
||||||
|
*/
|
||||||
|
private Long totalDurationMs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 允许的最大间隙(毫秒)
|
||||||
|
*/
|
||||||
|
private Long maxAllowedGapMs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙数量
|
||||||
|
*/
|
||||||
|
private Integer gapCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙列表(简化版,只包含关键信息)
|
||||||
|
*/
|
||||||
|
private List<GapInfo> gaps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙信息简化类
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class GapInfo implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前一个文件名
|
||||||
|
*/
|
||||||
|
private String beforeFileName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后一个文件名
|
||||||
|
*/
|
||||||
|
private String afterFileName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙时长(毫秒)
|
||||||
|
*/
|
||||||
|
private Long gapMs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙开始时间
|
||||||
|
*/
|
||||||
|
private Date gapStartTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙结束时间
|
||||||
|
*/
|
||||||
|
private Date gapEndTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从VideoContinuityResult创建缓存对象
|
||||||
|
*
|
||||||
|
* @param deviceId 设备ID
|
||||||
|
* @param result 检查结果
|
||||||
|
* @param startTime 检查开始时间
|
||||||
|
* @param endTime 检查结束时间
|
||||||
|
* @return 缓存对象
|
||||||
|
*/
|
||||||
|
public static DeviceVideoContinuityCache fromResult(Long deviceId, VideoContinuityResult result,
|
||||||
|
Date startTime, Date endTime) {
|
||||||
|
DeviceVideoContinuityCache cache = new DeviceVideoContinuityCache();
|
||||||
|
cache.setDeviceId(deviceId);
|
||||||
|
cache.setCheckTime(new Date());
|
||||||
|
cache.setStartTime(startTime);
|
||||||
|
cache.setEndTime(endTime);
|
||||||
|
cache.setSupport(result.isSupport());
|
||||||
|
cache.setContinuous(result.isContinuous());
|
||||||
|
cache.setTotalVideos(result.getTotalVideos());
|
||||||
|
cache.setTotalDurationMs(result.getTotalDurationMs());
|
||||||
|
cache.setMaxAllowedGapMs(result.getMaxAllowedGapMs());
|
||||||
|
cache.setGapCount(result.getGapCount());
|
||||||
|
|
||||||
|
// 转换间隙列表
|
||||||
|
if (result.getGaps() != null && !result.getGaps().isEmpty()) {
|
||||||
|
List<GapInfo> gapInfos = result.getGaps().stream()
|
||||||
|
.map(gap -> new GapInfo(
|
||||||
|
gap.getBeforeFile() != null ? gap.getBeforeFile().getName() : null,
|
||||||
|
gap.getAfterFile() != null ? gap.getAfterFile().getName() : null,
|
||||||
|
gap.getGapMs(),
|
||||||
|
gap.getGapStartTime(),
|
||||||
|
gap.getGapEndTime()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
cache.setGaps(gapInfos);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.ycwl.basic.device.entity.common;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频连续性检查中的间隙信息
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class VideoContinuityGap {
|
||||||
|
/**
|
||||||
|
* 间隙前的视频文件
|
||||||
|
*/
|
||||||
|
private FileObject beforeFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙后的视频文件
|
||||||
|
*/
|
||||||
|
private FileObject afterFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙时长(毫秒)
|
||||||
|
*/
|
||||||
|
private long gapMs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙开始时间(前一个视频的endTime)
|
||||||
|
*/
|
||||||
|
private Date gapStartTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙结束时间(后一个视频的createTime)
|
||||||
|
*/
|
||||||
|
private Date gapEndTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.ycwl.basic.device.entity.common;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频连续性检查结果
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class VideoContinuityResult {
|
||||||
|
/**
|
||||||
|
* 是否支持连续性检查功能
|
||||||
|
*/
|
||||||
|
private boolean support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频是否连续(所有间隙都在允许范围内)
|
||||||
|
*/
|
||||||
|
private boolean continuous;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测到的间隙列表
|
||||||
|
*/
|
||||||
|
private List<VideoContinuityGap> gaps = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频文件总数
|
||||||
|
*/
|
||||||
|
private int totalVideos;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总时长(毫秒)
|
||||||
|
*/
|
||||||
|
private long totalDurationMs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 允许的最大间隙(毫秒)
|
||||||
|
*/
|
||||||
|
private long maxAllowedGapMs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加一个间隙
|
||||||
|
*/
|
||||||
|
public void addGap(VideoContinuityGap gap) {
|
||||||
|
this.gaps.add(gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取间隙数量
|
||||||
|
*/
|
||||||
|
public int getGapCount() {
|
||||||
|
return gaps.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,50 @@
|
|||||||
package com.ycwl.basic.device.operator;
|
package com.ycwl.basic.device.operator;
|
||||||
|
|
||||||
|
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
|
||||||
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
|
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
|
||||||
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.util.Calendar;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
public abstract class ADeviceStorageOperator implements IDeviceStorageOperator {
|
public abstract class ADeviceStorageOperator implements IDeviceStorageOperator {
|
||||||
@Setter
|
@Setter
|
||||||
protected DeviceEntity device;
|
protected DeviceEntity device;
|
||||||
@Setter
|
@Setter
|
||||||
protected DeviceConfigEntity deviceConfig;
|
protected DeviceConfigEntity deviceConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认实现:不支持视频连续性检查
|
||||||
|
*
|
||||||
|
* @param startDate 开始时间
|
||||||
|
* @param endDate 结束时间
|
||||||
|
* @param maxGapMs 允许的最大间隔时间(毫秒)
|
||||||
|
* @return support=false的结果
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs) {
|
||||||
|
VideoContinuityResult result = new VideoContinuityResult();
|
||||||
|
result.setSupport(false);
|
||||||
|
result.setContinuous(false);
|
||||||
|
result.setTotalVideos(0);
|
||||||
|
result.setTotalDurationMs(0);
|
||||||
|
result.setMaxAllowedGapMs(maxGapMs);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认实现:不支持视频连续性检查
|
||||||
|
*
|
||||||
|
* @return support=false的结果
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public VideoContinuityResult checkRecentVideoContinuity() {
|
||||||
|
Calendar calendar = Calendar.getInstance();
|
||||||
|
calendar.add(Calendar.MINUTE, -2);
|
||||||
|
Date endDate = calendar.getTime();
|
||||||
|
calendar.add(Calendar.MINUTE, -5);
|
||||||
|
Date startDate = calendar.getTime();
|
||||||
|
return checkVideoContinuity(startDate, endDate, 2000L);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.ycwl.basic.device.operator;
|
|||||||
import cn.hutool.core.date.DateUtil;
|
import cn.hutool.core.date.DateUtil;
|
||||||
import com.ycwl.basic.utils.JacksonUtil;
|
import com.ycwl.basic.utils.JacksonUtil;
|
||||||
import com.ycwl.basic.device.entity.common.FileObject;
|
import com.ycwl.basic.device.entity.common.FileObject;
|
||||||
|
import com.ycwl.basic.device.entity.common.VideoContinuityGap;
|
||||||
|
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
|
||||||
import com.ycwl.basic.storage.StorageFactory;
|
import com.ycwl.basic.storage.StorageFactory;
|
||||||
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||||
import com.ycwl.basic.storage.entity.AliOssStorageConfig;
|
import com.ycwl.basic.storage.entity.AliOssStorageConfig;
|
||||||
@@ -98,4 +100,104 @@ public class AliOssStorageOperator extends ADeviceStorageOperator {
|
|||||||
String prefix = dateFormat.format(calendar.getTime());
|
String prefix = dateFormat.format(calendar.getTime());
|
||||||
return removeFilesByPrefix(prefix);
|
return removeFilesByPrefix(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查视频片段的连续性
|
||||||
|
*
|
||||||
|
* @param startDate 开始时间
|
||||||
|
* @param endDate 结束时间
|
||||||
|
* @param maxGapMs 允许的最大间隔时间(毫秒)
|
||||||
|
* @return 包含缺口信息的验证结果
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs) {
|
||||||
|
VideoContinuityResult result = new VideoContinuityResult();
|
||||||
|
result.setSupport(true);
|
||||||
|
result.setMaxAllowedGapMs(maxGapMs);
|
||||||
|
|
||||||
|
// 获取时间范围内的视频列表
|
||||||
|
List<FileObject> fileList = getFileListByDtRange(startDate, endDate);
|
||||||
|
|
||||||
|
if (fileList == null || fileList.isEmpty()) {
|
||||||
|
result.setContinuous(false);
|
||||||
|
result.setTotalVideos(0);
|
||||||
|
result.setTotalDurationMs(0);
|
||||||
|
log.warn("未找到指定时间范围内的视频文件: {} - {}", startDate, endDate);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.setTotalVideos(fileList.size());
|
||||||
|
|
||||||
|
// 只有一个视频文件时,认为是连续的
|
||||||
|
if (fileList.size() == 1) {
|
||||||
|
FileObject file = fileList.get(0);
|
||||||
|
long duration = file.getEndTime().getTime() - file.getCreateTime().getTime();
|
||||||
|
result.setContinuous(true);
|
||||||
|
result.setTotalDurationMs(duration);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查相邻视频之间的间隙
|
||||||
|
long totalDuration = 0;
|
||||||
|
for (int i = 0; i < fileList.size() - 1; i++) {
|
||||||
|
FileObject currentFile = fileList.get(i);
|
||||||
|
FileObject nextFile = fileList.get(i + 1);
|
||||||
|
|
||||||
|
// 计算当前视频的时长
|
||||||
|
totalDuration += currentFile.getEndTime().getTime() - currentFile.getCreateTime().getTime();
|
||||||
|
|
||||||
|
// 计算间隙: 后一个视频的开始时间 - 前一个视频的结束时间
|
||||||
|
long gapMs = nextFile.getCreateTime().getTime() - currentFile.getEndTime().getTime();
|
||||||
|
|
||||||
|
// 如果间隙超过允许值,记录该间隙
|
||||||
|
if (gapMs > maxGapMs) {
|
||||||
|
VideoContinuityGap gap = new VideoContinuityGap();
|
||||||
|
gap.setBeforeFile(currentFile);
|
||||||
|
gap.setAfterFile(nextFile);
|
||||||
|
gap.setGapMs(gapMs);
|
||||||
|
gap.setGapStartTime(currentFile.getEndTime());
|
||||||
|
gap.setGapEndTime(nextFile.getCreateTime());
|
||||||
|
result.addGap(gap);
|
||||||
|
log.debug("检测到视频间隙: {} -> {}, 间隙时长: {}ms",
|
||||||
|
currentFile.getName(), nextFile.getName(), gapMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加上最后一个视频的时长
|
||||||
|
FileObject lastFile = fileList.get(fileList.size() - 1);
|
||||||
|
totalDuration += lastFile.getEndTime().getTime() - lastFile.getCreateTime().getTime();
|
||||||
|
|
||||||
|
result.setTotalDurationMs(totalDuration);
|
||||||
|
result.setContinuous(result.getGapCount() == 0);
|
||||||
|
|
||||||
|
log.info("视频连续性检查完成: 总视频数={}, 总时长={}ms, 间隙数={}, 连续={}",
|
||||||
|
result.getTotalVideos(), result.getTotalDurationMs(), result.getGapCount(), result.isContinuous());
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查近期视频的连续性(测试用)
|
||||||
|
* 时间范围: 当前时间向前2分钟后,再向前10分钟(即前12分钟到前2分钟)
|
||||||
|
* 允许的最大间隙: 2秒
|
||||||
|
*
|
||||||
|
* @return 包含缺口信息的验证结果
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public VideoContinuityResult checkRecentVideoContinuity() {
|
||||||
|
Calendar calendar = Calendar.getInstance();
|
||||||
|
|
||||||
|
// 结束时间: 当前时间 - 2分钟
|
||||||
|
calendar.add(Calendar.MINUTE, -2);
|
||||||
|
Date endDate = calendar.getTime();
|
||||||
|
|
||||||
|
// 开始时间: 当前时间 - 12分钟 (再向前10分钟)
|
||||||
|
calendar.add(Calendar.MINUTE, -10);
|
||||||
|
Date startDate = calendar.getTime();
|
||||||
|
|
||||||
|
log.info("检查近期视频连续性: {} - {}", startDate, endDate);
|
||||||
|
|
||||||
|
// 允许的最大间隙为2秒(2000毫秒)
|
||||||
|
return checkVideoContinuity(startDate, endDate, 2000L);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.ycwl.basic.device.operator;
|
|||||||
|
|
||||||
import com.ycwl.basic.device.IDeviceCommon;
|
import com.ycwl.basic.device.IDeviceCommon;
|
||||||
import com.ycwl.basic.device.entity.common.FileObject;
|
import com.ycwl.basic.device.entity.common.FileObject;
|
||||||
|
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
|
||||||
|
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -19,10 +20,29 @@ public interface IDeviceStorageOperator extends IDeviceCommon {
|
|||||||
List<FileObject> getFileListByDtRange(Date startDate, Date endDate);
|
List<FileObject> getFileListByDtRange(Date startDate, Date endDate);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除指定日期之前的文件,不包含指定的日期当天
|
* 删除指定日期之前的文件,不包含指定的日期当天
|
||||||
*
|
*
|
||||||
* @param date 指定日期,不包含指定日期当天
|
* @param date 指定日期,不包含指定日期当天
|
||||||
* @return
|
* @return
|
||||||
*/
|
*/
|
||||||
boolean removeFilesBeforeDate(Date date);
|
boolean removeFilesBeforeDate(Date date);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查视频片段的连续性
|
||||||
|
*
|
||||||
|
* @param startDate 开始时间
|
||||||
|
* @param endDate 结束时间
|
||||||
|
* @param maxGapMs 允许的最大间隔时间(毫秒)
|
||||||
|
* @return 包含缺口信息的验证结果
|
||||||
|
*/
|
||||||
|
VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查近期视频的连续性(便捷方法)
|
||||||
|
* 时间范围: 当前时间向前2分钟后,再向前5分钟(即前7分钟到前2分钟)
|
||||||
|
* 允许的最大间隙: 2秒
|
||||||
|
*
|
||||||
|
* @return 包含缺口信息的验证结果
|
||||||
|
*/
|
||||||
|
VideoContinuityResult checkRecentVideoContinuity();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.ycwl.basic.device.operator;
|
package com.ycwl.basic.device.operator;
|
||||||
|
|
||||||
import com.ycwl.basic.device.entity.common.FileObject;
|
import com.ycwl.basic.device.entity.common.FileObject;
|
||||||
|
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
|
||||||
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
|
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
|
||||||
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.util.Calendar;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -34,4 +36,24 @@ public class LocalStorageOperator implements IDeviceStorageOperator {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs) {
|
||||||
|
VideoContinuityResult result = new VideoContinuityResult();
|
||||||
|
result.setSupport(false);
|
||||||
|
result.setContinuous(false);
|
||||||
|
result.setTotalVideos(0);
|
||||||
|
result.setTotalDurationMs(0);
|
||||||
|
result.setMaxAllowedGapMs(maxGapMs);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VideoContinuityResult checkRecentVideoContinuity() {
|
||||||
|
Calendar calendar = Calendar.getInstance();
|
||||||
|
calendar.add(Calendar.MINUTE, -2);
|
||||||
|
Date endDate = calendar.getTime();
|
||||||
|
calendar.add(Calendar.MINUTE, -5);
|
||||||
|
Date startDate = calendar.getTime();
|
||||||
|
return checkVideoContinuity(startDate, endDate, 2000L);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package com.ycwl.basic.task;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.ycwl.basic.device.DeviceFactory;
|
||||||
|
import com.ycwl.basic.device.entity.common.DeviceVideoContinuityCache;
|
||||||
|
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
|
||||||
|
import com.ycwl.basic.device.operator.IDeviceStorageOperator;
|
||||||
|
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||||
|
import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO;
|
||||||
|
import com.ycwl.basic.integration.device.service.DeviceIntegrationService;
|
||||||
|
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
|
||||||
|
import com.ycwl.basic.repository.DeviceRepository;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Calendar;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备视频连续性检查定时任务
|
||||||
|
* - 仅在生产环境(prod)运行
|
||||||
|
* - 每5分钟执行一次
|
||||||
|
* - 检查前12分钟到前2分钟的视频连续性
|
||||||
|
* - 仅在9点到18点之间检查
|
||||||
|
* - 结果缓存到Redis中
|
||||||
|
*
|
||||||
|
* @author Claude Code
|
||||||
|
* @date 2025-09-01
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@EnableScheduling
|
||||||
|
@Profile("prod")
|
||||||
|
public class DeviceVideoContinuityCheckTask {
|
||||||
|
|
||||||
|
private static final String REDIS_KEY_PREFIX = "device:video:continuity:";
|
||||||
|
private static final int CACHE_TTL_HOURS = 24; // 缓存24小时
|
||||||
|
private static final int START_HOUR = 9; // 开始检查时间 9:00
|
||||||
|
private static final int END_HOUR = 18; // 结束检查时间 18:00
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DeviceIntegrationService deviceIntegrationService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DeviceRepository deviceRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedisTemplate<String, String> redisTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时任务:每5分钟执行一次
|
||||||
|
* cron表达式: 0 0/5 * * * * 表示每5分钟执行一次
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "0 0/5 * * * *")
|
||||||
|
public void checkDeviceVideoContinuity() {
|
||||||
|
// 检查是否在执行时间范围内(9:00-18:00)
|
||||||
|
int currentHour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
|
||||||
|
if (currentHour < START_HOUR || currentHour >= END_HOUR) {
|
||||||
|
log.debug("当前时间 {}:00 不在检查时间范围内({}:00-{}:00),跳过检查",
|
||||||
|
currentHour, START_HOUR, END_HOUR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("开始执行设备视频连续性检查定时任务");
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取所有激活的设备(分页获取,每次100个)
|
||||||
|
int pageSize = 100;
|
||||||
|
int currentPage = 1;
|
||||||
|
int totalChecked = 0;
|
||||||
|
int successCount = 0;
|
||||||
|
int failureCount = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
PageResponse<DeviceV2DTO> pageResponse = deviceIntegrationService.listDevices(
|
||||||
|
currentPage, pageSize, null, null, null, 1, null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pageResponse == null || pageResponse.getList() == null
|
||||||
|
|| pageResponse.getList().isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查每个设备的视频连续性
|
||||||
|
for (DeviceV2DTO device : pageResponse.getList()) {
|
||||||
|
try {
|
||||||
|
boolean checked = checkSingleDevice(device);
|
||||||
|
totalChecked++;
|
||||||
|
if (checked) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failureCount++;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("检查设备 {} 视频连续性失败: {}", device.getId(), e.getMessage(), e);
|
||||||
|
failureCount++;
|
||||||
|
totalChecked++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否还有更多页
|
||||||
|
int totalPages = (int) Math.ceil((double) pageResponse.getTotal() / pageSize);
|
||||||
|
if (currentPage >= totalPages) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentPage++;
|
||||||
|
}
|
||||||
|
|
||||||
|
long endTime = System.currentTimeMillis();
|
||||||
|
log.info("设备视频连续性检查任务完成: 总计检查 {} 个设备, 成功 {}, 失败 {}, 耗时 {}ms",
|
||||||
|
totalChecked, successCount, failureCount, (endTime - startTime));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("执行设备视频连续性检查定时任务失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查单个设备的视频连续性
|
||||||
|
*
|
||||||
|
* @param device 设备信息
|
||||||
|
* @return true表示检查成功并缓存,false表示跳过检查
|
||||||
|
*/
|
||||||
|
private boolean checkSingleDevice(DeviceV2DTO device) {
|
||||||
|
try {
|
||||||
|
// 获取设备配置
|
||||||
|
DeviceConfigEntity config = deviceRepository.getDeviceConfig(device.getId());
|
||||||
|
if (config == null) {
|
||||||
|
log.debug("设备 {} 没有配置信息,跳过检查", device.getId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取设备的存储操作器
|
||||||
|
IDeviceStorageOperator operator = DeviceFactory.getDeviceStorageOperator(device, config);
|
||||||
|
if (operator == null) {
|
||||||
|
log.debug("设备 {} 没有配置存储操作器,跳过检查", device.getId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算检查时间范围: 当前时间向前12分钟到向前2分钟
|
||||||
|
Calendar calendar = Calendar.getInstance();
|
||||||
|
calendar.add(Calendar.MINUTE, -2);
|
||||||
|
Date endDate = calendar.getTime();
|
||||||
|
|
||||||
|
calendar.add(Calendar.MINUTE, -10); // 再向前10分钟,总共12分钟
|
||||||
|
Date startDate = calendar.getTime();
|
||||||
|
|
||||||
|
// 执行连续性检查(允许2秒间隙)
|
||||||
|
VideoContinuityResult result = operator.checkVideoContinuity(startDate, endDate, 2000L);
|
||||||
|
|
||||||
|
// 创建缓存对象
|
||||||
|
DeviceVideoContinuityCache cache = DeviceVideoContinuityCache.fromResult(
|
||||||
|
device.getId(), result, startDate, endDate
|
||||||
|
);
|
||||||
|
|
||||||
|
// 存储到Redis
|
||||||
|
String redisKey = REDIS_KEY_PREFIX + device.getId();
|
||||||
|
String cacheJson = objectMapper.writeValueAsString(cache);
|
||||||
|
redisTemplate.opsForValue().set(redisKey, cacheJson, CACHE_TTL_HOURS, TimeUnit.HOURS);
|
||||||
|
|
||||||
|
log.info("设备 {} 视频连续性检查完成: support={}, continuous={}, videos={}, gaps={}, duration={}ms",
|
||||||
|
device.getId(), result.isSupport(), result.isContinuous(),
|
||||||
|
result.getTotalVideos(), result.getGapCount(), result.getTotalDurationMs());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("检查设备 {} 视频连续性失败", device.getId(), e);
|
||||||
|
throw new RuntimeException("检查设备视频连续性失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动触发检查(用于测试)
|
||||||
|
*
|
||||||
|
* @param deviceId 设备ID
|
||||||
|
* @return 检查结果
|
||||||
|
*/
|
||||||
|
public DeviceVideoContinuityCache manualCheck(Long deviceId) {
|
||||||
|
log.info("手动触发设备 {} 的视频连续性检查", deviceId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取设备信息
|
||||||
|
DeviceV2DTO device = deviceIntegrationService.getDevice(deviceId);
|
||||||
|
if (device == null) {
|
||||||
|
throw new RuntimeException("设备不存在: " + deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查设备
|
||||||
|
checkSingleDevice(device);
|
||||||
|
|
||||||
|
// 从Redis获取结果
|
||||||
|
String redisKey = REDIS_KEY_PREFIX + deviceId;
|
||||||
|
String cacheJson = redisTemplate.opsForValue().get(redisKey);
|
||||||
|
if (cacheJson == null) {
|
||||||
|
throw new RuntimeException("检查完成但未找到缓存结果");
|
||||||
|
}
|
||||||
|
|
||||||
|
return objectMapper.readValue(cacheJson, DeviceVideoContinuityCache.class);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("手动检查设备 {} 视频连续性失败", deviceId, e);
|
||||||
|
throw new RuntimeException("手动检查失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
package com.ycwl.basic.device.operator;
|
||||||
|
|
||||||
|
import com.ycwl.basic.device.entity.common.FileObject;
|
||||||
|
import com.ycwl.basic.device.entity.common.VideoContinuityGap;
|
||||||
|
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
|
||||||
|
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||||
|
import com.ycwl.basic.storage.entity.StorageFileObject;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Calendar;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@DisplayName("AliOssStorageOperator视频连续性检查测试")
|
||||||
|
class AliOssStorageOperatorTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IStorageAdapter mockAdapter;
|
||||||
|
|
||||||
|
private AliOssStorageOperator operator;
|
||||||
|
|
||||||
|
private static final String TEST_CONFIG_JSON = "{\"endpoint\":\"oss-cn-hangzhou.aliyuncs.com\","
|
||||||
|
+ "\"accessKeyId\":\"test-key\",\"accessKeySecret\":\"test-secret\","
|
||||||
|
+ "\"bucketName\":\"test-bucket\",\"prefix\":\"test/\"}";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
operator = new AliOssStorageOperator(TEST_CONFIG_JSON);
|
||||||
|
// 通过反射注入mock的adapter
|
||||||
|
Field adapterField = AliOssStorageOperator.class.getDeclaredField("adapter");
|
||||||
|
adapterField.setAccessible(true);
|
||||||
|
adapterField.set(operator, mockAdapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("视频完全连续-无间隙")
|
||||||
|
void testCheckVideoContinuity_Continuous() throws Exception {
|
||||||
|
// Given: 创建连续的视频文件列表
|
||||||
|
Date baseTime = new Date();
|
||||||
|
List<StorageFileObject> mockStorageObjects = createMockStorageObjects(baseTime, new int[][] {
|
||||||
|
{0, 10}, // 0-10秒
|
||||||
|
{10, 20}, // 10-20秒(完美衔接)
|
||||||
|
{20, 30}, // 20-30秒(完美衔接)
|
||||||
|
{30, 40} // 30-40秒(完美衔接)
|
||||||
|
});
|
||||||
|
|
||||||
|
when(mockAdapter.listDir(anyString())).thenReturn(mockStorageObjects);
|
||||||
|
when(mockAdapter.getUrlForDownload(anyString())).thenReturn("http://test.com/video.ts");
|
||||||
|
|
||||||
|
// When: 检查视频连续性
|
||||||
|
Calendar cal = Calendar.getInstance();
|
||||||
|
cal.setTime(baseTime);
|
||||||
|
Date startDate = cal.getTime();
|
||||||
|
cal.add(Calendar.SECOND, 50);
|
||||||
|
Date endDate = cal.getTime();
|
||||||
|
|
||||||
|
VideoContinuityResult result = operator.checkVideoContinuity(startDate, endDate, 2000L);
|
||||||
|
|
||||||
|
// Then: 应该是连续的
|
||||||
|
assertTrue(result.isSupport(), "应该支持连续性检查");
|
||||||
|
assertTrue(result.isContinuous(), "视频应该是连续的");
|
||||||
|
assertEquals(0, result.getGapCount(), "不应该有间隙");
|
||||||
|
assertEquals(4, result.getTotalVideos(), "应该有4个视频");
|
||||||
|
assertEquals(40000L, result.getTotalDurationMs(), "总时长应该是40秒");
|
||||||
|
log.info("测试通过: 视频完全连续");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("视频有小间隙-在允许范围内")
|
||||||
|
void testCheckVideoContinuity_WithSmallGaps() throws Exception {
|
||||||
|
// Given: 创建有小间隙的视频文件列表(间隙1秒)
|
||||||
|
Date baseTime = new Date();
|
||||||
|
List<StorageFileObject> mockStorageObjects = createMockStorageObjects(baseTime, new int[][] {
|
||||||
|
{0, 10}, // 0-10秒
|
||||||
|
{11, 21}, // 11-21秒(1秒间隙)
|
||||||
|
{22, 32}, // 22-32秒(1秒间隙)
|
||||||
|
{33, 43} // 33-43秒(1秒间隙)
|
||||||
|
});
|
||||||
|
|
||||||
|
when(mockAdapter.listDir(anyString())).thenReturn(mockStorageObjects);
|
||||||
|
when(mockAdapter.getUrlForDownload(anyString())).thenReturn("http://test.com/video.ts");
|
||||||
|
|
||||||
|
// When: 检查视频连续性(允许2秒间隙)
|
||||||
|
Calendar cal = Calendar.getInstance();
|
||||||
|
cal.setTime(baseTime);
|
||||||
|
Date startDate = cal.getTime();
|
||||||
|
cal.add(Calendar.SECOND, 50);
|
||||||
|
Date endDate = cal.getTime();
|
||||||
|
|
||||||
|
VideoContinuityResult result = operator.checkVideoContinuity(startDate, endDate, 2000L);
|
||||||
|
|
||||||
|
// Then: 应该是连续的(间隙在允许范围内)
|
||||||
|
assertTrue(result.isContinuous(), "视频应该是连续的(间隙在允许范围内)");
|
||||||
|
assertEquals(0, result.getGapCount(), "不应该有超出允许范围的间隙");
|
||||||
|
assertEquals(4, result.getTotalVideos(), "应该有4个视频");
|
||||||
|
log.info("测试通过: 小间隙在允许范围内");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("视频有大间隙-超出允许范围")
|
||||||
|
void testCheckVideoContinuity_WithLargeGaps() throws Exception {
|
||||||
|
// Given: 创建有大间隙的视频文件列表(间隙5秒)
|
||||||
|
Date baseTime = new Date();
|
||||||
|
List<StorageFileObject> mockStorageObjects = createMockStorageObjects(baseTime, new int[][] {
|
||||||
|
{0, 10}, // 0-10秒
|
||||||
|
{15, 25}, // 15-25秒(5秒间隙)
|
||||||
|
{30, 40}, // 30-40秒(5秒间隙)
|
||||||
|
{45, 55} // 45-55秒(5秒间隙)
|
||||||
|
});
|
||||||
|
|
||||||
|
when(mockAdapter.listDir(anyString())).thenReturn(mockStorageObjects);
|
||||||
|
when(mockAdapter.getUrlForDownload(anyString())).thenReturn("http://test.com/video.ts");
|
||||||
|
|
||||||
|
// When: 检查视频连续性(允许2秒间隙)
|
||||||
|
Calendar cal = Calendar.getInstance();
|
||||||
|
cal.setTime(baseTime);
|
||||||
|
Date startDate = cal.getTime();
|
||||||
|
cal.add(Calendar.SECOND, 60);
|
||||||
|
Date endDate = cal.getTime();
|
||||||
|
|
||||||
|
VideoContinuityResult result = operator.checkVideoContinuity(startDate, endDate, 2000L);
|
||||||
|
|
||||||
|
// Then: 应该不连续(有3个超出允许范围的间隙)
|
||||||
|
assertFalse(result.isContinuous(), "视频应该不连续");
|
||||||
|
assertEquals(3, result.getGapCount(), "应该有3个间隙");
|
||||||
|
assertEquals(4, result.getTotalVideos(), "应该有4个视频");
|
||||||
|
|
||||||
|
// 验证间隙信息
|
||||||
|
List<VideoContinuityGap> gaps = result.getGaps();
|
||||||
|
for (VideoContinuityGap gap : gaps) {
|
||||||
|
assertEquals(5000L, gap.getGapMs(), "每个间隙应该是5秒");
|
||||||
|
assertNotNull(gap.getBeforeFile(), "应该有前一个文件");
|
||||||
|
assertNotNull(gap.getAfterFile(), "应该有后一个文件");
|
||||||
|
assertNotNull(gap.getGapStartTime(), "应该有间隙开始时间");
|
||||||
|
assertNotNull(gap.getGapEndTime(), "应该有间隙结束时间");
|
||||||
|
}
|
||||||
|
log.info("测试通过: 检测到大间隙");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("空文件列表")
|
||||||
|
void testCheckVideoContinuity_EmptyList() throws Exception {
|
||||||
|
// Given: 返回空列表
|
||||||
|
when(mockAdapter.listDir(anyString())).thenReturn(new ArrayList<>());
|
||||||
|
|
||||||
|
// When: 检查视频连续性
|
||||||
|
Date startDate = new Date();
|
||||||
|
Calendar cal = Calendar.getInstance();
|
||||||
|
cal.add(Calendar.MINUTE, 10);
|
||||||
|
Date endDate = cal.getTime();
|
||||||
|
|
||||||
|
VideoContinuityResult result = operator.checkVideoContinuity(startDate, endDate, 2000L);
|
||||||
|
|
||||||
|
// Then: 应该返回不连续(没有视频)
|
||||||
|
assertTrue(result.isSupport(), "应该支持连续性检查");
|
||||||
|
assertFalse(result.isContinuous(), "空列表应该返回不连续");
|
||||||
|
assertEquals(0, result.getGapCount(), "不应该有间隙");
|
||||||
|
assertEquals(0, result.getTotalVideos(), "视频数应该是0");
|
||||||
|
assertEquals(0L, result.getTotalDurationMs(), "总时长应该是0");
|
||||||
|
log.info("测试通过: 空文件列表");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("单个视频文件")
|
||||||
|
void testCheckVideoContinuity_SingleVideo() throws Exception {
|
||||||
|
// Given: 只有一个视频文件
|
||||||
|
Date baseTime = new Date();
|
||||||
|
List<StorageFileObject> mockStorageObjects = createMockStorageObjects(baseTime, new int[][] {
|
||||||
|
{0, 10} // 0-10秒
|
||||||
|
});
|
||||||
|
|
||||||
|
when(mockAdapter.listDir(anyString())).thenReturn(mockStorageObjects);
|
||||||
|
when(mockAdapter.getUrlForDownload(anyString())).thenReturn("http://test.com/video.ts");
|
||||||
|
|
||||||
|
// When: 检查视频连续性
|
||||||
|
Calendar cal = Calendar.getInstance();
|
||||||
|
cal.setTime(baseTime);
|
||||||
|
Date startDate = cal.getTime();
|
||||||
|
cal.add(Calendar.SECOND, 20);
|
||||||
|
Date endDate = cal.getTime();
|
||||||
|
|
||||||
|
VideoContinuityResult result = operator.checkVideoContinuity(startDate, endDate, 2000L);
|
||||||
|
|
||||||
|
// Then: 应该是连续的
|
||||||
|
assertTrue(result.isSupport(), "应该支持连续性检查");
|
||||||
|
assertTrue(result.isContinuous(), "单个视频应该是连续的");
|
||||||
|
assertEquals(0, result.getGapCount(), "不应该有间隙");
|
||||||
|
assertEquals(1, result.getTotalVideos(), "应该有1个视频");
|
||||||
|
assertEquals(10000L, result.getTotalDurationMs(), "总时长应该是10秒");
|
||||||
|
log.info("测试通过: 单个视频文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("使用真实时间范围-前7分钟到前2分钟")
|
||||||
|
void testCheckVideoContinuity_RealTimeRange() throws Exception {
|
||||||
|
// Given: 创建前7分钟到前2分钟的视频文件
|
||||||
|
Calendar cal = Calendar.getInstance();
|
||||||
|
cal.add(Calendar.MINUTE, -7);
|
||||||
|
Date baseTime = cal.getTime();
|
||||||
|
|
||||||
|
// 创建5分钟的连续视频(每10秒一个片段)
|
||||||
|
List<int[]> timeRanges = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 30; i++) {
|
||||||
|
int start = i * 10;
|
||||||
|
int end = start + 10;
|
||||||
|
timeRanges.add(new int[]{start, end});
|
||||||
|
}
|
||||||
|
|
||||||
|
List<StorageFileObject> mockStorageObjects = createMockStorageObjects(
|
||||||
|
baseTime,
|
||||||
|
timeRanges.toArray(new int[0][])
|
||||||
|
);
|
||||||
|
|
||||||
|
when(mockAdapter.listDir(anyString())).thenReturn(mockStorageObjects);
|
||||||
|
when(mockAdapter.getUrlForDownload(anyString())).thenReturn("http://test.com/video.ts");
|
||||||
|
|
||||||
|
// When: 检查近期视频连续性
|
||||||
|
VideoContinuityResult result = operator.checkRecentVideoContinuity();
|
||||||
|
|
||||||
|
// Then: 验证结果
|
||||||
|
assertNotNull(result, "结果不应该为空");
|
||||||
|
assertEquals(30, result.getTotalVideos(), "应该有30个视频片段");
|
||||||
|
assertTrue(result.isContinuous(), "视频应该是连续的");
|
||||||
|
assertEquals(300000L, result.getTotalDurationMs(), "总时长应该是300秒(5分钟)");
|
||||||
|
assertEquals(2000L, result.getMaxAllowedGapMs(), "允许的最大间隙应该是2秒");
|
||||||
|
log.info("测试通过: 真实时间范围检查 - 视频数: {}, 总时长: {}ms, 连续: {}",
|
||||||
|
result.getTotalVideos(), result.getTotalDurationMs(), result.isContinuous());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("混合场景-既有小间隙又有大间隙")
|
||||||
|
void testCheckVideoContinuity_MixedGaps() throws Exception {
|
||||||
|
// Given: 创建混合间隙的视频文件列表
|
||||||
|
Date baseTime = new Date();
|
||||||
|
List<StorageFileObject> mockStorageObjects = createMockStorageObjects(baseTime, new int[][] {
|
||||||
|
{0, 10}, // 0-10秒
|
||||||
|
{11, 21}, // 11-21秒(1秒间隙,允许)
|
||||||
|
{22, 32}, // 22-32秒(1秒间隙,允许)
|
||||||
|
{37, 47}, // 37-47秒(5秒间隙,超出)
|
||||||
|
{48, 58}, // 48-58秒(1秒间隙,允许)
|
||||||
|
{63, 73} // 63-73秒(5秒间隙,超出)
|
||||||
|
});
|
||||||
|
|
||||||
|
when(mockAdapter.listDir(anyString())).thenReturn(mockStorageObjects);
|
||||||
|
when(mockAdapter.getUrlForDownload(anyString())).thenReturn("http://test.com/video.ts");
|
||||||
|
|
||||||
|
// When: 检查视频连续性(允许2秒间隙)
|
||||||
|
Calendar cal = Calendar.getInstance();
|
||||||
|
cal.setTime(baseTime);
|
||||||
|
Date startDate = cal.getTime();
|
||||||
|
cal.add(Calendar.SECOND, 80);
|
||||||
|
Date endDate = cal.getTime();
|
||||||
|
|
||||||
|
VideoContinuityResult result = operator.checkVideoContinuity(startDate, endDate, 2000L);
|
||||||
|
|
||||||
|
// Then: 应该检测到2个大间隙
|
||||||
|
assertTrue(result.isSupport(), "应该支持连续性检查");
|
||||||
|
assertFalse(result.isContinuous(), "视频应该不连续");
|
||||||
|
assertEquals(2, result.getGapCount(), "应该有2个超出允许范围的间隙");
|
||||||
|
assertEquals(6, result.getTotalVideos(), "应该有6个视频");
|
||||||
|
|
||||||
|
// 验证第一个大间隙
|
||||||
|
VideoContinuityGap firstGap = result.getGaps().get(0);
|
||||||
|
assertEquals(5000L, firstGap.getGapMs(), "第一个间隙应该是5秒");
|
||||||
|
|
||||||
|
// 验证第二个大间隙
|
||||||
|
VideoContinuityGap secondGap = result.getGaps().get(1);
|
||||||
|
assertEquals(5000L, secondGap.getGapMs(), "第二个间隙应该是5秒");
|
||||||
|
|
||||||
|
log.info("测试通过: 混合场景检测 - 总间隙: {}, 小间隙被忽略, 大间隙被检测", result.getGapCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("测试不支持的实现类返回support=false")
|
||||||
|
void testUnsupportedOperator() {
|
||||||
|
// Given: 使用不支持连续性检查的操作器
|
||||||
|
LocalStorageOperator localOperator = new LocalStorageOperator("{}");
|
||||||
|
|
||||||
|
// When: 调用检查方法
|
||||||
|
VideoContinuityResult result = localOperator.checkRecentVideoContinuity();
|
||||||
|
|
||||||
|
// Then: 应该返回support=false
|
||||||
|
assertFalse(result.isSupport(), "LocalStorageOperator不应该支持连续性检查");
|
||||||
|
assertFalse(result.isContinuous(), "不支持时应该返回不连续");
|
||||||
|
assertEquals(0, result.getTotalVideos(), "视频数应该是0");
|
||||||
|
assertEquals(0, result.getGapCount(), "间隙数应该是0");
|
||||||
|
log.info("测试通过: 不支持的实现类正确返回support=false");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建模拟的存储文件对象列表
|
||||||
|
*
|
||||||
|
* @param baseTime 基准时间
|
||||||
|
* @param timeRanges 时间范围数组,每个元素是[开始秒数, 结束秒数]
|
||||||
|
* @return 存储文件对象列表
|
||||||
|
*/
|
||||||
|
private List<StorageFileObject> createMockStorageObjects(Date baseTime, int[][] timeRanges) {
|
||||||
|
List<StorageFileObject> objects = new ArrayList<>();
|
||||||
|
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd");
|
||||||
|
SimpleDateFormat timeFormat = new SimpleDateFormat("HHmmss");
|
||||||
|
|
||||||
|
String datePrefix = dateFormat.format(baseTime);
|
||||||
|
Calendar cal = Calendar.getInstance();
|
||||||
|
cal.setTime(baseTime);
|
||||||
|
|
||||||
|
for (int[] range : timeRanges) {
|
||||||
|
int startSeconds = range[0];
|
||||||
|
int endSeconds = range[1];
|
||||||
|
|
||||||
|
// 计算开始和结束时间
|
||||||
|
cal.setTime(baseTime);
|
||||||
|
cal.add(Calendar.SECOND, startSeconds);
|
||||||
|
String startTime = timeFormat.format(cal.getTime());
|
||||||
|
|
||||||
|
cal.setTime(baseTime);
|
||||||
|
cal.add(Calendar.SECOND, endSeconds);
|
||||||
|
String endTime = timeFormat.format(cal.getTime());
|
||||||
|
|
||||||
|
// 创建文件名: 开始时间_结束时间.ts
|
||||||
|
String fileName = startTime + "_" + endTime + ".ts";
|
||||||
|
String path = datePrefix;
|
||||||
|
|
||||||
|
StorageFileObject obj = new StorageFileObject();
|
||||||
|
obj.setPath(path);
|
||||||
|
obj.setName(fileName);
|
||||||
|
obj.setSize(1024L * 100L); // 假设每个文件100KB
|
||||||
|
objects.add(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
return objects;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user