视频融合平台服务端录像功能实现方案

一、服务端录像计划配置

1. 录像计划管理

(1) 录像计划数据结构设计
// 录像计划实体类
public class RecordPlan {
    private Long id;
    private String planName;          // 计划名称
    private String deviceId;          // 设备ID
    private Integer recordType;       // 录像类型:0-定时,1-事件触发,2-全天
    private String timeSections;      // 时间段配置,JSON格式
    private Integer storageDays;      // 存储天数
    private String storagePath;      // 存储路径
    private Integer status;           // 状态:0-禁用,1-启用
    private Date createTime;
    // getters & setters...
}

// 时间段配置示例
[
    {"start":"09:00:00", "end":"12:00:00"},
    {"start":"14:00:00", "end":"18:00:00"}
]

在这里插入图片描述

(2) 录像计划API接口
@RestController
@RequestMapping("/api/record/plan")
public class RecordPlanController {
    
    @PostMapping
    public Result createPlan(@RequestBody RecordPlan plan) {
        // 创建录像计划
    }
    
    @PutMapping("/{id}")
    public Result updatePlan(@PathVariable Long id, @RequestBody RecordPlan plan) {
        // 更新录像计划
    }
    
    @GetMapping("/{id}")
    public Result getPlan(@PathVariable Long id) {
        // 获取单个计划详情
    }
    
    @GetMapping("/list")
    public Result listPlans(@RequestParam Map<String, Object> params) {
        // 分页查询录像计划
    }
    
    @PostMapping("/{id}/status")
    public Result changeStatus(@PathVariable Long id, @RequestParam Integer status) {
        // 启用/禁用录像计划
    }
}

2. 录像任务调度实现

(1) 基于Quartz的调度配置
public class RecordJobScheduler {
    
    @Autowired
    private Scheduler scheduler;
    
    public void scheduleRecordJob(RecordPlan plan) {
        JobDetail jobDetail = JobBuilder.newJob(RecordJob.class)
            .withIdentity("record_" + plan.getDeviceId())
            .usingJobData("planId", plan.getId())
            .build();
        
        // 构建Cron表达式(根据时间段配置动态生成)
        String cronExpression = generateCronExpression(plan.getTimeSections());
        
        Trigger trigger = TriggerBuilder.newTrigger()
            .withIdentity("trigger_" + plan.getDeviceId())
            .withSchedule(CronScheduleBuilder.cronSchedule(cronExpression))
            .build();
        
        scheduler.scheduleJob(jobDetail, trigger);
    }
    
    private String generateCronExpression(String timeSections) {
        // 将时间段配置转换为Quartz Cron表达式
        // 示例实现...
    }
}
(2) 录像任务执行实现
public class RecordJob implements Job {
    
    @Override
    public void execute(JobExecutionContext context) {
        Long planId = context.getJobDetail().getJobDataMap().getLong("planId");
        RecordPlan plan = recordPlanService.getById(planId);
        
        // 开始录像
        String outputPath = generateRecordFilePath(plan);
        startRecording(plan.getDeviceId(), outputPath);
        
        // 记录录像信息
        RecordHistory history = new RecordHistory();
        history.setPlanId(planId);
        history.setStartTime(new Date());
        history.setFilePath(outputPath);
        recordHistoryService.save(history);
    }
    
    private String generateRecordFilePath(RecordPlan plan) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss");
        String timestamp = sdf.format(new Date());
        return plan.getStoragePath() + "/" + plan.getDeviceId() + "_" + timestamp + ".mp4";
    }
}

二、录像时段查询功能

1. 数据库设计

CREATE TABLE `record_history` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `plan_id` bigint DEFAULT NULL COMMENT '关联的计划ID',
  `device_id` varchar(64) NOT NULL COMMENT '设备ID',
  `start_time` datetime NOT NULL COMMENT '开始时间',
  `end_time` datetime DEFAULT NULL COMMENT '结束时间',
  `file_path` varchar(255) NOT NULL COMMENT '文件存储路径',
  `file_size` bigint DEFAULT '0' COMMENT '文件大小(字节)',
  `duration` int DEFAULT '0' COMMENT '录像时长(秒)',
  `status` tinyint DEFAULT '0' COMMENT '状态:0-录制中,1-已完成,2-异常结束',
  PRIMARY KEY (`id`),
  KEY `idx_device_time` (`device_id`,`start_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='录像历史记录表';

2. 时段查询接口实现

@GetMapping("/history")
public Result queryRecordHistory(
    @RequestParam String deviceId,
    @RequestParam @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss") Date startTime,
    @RequestParam @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss") Date endTime,
    @RequestParam(defaultValue="1") Integer pageNum,
    @RequestParam(defaultValue="10") Integer pageSize) {
    
    Page<RecordHistory> page = new Page<>(pageNum, pageSize);
    LambdaQueryWrapper<RecordHistory> query = Wrappers.lambdaQuery();
    query.eq(RecordHistory::getDeviceId, deviceId)
         .ge(RecordHistory::getStartTime, startTime)
         .le(RecordHistory::getEndTime, endTime)
         .orderByDesc(RecordHistory::getStartTime);
    
    return Result.success(recordHistoryService.page(page, query));
}

3. 前端时间轴展示

<template>
  <div class="timeline-container">
    <div class="timeline-header">
      <el-date-picker v-model="queryDate" type="date" @change="loadData"/>
    </div>
    <div class="timeline-body">
      <div v-for="item in recordList" :key="item.id" class="timeline-item">
        <div class="time-range">
          {{ formatTime(item.startTime) }} - {{ formatTime(item.endTime) }}
        </div>
        <div class="record-info">
          <span>时长: {{ formatDuration(item.duration) }}</span>
          <el-button size="mini" @click="playRecord(item)">播放</el-button>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      queryDate: new Date(),
      recordList: []
    }
  },
  methods: {
    async loadData() {
      const start = new Date(this.queryDate.setHours(0, 0, 0, 0));
      const end = new Date(this.queryDate.setHours(23, 59, 59, 999));
      
      const res = await this.$http.get('/api/record/history', {
        params: {
          deviceId: this.deviceId,
          startTime: start.toISOString(),
          endTime: end.toISOString()
        }
      });
      this.recordList = res.data.records;
    },
    formatTime(time) {
      return dayjs(time).format('HH:mm:ss');
    },
    formatDuration(seconds) {
      // 格式化显示时长
    },
    playRecord(item) {
      this.$emit('play', item.filePath);
    }
  }
}
</script>

三、录像检索与回放功能

1. 录像检索接口

@GetMapping("/search")
public Result searchRecords(
    @RequestParam(required = false) String deviceId,
    @RequestParam(required = false) String planName,
    @RequestParam(required = false) @DateTimeFormat(pattern="yyyy-MM-dd") Date date,
    @RequestParam(defaultValue="1") Integer pageNum,
    @RequestParam(defaultValue="10") Integer pageSize) {
    
    Page<RecordHistory> page = new Page<>(pageNum, pageSize);
    LambdaQueryWrapper<RecordHistory> query = Wrappers.lambdaQuery();
    
    if (StringUtils.isNotBlank(deviceId)) {
        query.eq(RecordHistory::getDeviceId, deviceId);
    }
    
    if (date != null) {
        query.ge(RecordHistory::getStartTime, date)
             .le(RecordHistory::getStartTime, 
                 Date.from(date.toInstant().plus(1, ChronoUnit.DAYS)));
    }
    
    if (StringUtils.isNotBlank(planName)) {
        query.inSql(RecordHistory::getPlanId, 
                   "SELECT id FROM record_plan WHERE plan_name LIKE '%" + planName + "%'");
    }
    
    query.orderByDesc(RecordHistory::getStartTime);
    
    return Result.success(recordHistoryService.page(page, query));
}

2. 录像回放实现

(1) 后端视频流处理
@GetMapping("/play/{id}")
public void playRecord(@PathVariable Long id, HttpServletResponse response) {
    RecordHistory record = recordHistoryService.getById(id);
    if (record == null || !new File(record.getFilePath()).exists()) {
        throw new RuntimeException("录像文件不存在");
    }
    
    response.setContentType("video/mp4");
    try (InputStream is = new FileInputStream(record.getFilePath());
         OutputStream os = response.getOutputStream()) {
        byte[] buffer = new byte[1024];
        int length;
        while ((length = is.read(buffer)) > 0) {
            os.write(buffer, 0, length);
        }
    }
}

// 支持范围请求的实现(大文件分片传输)
@GetMapping("/stream/{id}")
public void streamRecord(@PathVariable Long id, 
                       HttpServletRequest request,
                       HttpServletResponse response) {
    // 实现HTTP Range请求处理
    // ...
}
(2) 前端播放器集成
<template>
  <div class="player-container">
    <video ref="videoPlayer" controls class="video-js vjs-big-play-centered">
      <source :src="videoUrl" type="video/mp4">
    </video>
  </div>
</template>

<script>
import videojs from 'video.js';
import 'video.js/dist/video-js.css';

export default {
  props: ['recordId'],
  data() {
    return {
      player: null
    }
  },
  computed: {
    videoUrl() {
      return `/api/record/play/${this.recordId}`;
    }
  },
  mounted() {
    this.initPlayer();
  },
  beforeDestroy() {
    if (this.player) {
      this.player.dispose();
    }
  },
  methods: {
    initPlayer() {
      this.player = videojs(this.$refs.videoPlayer, {
        controls: true,
        autoplay: false,
        preload: 'auto',
        playbackRates: [0.5, 1, 1.5, 2]
      });
      
      // 添加时间轴标记(录像时段)
      if (this.recordMarks) {
        this.player.markers({
          markers: this.recordMarks.map(mark => ({
            time: mark.time,
            text: mark.text,
            class: mark.class
          }))
        });
      }
    }
  }
}
</script>

3. 录像剪辑与下载

@PostMapping("/clip")
public Result clipRecord(@RequestBody ClipRequest request) {
    // 参数校验
    RecordHistory record = recordHistoryService.getById(request.getRecordId());
    File sourceFile = new File(record.getFilePath());
    
    // 生成剪辑文件路径
    String clipPath = generateClipPath(record, request.getStart(), request.getEnd());
    
    // 使用FFmpeg进行视频剪辑
    String cmd = String.format("ffmpeg -i %s -ss %s -to %s -c copy %s",
        sourceFile.getAbsolutePath(),
        formatTime(request.getStart()),
        formatTime(request.getEnd()),
        clipPath);
    
    try {
        Process process = Runtime.getRuntime().exec(cmd);
        process.waitFor();
        
        // 保存剪辑记录
        RecordClip clip = new RecordClip();
        clip.setRecordId(request.getRecordId());
        clip.setClipPath(clipPath);
        clip.setStartTime(request.getStart());
        clip.setEndTime(request.getEnd());
        recordClipService.save(clip);
        
        return Result.success(clip);
    } catch (Exception e) {
        throw new RuntimeException("视频剪辑失败", e);
    }
}

@GetMapping("/download/{id}")
public void downloadRecord(@PathVariable Long id, HttpServletResponse response) {
    RecordHistory record = recordHistoryService.getById(id);
    File file = new File(record.getFilePath());
    
    response.setContentType("application/octet-stream");
    response.setHeader("Content-Disposition", 
        "attachment; filename=" + URLEncoder.encode(file.getName(), "UTF-8"));
    response.setContentLength((int) file.length());
    
    try (InputStream is = new FileInputStream(file);
         OutputStream os = response.getOutputStream()) {
        byte[] buffer = new byte[1024];
        int length;
        while ((length = is.read(buffer)) > 0) {
            os.write(buffer, 0, length);
        }
    }
}

四、存储管理与自动清理

1. 存储状态监控

@Service
public class StorageMonitorService {
    
    @Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点执行
    public void monitorStorage() {
        // 检查存储空间使用情况
        File storageDir = new File(storageConfig.getBasePath());
        long usedSpace = FileUtils.sizeOfDirectory(storageDir);
        long totalSpace = storageDir.getTotalSpace();
        double usageRate = (double) usedSpace / totalSpace;
        
        if (usageRate > storageConfig.getThreshold()) {
            // 触发自动清理
            cleanExpiredRecords();
        }
    }
    
    private void cleanExpiredRecords() {
        // 查询过期的录像记录
        Date expireDate = DateUtils.addDays(new Date(), 
            -storageConfig.getRetentionDays());
        
        List<RecordHistory> expiredRecords = recordHistoryService
            .list(Wrappers.<RecordHistory>lambdaQuery()
                .lt(RecordHistory::getStartTime, expireDate));
        
        // 删除文件及数据库记录
        expiredRecords.forEach(record -> {
            File file = new File(record.getFilePath());
            if (file.exists()) {
                file.delete();
            }
            recordHistoryService.removeById(record.getId());
        });
    }
}

2. 存储配置

# application.yml
storage:
  base-path: /var/video-storage
  retention-days: 30
  threshold: 0.8  # 存储空间使用率阈值
  cleanup:
    enabled: true
    cron: "0 0 2 * * ?"  # 每天凌晨2点执行清理

五、安全与权限控制

1. 权限验证

@RestControllerAdvice
public class SecurityAdvice {
    
    @ModelAttribute
    public void checkRecordPermission(
        @RequestParam(required = false) Long recordId,
        @RequestParam(required = false) Long planId,
        HttpServletRequest request) {
        
        // 从token获取当前用户
        User user = getCurrentUser(request);
        
        if (recordId != null) {
            RecordHistory record = recordHistoryService.getById(recordId);
            if (!hasPermission(user, record.getDeviceId())) {
                throw new AccessDeniedException("无权访问该录像");
            }
        }
        
        if (planId != null) {
            RecordPlan plan = recordPlanService.getById(planId);
            if (!hasPermission(user, plan.getDeviceId())) {
                throw new AccessDeniedException("无权操作该计划");
            }
        }
    }
    
    private boolean hasPermission(User user, String deviceId) {
        // 实现具体的权限检查逻辑
        return true;
    }
}

2. 录像加密

// 录像文件加密存储
public void saveWithEncryption(File source, String destPath) throws Exception {
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(Cipher.ENCRYPT_MODE, getSecretKey());
    
    try (InputStream is = new FileInputStream(source);
         OutputStream os = new FileOutputStream(destPath);
         CipherOutputStream cos = new CipherOutputStream(os, cipher)) {
        byte[] buffer = new byte[1024];
        int length;
        while ((length = is.read(buffer)) > 0) {
            cos.write(buffer, 0, length);
        }
    }
}

// 录像文件解密播放
public void decryptAndPlay(String encryptedPath, OutputStream output) throws Exception {
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(Cipher.DECRYPT_MODE, getSecretKey());
    
    try (InputStream is = new FileInputStream(encryptedPath);
         CipherInputStream cis = new CipherInputStream(is, cipher)) {
        byte[] buffer = new byte[1024];
        int length;
        while ((length = cis.read(buffer)) > 0) {
            output.write(buffer, 0, length);
        }
    }
}

通过以上方案,SkeyeVSS视频融合平台可以实现完整的服务端录像功能,包括计划配置、时段查询、检索回放等核心功能,同时兼顾性能、安全和易用性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值