diff --git a/src/main/java/com/ycwl/basic/controller/pc/DeviceVideoContinuityController.java b/src/main/java/com/ycwl/basic/controller/pc/DeviceVideoContinuityController.java new file mode 100644 index 00000000..cb9203f0 --- /dev/null +++ b/src/main/java/com/ycwl/basic/controller/pc/DeviceVideoContinuityController.java @@ -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 redisTemplate; + private final ObjectMapper objectMapper; + private final DeviceVideoContinuityCheckTask checkTask; + + /** + * 查询设备最近的视频连续性检查结果 + * + * @param deviceId 设备ID + * @return 检查结果 + */ + @GetMapping("/{deviceId}") + public ApiResponse 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 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 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()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/device/entity/common/DeviceVideoContinuityCache.java b/src/main/java/com/ycwl/basic/device/entity/common/DeviceVideoContinuityCache.java new file mode 100644 index 00000000..6dde8f02 --- /dev/null +++ b/src/main/java/com/ycwl/basic/device/entity/common/DeviceVideoContinuityCache.java @@ -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 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 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; + } +} diff --git a/src/main/java/com/ycwl/basic/device/entity/common/VideoContinuityGap.java b/src/main/java/com/ycwl/basic/device/entity/common/VideoContinuityGap.java new file mode 100644 index 00000000..1d5e305d --- /dev/null +++ b/src/main/java/com/ycwl/basic/device/entity/common/VideoContinuityGap.java @@ -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; +} diff --git a/src/main/java/com/ycwl/basic/device/entity/common/VideoContinuityResult.java b/src/main/java/com/ycwl/basic/device/entity/common/VideoContinuityResult.java new file mode 100644 index 00000000..fda1d030 --- /dev/null +++ b/src/main/java/com/ycwl/basic/device/entity/common/VideoContinuityResult.java @@ -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 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(); + } +} diff --git a/src/main/java/com/ycwl/basic/device/operator/ADeviceStorageOperator.java b/src/main/java/com/ycwl/basic/device/operator/ADeviceStorageOperator.java index 3657aa7b..e466a85e 100644 --- a/src/main/java/com/ycwl/basic/device/operator/ADeviceStorageOperator.java +++ b/src/main/java/com/ycwl/basic/device/operator/ADeviceStorageOperator.java @@ -1,12 +1,50 @@ 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.DeviceEntity; import lombok.Setter; +import java.util.Calendar; +import java.util.Date; + public abstract class ADeviceStorageOperator implements IDeviceStorageOperator { @Setter protected DeviceEntity device; @Setter 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); + } } diff --git a/src/main/java/com/ycwl/basic/device/operator/AliOssStorageOperator.java b/src/main/java/com/ycwl/basic/device/operator/AliOssStorageOperator.java index faa01352..823c2098 100644 --- a/src/main/java/com/ycwl/basic/device/operator/AliOssStorageOperator.java +++ b/src/main/java/com/ycwl/basic/device/operator/AliOssStorageOperator.java @@ -3,6 +3,8 @@ package com.ycwl.basic.device.operator; import cn.hutool.core.date.DateUtil; import com.ycwl.basic.utils.JacksonUtil; 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.adapters.IStorageAdapter; import com.ycwl.basic.storage.entity.AliOssStorageConfig; @@ -98,4 +100,104 @@ public class AliOssStorageOperator extends ADeviceStorageOperator { String prefix = dateFormat.format(calendar.getTime()); 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 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); + } } diff --git a/src/main/java/com/ycwl/basic/device/operator/IDeviceStorageOperator.java b/src/main/java/com/ycwl/basic/device/operator/IDeviceStorageOperator.java index 434961af..739661a9 100644 --- a/src/main/java/com/ycwl/basic/device/operator/IDeviceStorageOperator.java +++ b/src/main/java/com/ycwl/basic/device/operator/IDeviceStorageOperator.java @@ -2,6 +2,7 @@ package com.ycwl.basic.device.operator; import com.ycwl.basic.device.IDeviceCommon; import com.ycwl.basic.device.entity.common.FileObject; +import com.ycwl.basic.device.entity.common.VideoContinuityResult; import java.util.Date; import java.util.List; @@ -19,10 +20,29 @@ public interface IDeviceStorageOperator extends IDeviceCommon { List getFileListByDtRange(Date startDate, Date endDate); /** - * 删除指定日期之前的文件,不包含指定的日期当天 + * 删除指定日期之前的文件,不包含指定的日期当天 * - * @param date 指定日期,不包含指定日期当天 + * @param date 指定日期,不包含指定日期当天 * @return */ 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(); } diff --git a/src/main/java/com/ycwl/basic/device/operator/LocalStorageOperator.java b/src/main/java/com/ycwl/basic/device/operator/LocalStorageOperator.java index 7dc7dfa0..21ca02c4 100644 --- a/src/main/java/com/ycwl/basic/device/operator/LocalStorageOperator.java +++ b/src/main/java/com/ycwl/basic/device/operator/LocalStorageOperator.java @@ -1,10 +1,12 @@ package com.ycwl.basic.device.operator; 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.DeviceEntity; import lombok.Setter; +import java.util.Calendar; import java.util.Collections; import java.util.Date; import java.util.List; @@ -34,4 +36,24 @@ public class LocalStorageOperator implements IDeviceStorageOperator { 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); + } } diff --git a/src/main/java/com/ycwl/basic/task/DeviceVideoContinuityCheckTask.java b/src/main/java/com/ycwl/basic/task/DeviceVideoContinuityCheckTask.java new file mode 100644 index 00000000..60847411 --- /dev/null +++ b/src/main/java/com/ycwl/basic/task/DeviceVideoContinuityCheckTask.java @@ -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 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 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); + } + } +} diff --git a/src/test/java/com/ycwl/basic/device/operator/AliOssStorageOperatorTest.java b/src/test/java/com/ycwl/basic/device/operator/AliOssStorageOperatorTest.java new file mode 100644 index 00000000..5ca2f5e9 --- /dev/null +++ b/src/test/java/com/ycwl/basic/device/operator/AliOssStorageOperatorTest.java @@ -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 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 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 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 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 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 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 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 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 createMockStorageObjects(Date baseTime, int[][] timeRanges) { + List 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; + } +}