一、服务端录像计划配置
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视频融合平台可以实现完整的服务端录像功能,包括计划配置、时段查询、检索回放等核心功能,同时兼顾性能、安全和易用性。