HOME\tianlin01 11 månader sedan
förälder
incheckning
77e550f8a3
40 ändrade filer med 3113 tillägg och 0 borttagningar
  1. 21 0
      elevator-media-record/LICENSE
  2. 3 0
      elevator-media-record/README.md
  3. BIN
      elevator-media-record/lib-arm/ffmpeg
  4. BIN
      elevator-media-record/lib-arm/ffprobe
  5. BIN
      elevator-media-record/lib/ffmpeg
  6. BIN
      elevator-media-record/lib/ffprobe
  7. 121 0
      elevator-media-record/pom.xml
  8. 15 0
      elevator-media-record/src/main/java/top/panll/assist/WvpProAssistApplication.java
  9. 44 0
      elevator-media-record/src/main/java/top/panll/assist/config/FastJsonRedisSerializer.java
  10. 44 0
      elevator-media-record/src/main/java/top/panll/assist/config/GlobalExceptionHandler.java
  11. 54 0
      elevator-media-record/src/main/java/top/panll/assist/config/GlobalResponseAdvice.java
  12. 66 0
      elevator-media-record/src/main/java/top/panll/assist/config/RedisConfig.java
  13. 45 0
      elevator-media-record/src/main/java/top/panll/assist/config/SpringDocConfig.java
  14. 59 0
      elevator-media-record/src/main/java/top/panll/assist/config/ThreadPoolTaskConfig.java
  15. 24 0
      elevator-media-record/src/main/java/top/panll/assist/config/WebMvcConfig.java
  16. 130 0
      elevator-media-record/src/main/java/top/panll/assist/controller/RecordController.java
  17. 35 0
      elevator-media-record/src/main/java/top/panll/assist/controller/bean/ControllerException.java
  18. 29 0
      elevator-media-record/src/main/java/top/panll/assist/controller/bean/ErrorCode.java
  19. 16 0
      elevator-media-record/src/main/java/top/panll/assist/controller/bean/FileLIstInfo.java
  20. 53 0
      elevator-media-record/src/main/java/top/panll/assist/controller/bean/RecordFile.java
  21. 66 0
      elevator-media-record/src/main/java/top/panll/assist/controller/bean/WVPResult.java
  22. 8 0
      elevator-media-record/src/main/java/top/panll/assist/dto/AssistConstants.java
  23. 108 0
      elevator-media-record/src/main/java/top/panll/assist/dto/MergeOrCutTaskInfo.java
  24. 31 0
      elevator-media-record/src/main/java/top/panll/assist/dto/SignInfo.java
  25. 23 0
      elevator-media-record/src/main/java/top/panll/assist/dto/SpaceInfo.java
  26. 78 0
      elevator-media-record/src/main/java/top/panll/assist/dto/UserSettings.java
  27. 77 0
      elevator-media-record/src/main/java/top/panll/assist/dto/VideoFile.java
  28. 79 0
      elevator-media-record/src/main/java/top/panll/assist/dto/VideoTaskInfo.java
  29. 159 0
      elevator-media-record/src/main/java/top/panll/assist/service/FFmpegExecUtils.java
  30. 86 0
      elevator-media-record/src/main/java/top/panll/assist/service/FileManagerTimer.java
  31. 152 0
      elevator-media-record/src/main/java/top/panll/assist/service/VideoFileFactory.java
  32. 505 0
      elevator-media-record/src/main/java/top/panll/assist/service/VideoFileService.java
  33. 46 0
      elevator-media-record/src/main/java/top/panll/assist/utils/DateUtils.java
  34. 723 0
      elevator-media-record/src/main/java/top/panll/assist/utils/RedisUtil.java
  35. 57 0
      elevator-media-record/src/main/resources/all-application.yml
  36. 58 0
      elevator-media-record/src/main/resources/application-dev.yml
  37. 57 0
      elevator-media-record/src/main/resources/application-local.yml
  38. 3 0
      elevator-media-record/src/main/resources/application.yml
  39. 25 0
      elevator-media-record/src/main/resources/static/download.html
  40. 13 0
      elevator-media-record/src/test/java/top/panll/assist/WvpProAssistApplicationTests.java

+ 21 - 0
elevator-media-record/LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 panll
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 3 - 0
elevator-media-record/README.md

@@ -0,0 +1,3 @@
+# wvp-pro-assist
+
+wvp-pro-assist是wvp-pro的辅助录像程序,也可单独跟zlm一起使用,提供录像控制,录像合并下载接口

BIN
elevator-media-record/lib-arm/ffmpeg


BIN
elevator-media-record/lib-arm/ffprobe


BIN
elevator-media-record/lib/ffmpeg


BIN
elevator-media-record/lib/ffprobe


+ 121 - 0
elevator-media-record/pom.xml

@@ -0,0 +1,121 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>2.7.2</version>
+    </parent>
+    <groupId>top.panll.assist</groupId>
+    <artifactId>wvp-pro-assist</artifactId>
+    <version>2.6.9</version>
+    <name>wvp-pro-assist</name>
+    <description></description>
+    <properties>
+        <java.version>1.8</java.version>
+        <maven.build.timestamp.format>MMddHHmm</maven.build.timestamp.format>
+<!--        <pagehelper.version>5.2.0</pagehelper.version>-->
+    </properties>
+
+    <repositories>
+        <repository>
+            <id>nexus-aliyun</id>
+            <name>Nexus aliyun</name>
+            <url>https://maven.aliyun.com/repository/public</url>
+            <layout>default</layout>
+            <snapshots>
+                <enabled>false</enabled>
+            </snapshots>
+            <releases>
+                <enabled>true</enabled>
+            </releases>
+        </repository>
+    </repositories>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-redis</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>net.bramp.ffmpeg</groupId>
+            <artifactId>ffmpeg</artifactId>
+            <version>0.6.2</version>
+        </dependency>
+
+        <!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>fastjson</artifactId>
+            <version>1.2.73</version>
+        </dependency>
+
+        <!--在线文档 -->
+        <dependency>
+            <groupId>org.springdoc</groupId>
+            <artifactId>springdoc-openapi-ui</artifactId>
+            <version>1.6.10</version>
+        </dependency>
+        <dependency>
+            <groupId>com.github.xiaoymin</groupId>
+            <artifactId>knife4j-springdoc-ui</artifactId>
+            <version>3.0.3</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.mp4parser</groupId>
+            <artifactId>muxer</artifactId>
+            <version>1.9.56</version>
+        </dependency>
+        <dependency>
+            <groupId>org.mp4parser</groupId>
+            <artifactId>streaming</artifactId>
+            <version>1.9.56</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.mp4parser</groupId>
+            <artifactId>isoparser</artifactId>
+            <version>1.9.27</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+
+    </dependencies>
+
+    <build>
+        <finalName>${project.artifactId}-${project.version}</finalName>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <configuration>
+                    <source>1.8</source>
+                    <target>1.8</target>
+                </configuration>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <skipTests>true</skipTests>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>

+ 15 - 0
elevator-media-record/src/main/java/top/panll/assist/WvpProAssistApplication.java

@@ -0,0 +1,15 @@
+package top.panll.assist;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@SpringBootApplication
+@EnableScheduling
+public class WvpProAssistApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(WvpProAssistApplication.class, args);
+    }
+
+}

+ 44 - 0
elevator-media-record/src/main/java/top/panll/assist/config/FastJsonRedisSerializer.java

@@ -0,0 +1,44 @@
+package top.panll.assist.config;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.parser.ParserConfig;
+import com.alibaba.fastjson.serializer.SerializerFeature;
+import org.springframework.data.redis.serializer.RedisSerializer;
+import org.springframework.data.redis.serializer.SerializationException;
+
+import java.nio.charset.Charset;
+
+public class FastJsonRedisSerializer<T> implements RedisSerializer<T> {
+    private static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
+    private Class<T> clazz;
+
+    /**
+     * 添加autotype白名单
+     * 解决redis反序列化对象时报错 :com.alibaba.fastjson.JSONException: autoType is not support
+     */
+    static {
+        ParserConfig.getGlobalInstance().addAccept("top.panll.assist");
+    }
+
+    public FastJsonRedisSerializer(Class<T> clazz) {
+        super();
+        this.clazz = clazz;
+    }
+
+    @Override
+    public byte[] serialize(T t) throws SerializationException {
+        if (null == t) {
+            return new byte[0];
+        }
+        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
+    }
+
+    @Override
+    public T deserialize(byte[] bytes) throws SerializationException {
+        if (null == bytes || bytes.length <= 0) {
+            return null;
+        }
+        String str = new String(bytes, DEFAULT_CHARSET);
+        return JSON.parseObject(str, clazz);
+    }
+}

+ 44 - 0
elevator-media-record/src/main/java/top/panll/assist/config/GlobalExceptionHandler.java

@@ -0,0 +1,44 @@
+package top.panll.assist.config;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import top.panll.assist.controller.bean.ControllerException;
+import top.panll.assist.controller.bean.ErrorCode;
+import top.panll.assist.controller.bean.WVPResult;
+
+/**
+ * 全局异常处理
+ */
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+    private final static Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+    /**
+     * 默认异常处理
+     * @param e 异常
+     * @return 统一返回结果
+     */
+    @ExceptionHandler(Exception.class)
+    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+    public WVPResult<String> exceptionHandler(Exception e) {
+        logger.error("[全局异常]: ", e);
+        return WVPResult.fail(ErrorCode.ERROR500.getCode(), e.getMessage());
+    }
+
+    /**
+     * 自定义异常处理, 处理controller中返回的错误
+     * @param e 异常
+     * @return 统一返回结果
+     */
+    @ExceptionHandler(ControllerException.class)
+    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+    public WVPResult<String> exceptionHandler(ControllerException e) {
+        return WVPResult.fail(e.getCode(), e.getMsg());
+    }
+
+}

+ 54 - 0
elevator-media-record/src/main/java/top/panll/assist/config/GlobalResponseAdvice.java

@@ -0,0 +1,54 @@
+package top.panll.assist.config;
+
+import com.alibaba.fastjson.JSON;
+import org.springframework.core.MethodParameter;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.server.ServerHttpRequest;
+import org.springframework.http.server.ServerHttpResponse;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
+import top.panll.assist.controller.bean.ErrorCode;
+import top.panll.assist.controller.bean.WVPResult;
+
+import javax.validation.constraints.NotNull;
+
+/**
+ * 全局统一返回结果
+ * @author lin
+ */
+@RestControllerAdvice
+public class GlobalResponseAdvice implements ResponseBodyAdvice<Object> {
+
+
+    @Override
+    public boolean supports(@NotNull MethodParameter returnType, @NotNull Class<? extends HttpMessageConverter<?>> converterType) {
+        return true;
+    }
+
+    @Override
+    public Object beforeBodyWrite(Object body, @NotNull MethodParameter returnType, @NotNull MediaType selectedContentType, @NotNull Class<? extends HttpMessageConverter<?>> selectedConverterType, @NotNull ServerHttpRequest request, @NotNull ServerHttpResponse response) {
+        // 排除api文档的接口,这个接口不需要统一
+        String[] excludePath = {"/v3/api-docs","/api/v1","/index/hook"};
+        for (String path : excludePath) {
+            if (request.getURI().getPath().startsWith(path)) {
+                return body;
+            }
+        }
+
+        if (body instanceof WVPResult) {
+            return body;
+        }
+
+        if (body instanceof ErrorCode) {
+            ErrorCode errorCode = (ErrorCode) body;
+            return new WVPResult<>(errorCode.getCode(), errorCode.getMsg(), null);
+        }
+
+        if (body instanceof String) {
+            return JSON.toJSONString(WVPResult.success(body));
+        }
+
+        return WVPResult.success(body);
+    }
+}

+ 66 - 0
elevator-media-record/src/main/java/top/panll/assist/config/RedisConfig.java

@@ -0,0 +1,66 @@
+package top.panll.assist.config;
+
+import com.alibaba.fastjson.parser.ParserConfig;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisOperations;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.listener.RedisMessageListenerContainer;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+/**
+ * @Description:Redis中间件配置类,使用spring-data-redis集成,自动从application.yml中加载redis配置
+ * @author: swwheihei
+ * @date: 2019年5月30日 上午10:58:25
+ * 
+ */
+@Configuration
+@ConditionalOnClass(RedisOperations.class)
+@EnableConfigurationProperties(RedisProperties.class)
+public class RedisConfig {
+
+	static {
+		ParserConfig.getGlobalInstance().addAccept("top.panll.assist");
+	}
+
+	@Bean
+	@ConditionalOnMissingBean(name = "redisTemplate")
+	public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
+		RedisTemplate<Object, Object> template = new RedisTemplate<>();
+		template.setConnectionFactory(redisConnectionFactory);
+		// 使用fastjson进行序列化处理,提高解析效率
+		FastJsonRedisSerializer<Object> serializer = new FastJsonRedisSerializer<>(Object.class);
+		// value值的序列化采用fastJsonRedisSerializer
+		template.setValueSerializer(serializer);
+		template.setHashValueSerializer(serializer);
+		// key的序列化采用StringRedisSerializer
+		template.setKeySerializer(new StringRedisSerializer());
+		template.setHashKeySerializer(new StringRedisSerializer());
+		template.setConnectionFactory(redisConnectionFactory);
+		// 使用fastjson时需设置此项,否则会报异常not support type
+//		ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
+		return template;
+
+	}
+
+	/**
+	 * redis消息监听器容器 可以添加多个监听不同话题的redis监听器,只需要把消息监听器和相应的消息订阅处理器绑定,该消息监听器
+	 * 通过反射技术调用消息订阅处理器的相关方法进行一些业务处理
+	 * 
+	 * @param connectionFactory
+	 * @return
+	 */
+	@Bean
+	RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory) {
+
+        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
+        container.setConnectionFactory(connectionFactory);
+        return container;
+    }
+
+}

+ 45 - 0
elevator-media-record/src/main/java/top/panll/assist/config/SpringDocConfig.java

@@ -0,0 +1,45 @@
+package top.panll.assist.config;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Contact;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.info.License;
+import org.springdoc.core.GroupedOpenApi;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * @author lin
+ */
+@Configuration
+public class SpringDocConfig {
+
+    @Value("${doc.enabled: true}")
+    private boolean enable;
+
+    @Bean
+    public OpenAPI springShopOpenApi() {
+        Contact contact = new Contact();
+        contact.setName("pan");
+        contact.setEmail("648540858@qq.com");
+        return new OpenAPI()
+                .info(new Info().title("WVP-PRO-ASSIST 接口文档")
+                        .contact(contact)
+                        .description("WVP-PRO助手,补充ZLM功能")
+                        .version("v2.0")
+                        .license(new License().name("Apache 2.0").url("http://springdoc.org")));
+    }
+
+    /**
+     * 添加分组
+     * @return
+     */
+    @Bean
+    public GroupedOpenApi publicApi() {
+        return GroupedOpenApi.builder()
+                .group("1. 全部")
+                .packagesToScan("top.panll.assist")
+                .build();
+    }
+}

+ 59 - 0
elevator-media-record/src/main/java/top/panll/assist/config/ThreadPoolTaskConfig.java

@@ -0,0 +1,59 @@
+package top.panll.assist.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+@Configuration
+@EnableAsync(proxyTargetClass = true)
+public class ThreadPoolTaskConfig {
+
+    public static final int cpuNum = Runtime.getRuntime().availableProcessors();
+
+    /**
+     *   默认情况下,在创建了线程池后,线程池中的线程数为0,当有任务来之后,就会创建一个线程去执行任务,
+     *    当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中;
+     *  当队列满了,就继续创建线程,当线程数量大于等于maxPoolSize后,开始使用拒绝策略拒绝
+     */
+
+    /**
+     * 核心线程数(默认线程数)
+     */
+    private static final int corePoolSize = cpuNum;
+    /**
+     * 最大线程数
+     */
+    private static final int maxPoolSize = cpuNum*2;
+    /**
+     * 允许线程空闲时间(单位:默认为秒)
+     */
+    private static final int keepAliveTime = 30;
+    /**
+     * 缓冲队列大小
+     */
+    private static final int queueCapacity = 500;
+    /**
+     * 线程池名前缀
+     */
+    private static final String threadNamePrefix = "wvp-assist-";
+
+    @Bean("taskExecutor") // bean的名称,默认为首字母小写的方法名
+    public ThreadPoolTaskExecutor taskExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(corePoolSize);
+        executor.setMaxPoolSize(maxPoolSize);
+        executor.setQueueCapacity(queueCapacity);
+        executor.setKeepAliveSeconds(keepAliveTime);
+        executor.setThreadNamePrefix(threadNamePrefix);
+
+        // 线程池对拒绝任务的处理策略
+        // CallerRunsPolicy:由调用线程(提交任务的线程)处理该任务
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        // 初始化
+        executor.initialize();
+        return executor;
+    }
+}

+ 24 - 0
elevator-media-record/src/main/java/top/panll/assist/config/WebMvcConfig.java

@@ -0,0 +1,24 @@
+package top.panll.assist.config;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
+import top.panll.assist.dto.UserSettings;
+
+import java.io.File;
+
+
+@Configuration
+public class WebMvcConfig extends WebMvcConfigurerAdapter {
+
+    @Autowired
+    private UserSettings userSettings;
+
+    @Override
+    public void addResourceHandlers(ResourceHandlerRegistry registry) {
+        File file = new File(userSettings.getRecordTempPath());
+        registry.addResourceHandler("/download/**").addResourceLocations("file://" + file.getAbsolutePath() + "/");
+        super.addResourceHandlers(registry);
+    }
+}

+ 130 - 0
elevator-media-record/src/main/java/top/panll/assist/controller/RecordController.java

@@ -0,0 +1,130 @@
+package top.panll.assist.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import top.panll.assist.controller.bean.*;
+import top.panll.assist.dto.*;
+import top.panll.assist.service.VideoFileService;
+import top.panll.assist.utils.RedisUtil;
+
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Tag(name = "录像管理", description = "录像管理")
+@CrossOrigin
+@RestController
+@RequestMapping("/api/record")
+public class RecordController {
+
+    private final static Logger logger = LoggerFactory.getLogger(RecordController.class);
+
+    @Autowired
+    private VideoFileService videoFileService;
+
+    @Autowired
+    private RedisUtil redisUtil;
+
+    @Autowired
+    private UserSettings userSettings;
+
+    private SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+
+
+    /**
+     * 获取Assist服务配置信息
+     */
+    @Operation(summary ="获取Assist服务配置信息")
+    @GetMapping(value = "/info")
+    @ResponseBody
+    public UserSettings getInfo(){
+        return userSettings;
+    }
+
+
+    /**
+     * 添加视频裁剪合并任务
+     */
+    @Operation(summary ="添加视频裁剪合并任务")
+    @Parameter(name = "videoTaskInfo", description = "视频合并任务的信息", required = true)
+    @PostMapping(value = "/file/download/task/add")
+    @ResponseBody
+    public String addTaskForDownload(@RequestBody VideoTaskInfo videoTaskInfo ){
+        if (videoTaskInfo.getFilePathList() == null || videoTaskInfo.getFilePathList().isEmpty()) {
+            throw new ControllerException(ErrorCode.ERROR100.getCode(), "视频文件列表不可为空");
+        }
+        String id = videoFileService.mergeOrCut(videoTaskInfo);
+        if (id== null) {
+            throw new ControllerException(ErrorCode.ERROR100.getCode(), "可能未找到视频文件");
+        }
+        return id;
+    }
+
+    /**
+     * 查询视频裁剪合并任务列表
+     */
+    @Operation(summary ="查询视频裁剪合并任务列表")
+    @Parameter(name = "taskId", description = "任务ID", required = true)
+    @Parameter(name = "isEnd", description = "是否结束", required = true)
+    @GetMapping(value = "/file/download/task/list")
+    @ResponseBody
+    public List<MergeOrCutTaskInfo> getTaskListForDownload(
+            @RequestParam(required = false) String app,
+            @RequestParam(required = false) String stream,
+            @RequestParam(required = false) String callId,
+            @RequestParam(required = false) String taskId,
+            @RequestParam(required = false) Boolean isEnd){
+        List<MergeOrCutTaskInfo> taskList = videoFileService.getTaskListForDownload(app, stream, callId, isEnd, taskId);
+        if (taskList == null) {
+            throw new ControllerException(ErrorCode.ERROR100);
+        }
+        return taskList;
+    }
+
+    /**
+     * 中止视频裁剪合并任务列表
+     */
+    @Operation(summary ="中止视频裁剪合并任务列表(暂不支持)")
+    @GetMapping(value = "/file/download/task/stop")
+    @ResponseBody
+    public WVPResult<String> stopTaskForDownload(@RequestParam String taskId){
+//        WVPResult<String> result = new WVPResult<>();
+//        if (taskId == null) {
+//            result.setCode(400);
+//            result.setMsg("taskId 不能为空");
+//            return result;
+//        }
+//        boolean stopResult = videoFileService.stopTask(taskId);
+//        result.setCode(0);
+//        result.setMsg(stopResult ? "success": "fail");
+        return null;
+    }
+
+    /**
+     * 磁盘空间查询
+     */
+    @Operation(summary ="磁盘空间查询")
+    @ResponseBody
+    @GetMapping(value = "/space", produces = "application/json;charset=UTF-8")
+    public SpaceInfo getSpace() {
+        return videoFileService.getSpaceInfo();
+    }
+
+    /**
+     * 录像文件的时长
+     */
+    @Operation(summary ="录像文件的时长")
+    @Parameter(name = "app", description = "应用名", required = true)
+    @Parameter(name = "stream", description = "流ID", required = true)
+    @Parameter(name = "recordIng", description = "是否录制中", required = true)
+    @ResponseBody
+    @GetMapping(value = "/file/duration", produces = "application/json;charset=UTF-8")
+    @PostMapping(value = "/file/duration", produces = "application/json;charset=UTF-8")
+    public long fileDuration( @RequestParam String app, @RequestParam String stream) {
+        return videoFileService.fileDuration(app, stream);
+    }
+}

+ 35 - 0
elevator-media-record/src/main/java/top/panll/assist/controller/bean/ControllerException.java

@@ -0,0 +1,35 @@
+package top.panll.assist.controller.bean;
+
+/**
+ * 自定义异常,controller出现错误时直接抛出异常由全局异常捕获并返回结果
+ */
+public class ControllerException extends RuntimeException{
+
+    private int code;
+    private String msg;
+
+    public ControllerException(int code, String msg) {
+        this.code = code;
+        this.msg = msg;
+    }
+    public ControllerException(ErrorCode errorCode) {
+        this.code = errorCode.getCode();
+        this.msg = errorCode.getMsg();
+    }
+
+    public int getCode() {
+        return code;
+    }
+
+    public void setCode(int code) {
+        this.code = code;
+    }
+
+    public String getMsg() {
+        return msg;
+    }
+
+    public void setMsg(String msg) {
+        this.msg = msg;
+    }
+}

+ 29 - 0
elevator-media-record/src/main/java/top/panll/assist/controller/bean/ErrorCode.java

@@ -0,0 +1,29 @@
+package top.panll.assist.controller.bean;
+
+/**
+ * 全局错误码
+ */
+public enum ErrorCode {
+    SUCCESS(0, "成功"),
+    ERROR100(100, "失败"),
+    ERROR400(400, "参数不全或者错误"),
+    ERROR403(403, "无权限操作"),
+    ERROR401(401, "请登录后重新请求"),
+    ERROR500(500, "系统异常");
+
+    private final int code;
+    private final String msg;
+
+    ErrorCode(int code, String msg) {
+        this.code = code;
+        this.msg = msg;
+    }
+
+    public int getCode() {
+        return code;
+    }
+
+    public String getMsg() {
+        return msg;
+    }
+}

+ 16 - 0
elevator-media-record/src/main/java/top/panll/assist/controller/bean/FileLIstInfo.java

@@ -0,0 +1,16 @@
+package top.panll.assist.controller.bean;
+
+import java.util.List;
+
+public class FileLIstInfo {
+
+    private List<String> filePathList;
+
+    public List<String> getFilePathList() {
+        return filePathList;
+    }
+
+    public void setFilePathList(List<String> filePathList) {
+        this.filePathList = filePathList;
+    }
+}

+ 53 - 0
elevator-media-record/src/main/java/top/panll/assist/controller/bean/RecordFile.java

@@ -0,0 +1,53 @@
+package top.panll.assist.controller.bean;
+
+public class RecordFile {
+    private String app;
+    private String stream;
+
+    private String fileName;
+
+    private String date;
+
+
+    public static RecordFile instance(String app, String stream, String fileName, String date) {
+        RecordFile recordFile = new RecordFile();
+        recordFile.setApp(app);
+        recordFile.setStream(stream);
+        recordFile.setFileName(fileName);
+        recordFile.setDate(date);
+        return recordFile;
+    }
+
+
+    public String getApp() {
+        return app;
+    }
+
+    public void setApp(String app) {
+        this.app = app;
+    }
+
+    public String getStream() {
+        return stream;
+    }
+
+    public void setStream(String stream) {
+        this.stream = stream;
+    }
+
+    public String getFileName() {
+        return fileName;
+    }
+
+    public void setFileName(String fileName) {
+        this.fileName = fileName;
+    }
+
+    public String getDate() {
+        return date;
+    }
+
+    public void setDate(String date) {
+        this.date = date;
+    }
+}

+ 66 - 0
elevator-media-record/src/main/java/top/panll/assist/controller/bean/WVPResult.java

@@ -0,0 +1,66 @@
+package top.panll.assist.controller.bean;
+
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "统一返回结果")
+public class WVPResult<T> {
+
+    public WVPResult() {
+    }
+
+    public WVPResult(int code, String msg, T data) {
+        this.code = code;
+        this.msg = msg;
+        this.data = data;
+    }
+
+
+    @Schema(description = "错误码,0为成功")
+    private int code;
+    @Schema(description = "描述,错误时描述错误原因")
+    private String msg;
+    @Schema(description = "数据")
+    private T data;
+
+
+    public static <T> WVPResult<T> success(T t, String msg) {
+        return new WVPResult<>(ErrorCode.SUCCESS.getCode(), msg, t);
+    }
+
+    public static <T> WVPResult<T> success(T t) {
+        return success(t, ErrorCode.SUCCESS.getMsg());
+    }
+
+    public static <T> WVPResult<T> fail(int code, String msg) {
+        return new WVPResult<>(code, msg, null);
+    }
+
+    public static <T> WVPResult<T> fail(ErrorCode errorCode) {
+        return fail(errorCode.getCode(), errorCode.getMsg());
+    }
+
+    public int getCode() {
+        return code;
+    }
+
+    public void setCode(int code) {
+        this.code = code;
+    }
+
+    public String getMsg() {
+        return msg;
+    }
+
+    public void setMsg(String msg) {
+        this.msg = msg;
+    }
+
+    public T getData() {
+        return data;
+    }
+
+    public void setData(T data) {
+        this.data = data;
+    }
+}

+ 8 - 0
elevator-media-record/src/main/java/top/panll/assist/dto/AssistConstants.java

@@ -0,0 +1,8 @@
+package top.panll.assist.dto;
+
+public class AssistConstants {
+
+    public final static String STREAM_CALL_INFO = "STREAM_CALL_INFO_";
+
+    public final static String MERGEORCUT = "MERGEORCUT_";
+}

+ 108 - 0
elevator-media-record/src/main/java/top/panll/assist/dto/MergeOrCutTaskInfo.java

@@ -0,0 +1,108 @@
+package top.panll.assist.dto;
+
+
+public class MergeOrCutTaskInfo {
+    private String id;
+    private String createTime;
+    private String percentage;
+
+    private String recordFile;
+
+    private String downloadFile;
+
+    private String playFile;
+
+    private String app;
+    private String stream;
+    private String startTime;
+    private String endTime;
+    private String callId;
+
+    public String getId() {
+        return id;
+    }
+
+    public void setId(String id) {
+        this.id = id;
+    }
+
+    public String getPercentage() {
+        return percentage;
+    }
+
+    public void setPercentage(String percentage) {
+        this.percentage = percentage;
+    }
+
+    public String getRecordFile() {
+        return recordFile;
+    }
+
+    public void setRecordFile(String recordFile) {
+        this.recordFile = recordFile;
+    }
+
+    public String getDownloadFile() {
+        return downloadFile;
+    }
+
+    public void setDownloadFile(String downloadFile) {
+        this.downloadFile = downloadFile;
+    }
+
+    public String getPlayFile() {
+        return playFile;
+    }
+
+    public void setPlayFile(String playFile) {
+        this.playFile = playFile;
+    }
+
+    public String getCreateTime() {
+        return createTime;
+    }
+
+    public void setCreateTime(String createTime) {
+        this.createTime = createTime;
+    }
+
+    public String getApp() {
+        return app;
+    }
+
+    public void setApp(String app) {
+        this.app = app;
+    }
+
+    public String getStream() {
+        return stream;
+    }
+
+    public void setStream(String stream) {
+        this.stream = stream;
+    }
+
+    public String getStartTime() {
+        return startTime;
+    }
+
+    public void setStartTime(String startTime) {
+        this.startTime = startTime;
+    }
+
+    public String getEndTime() {
+        return endTime;
+    }
+
+    public void setEndTime(String endTime) {
+        this.endTime = endTime;
+    }
+
+    public String getCallId() {
+        return callId;
+    }
+
+    public void setCallId(String callId) {
+        this.callId = callId;
+    }
+}

+ 31 - 0
elevator-media-record/src/main/java/top/panll/assist/dto/SignInfo.java

@@ -0,0 +1,31 @@
+package top.panll.assist.dto;
+
+public class SignInfo {
+    private String app;
+    private String stream;
+    private String type;
+
+    public String getApp() {
+        return app;
+    }
+
+    public void setApp(String app) {
+        this.app = app;
+    }
+
+    public String getStream() {
+        return stream;
+    }
+
+    public void setStream(String stream) {
+        this.stream = stream;
+    }
+
+    public String getType() {
+        return type;
+    }
+
+    public void setType(String type) {
+        this.type = type;
+    }
+}

+ 23 - 0
elevator-media-record/src/main/java/top/panll/assist/dto/SpaceInfo.java

@@ -0,0 +1,23 @@
+package top.panll.assist.dto;
+
+public class SpaceInfo {
+    private long total;
+    private long free;
+
+    public long getTotal() {
+        return total;
+    }
+
+    public void setTotal(long total) {
+        this.total = total;
+    }
+
+    public long getFree() {
+        return free;
+    }
+
+    public void setFree(long free) {
+        this.free = free;
+    }
+
+}

+ 78 - 0
elevator-media-record/src/main/java/top/panll/assist/dto/UserSettings.java

@@ -0,0 +1,78 @@
+package top.panll.assist.dto;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * @author lin
+ */
+@Component
+public class UserSettings {
+
+    @Value("${userSettings.id}")
+    private String id;
+
+    @Value("${userSettings.record-temp:./recordTemp}")
+    private String recordTempPath;
+
+    @Value("${userSettings.record-temp-day:7}")
+    private int recordTempDay;
+
+    @Value("${userSettings.ffmpeg}")
+    private String ffmpeg;
+
+    @Value("${userSettings.ffprobe}")
+    private String ffprobe;
+
+    @Value("${userSettings.threads:2}")
+    private int threads;
+
+    public String getId() {
+        return id;
+    }
+
+    public void setId(String id) {
+        this.id = id;
+    }
+
+    public String getFfmpeg() {
+        return ffmpeg;
+    }
+
+    public void setFfmpeg(String ffmpeg) {
+        this.ffmpeg = ffmpeg;
+    }
+
+    public String getFfprobe() {
+        return ffprobe;
+    }
+
+    public void setFfprobe(String ffprobe) {
+        this.ffprobe = ffprobe;
+    }
+
+
+    public int getRecordTempDay() {
+        return recordTempDay;
+    }
+
+    public void setRecordTempDay(int recordTempDay) {
+        this.recordTempDay = recordTempDay;
+    }
+
+    public int getThreads() {
+        return threads;
+    }
+
+    public void setThreads(int threads) {
+        this.threads = threads;
+    }
+
+    public String getRecordTempPath() {
+        return recordTempPath;
+    }
+
+    public void setRecordTempPath(String recordTempPath) {
+        this.recordTempPath = recordTempPath;
+    }
+}

+ 77 - 0
elevator-media-record/src/main/java/top/panll/assist/dto/VideoFile.java

@@ -0,0 +1,77 @@
+package top.panll.assist.dto;
+
+import java.io.File;
+import java.util.Date;
+
+/**
+ * 视频文件
+ */
+public class VideoFile {
+
+    /**
+     * 文件对象
+     */
+    private File file;
+
+    /**
+     * 文件开始时间
+     */
+    private Date startTime;
+
+    /**
+     * 文件结束时间
+     */
+    private Date endTime;
+
+
+    /**
+     * 时长, 单位:秒
+     */
+    private long duration;
+
+
+    /**
+     * 是否是目标格式
+     */
+    private boolean targetFormat;
+
+    public File getFile() {
+        return file;
+    }
+
+    public void setFile(File file) {
+        this.file = file;
+    }
+
+    public Date getStartTime() {
+        return startTime;
+    }
+
+    public void setStartTime(Date startTime) {
+        this.startTime = startTime;
+    }
+
+    public Date getEndTime() {
+        return endTime;
+    }
+
+    public void setEndTime(Date endTime) {
+        this.endTime = endTime;
+    }
+
+    public long getDuration() {
+        return duration;
+    }
+
+    public void setDuration(long duration) {
+        this.duration = duration;
+    }
+
+    public boolean isTargetFormat() {
+        return targetFormat;
+    }
+
+    public void setTargetFormat(boolean targetFormat) {
+        this.targetFormat = targetFormat;
+    }
+}

+ 79 - 0
elevator-media-record/src/main/java/top/panll/assist/dto/VideoTaskInfo.java

@@ -0,0 +1,79 @@
+package top.panll.assist.dto;
+
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import java.util.List;
+
+@Schema(description = "视频合并任务的信息")
+public class VideoTaskInfo {
+
+    private String app;
+    private String stream;
+    private String startTime;
+    private String endTime;
+    private String callId;
+
+
+    @Schema(description = "视频文件路径列表")
+    private List<String> filePathList;
+
+    @Schema(description = "返回地址时的远程地址")
+    private String remoteHost;
+
+    public List<String> getFilePathList() {
+        return filePathList;
+    }
+
+    public void setFilePathList(List<String> filePathList) {
+        this.filePathList = filePathList;
+    }
+
+    public String getRemoteHost() {
+        return remoteHost;
+    }
+
+    public void setRemoteHost(String remoteHost) {
+        this.remoteHost = remoteHost;
+    }
+
+    public String getApp() {
+        return app;
+    }
+
+    public void setApp(String app) {
+        this.app = app;
+    }
+
+    public String getStream() {
+        return stream;
+    }
+
+    public void setStream(String stream) {
+        this.stream = stream;
+    }
+
+    public String getStartTime() {
+        return startTime;
+    }
+
+    public void setStartTime(String startTime) {
+        this.startTime = startTime;
+    }
+
+    public String getEndTime() {
+        return endTime;
+    }
+
+    public void setEndTime(String endTime) {
+        this.endTime = endTime;
+    }
+
+    public String getCallId() {
+        return callId;
+    }
+
+    public void setCallId(String callId) {
+        this.callId = callId;
+    }
+}

+ 159 - 0
elevator-media-record/src/main/java/top/panll/assist/service/FFmpegExecUtils.java

@@ -0,0 +1,159 @@
+package top.panll.assist.service;
+
+import net.bramp.ffmpeg.FFmpeg;
+import net.bramp.ffmpeg.FFmpegExecutor;
+import net.bramp.ffmpeg.FFmpegUtils;
+import net.bramp.ffmpeg.FFprobe;
+import net.bramp.ffmpeg.builder.FFmpegBuilder;
+import net.bramp.ffmpeg.job.FFmpegJob;
+import net.bramp.ffmpeg.probe.FFmpegProbeResult;
+import net.bramp.ffmpeg.progress.Progress;
+import net.bramp.ffmpeg.progress.ProgressListener;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.util.DigestUtils;
+import top.panll.assist.dto.UserSettings;
+import top.panll.assist.dto.VideoFile;
+import top.panll.assist.utils.RedisUtil;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+@Component
+public class FFmpegExecUtils implements InitializingBean{
+
+    private final static Logger logger = LoggerFactory.getLogger(FFmpegExecUtils.class);
+//    private static FFmpegExecUtils instance;
+//
+//    public FFmpegExecUtils() {
+//    }
+//
+//    public static FFmpegExecUtils getInstance(){
+//        if(instance==null){
+//            synchronized (FFmpegExecUtils.class){
+//                if(instance==null){
+//                    instance=new FFmpegExecUtils();
+//                }
+//            }
+//        }
+//        return instance;
+//    }
+    @Autowired
+    private UserSettings userSettings;
+
+    private FFprobe ffprobe;
+    private FFmpeg ffmpeg;
+
+    public FFprobe getFfprobe() {
+        return ffprobe;
+    }
+
+    public FFmpeg getFfmpeg() {
+        return ffmpeg;
+    }
+
+    @Override
+    public void afterPropertiesSet() throws Exception {
+        String ffmpegPath = userSettings.getFfmpeg();
+        String ffprobePath = userSettings.getFfprobe();
+        this.ffmpeg = new FFmpeg(ffmpegPath);
+        this.ffprobe = new FFprobe(ffprobePath);
+        logger.info("wvp-pro辅助程序启动成功。 \n{}\n{} ", this.ffmpeg.version(), this.ffprobe.version());
+    }
+
+
+
+    public interface VideoHandEndCallBack {
+        void run(String status, double percentage, String result);
+    }
+
+    @Async
+    public void mergeOrCutFile(List<File> fils, File dest, String destFileName, VideoHandEndCallBack callBack){
+
+        if (fils == null || fils.size() == 0 || ffmpeg == null || ffprobe == null || dest== null || !dest.exists()){
+            callBack.run("error", 0.0, null);
+            return;
+        }
+
+        File tempFile = new File(dest.getAbsolutePath());
+        if (!tempFile.exists()) {
+            tempFile.mkdirs();
+        }
+        FFmpegExecutor executor = new FFmpegExecutor(ffmpeg, ffprobe);
+        String fileListName = tempFile.getAbsolutePath() + File.separator + "fileList";
+        double durationAll = 0.0;
+        try {
+            BufferedWriter bw =new BufferedWriter(new FileWriter(fileListName));
+            for (File file : fils) {
+                VideoFile videoFile = VideoFileFactory.createFile(this, file);
+                if (videoFile == null) {
+                    return;
+                }
+                bw.write("file " + file.getAbsolutePath());
+                bw.newLine();
+                durationAll += videoFile.getDuration();
+            }
+            bw.flush();
+            bw.close();
+        } catch (IOException e) {
+            e.printStackTrace();
+            callBack.run("error", 0.0, null);
+        }
+        String recordFileResultPath = dest.getAbsolutePath() + File.separator + destFileName + ".mp4";
+        long startTime = System.currentTimeMillis();
+        FFmpegBuilder builder = new FFmpegBuilder()
+
+                .setFormat("concat")
+                .overrideOutputFiles(true)
+                .setInput(fileListName) // Or filename
+                .addExtraArgs("-safe", "0")
+                .addExtraArgs("-threads", userSettings.getThreads() + "")
+                .addOutput(recordFileResultPath)
+                .setVideoCodec("copy")
+                .setAudioCodec("aac")
+                .setFormat("mp4")
+                .done();
+
+        double finalDurationAll = durationAll;
+        FFmpegJob job = executor.createJob(builder, (Progress progress) -> {
+            final double duration_ns = finalDurationAll * TimeUnit.SECONDS.toNanos(1);
+            double percentage = progress.out_time_ns / duration_ns;
+
+//             Print out interesting information about the progress
+//            System.out.println(String.format(
+//                    "[%.0f%%] status:%s frame:%d time:%s ms fps:%.0f speed:%.2fx",
+//                    percentage * 100,
+//                    progress.status,
+//                    progress.frame,
+//                    FFmpegUtils.toTimecode(progress.out_time_ns, TimeUnit.NANOSECONDS),
+//                    progress.fps.doubleValue(),
+//                    progress.speed
+//            ));
+
+            if (progress.status.equals(Progress.Status.END)){
+                callBack.run(progress.status.name(), percentage, recordFileResultPath);
+            }else {
+                callBack.run(progress.status.name(), percentage, null);
+            }
+
+        });
+        job.run();
+    }
+
+    public long duration(File file) throws IOException {
+        FFmpegProbeResult in = ffprobe.probe(file.getAbsolutePath());
+        double duration = in.getFormat().duration * 1000;
+        long durationLong = new Double(duration).longValue();
+        return durationLong;
+    }
+
+}

+ 86 - 0
elevator-media-record/src/main/java/top/panll/assist/service/FileManagerTimer.java

@@ -0,0 +1,86 @@
+package top.panll.assist.service;
+
+import org.apache.commons.io.FileUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import top.panll.assist.dto.AssistConstants;
+import top.panll.assist.dto.MergeOrCutTaskInfo;
+import top.panll.assist.dto.UserSettings;
+import top.panll.assist.utils.RedisUtil;
+
+import java.io.File;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
+@Component
+public class FileManagerTimer {
+
+    private SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd");
+    private final SimpleDateFormat simpleDateFormatForTime = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+
+    private final static Logger logger = LoggerFactory.getLogger(FileManagerTimer.class);
+
+    @Autowired
+    private UserSettings userSettings;
+
+    @Autowired
+    private VideoFileService videoFileService;
+
+    @Autowired
+    private RedisUtil redisUtil;
+
+//    @Scheduled(fixedDelay = 2000)   //测试 20秒执行一次
+    @Scheduled(cron = "0 0 0 * * ?")   //每天的0点执行
+    public void execute(){
+        if (userSettings.getRecordTempPath() == null) {
+            return;
+        }
+
+        // 清理任务临时文件
+        int recordTempDay = userSettings.getRecordTempDay();
+        Date lastTempDate = new Date();
+        Calendar lastTempCalendar = Calendar.getInstance();
+        lastTempCalendar.setTime(lastTempDate);
+        lastTempCalendar.add(Calendar.DAY_OF_MONTH, -recordTempDay);
+        lastTempDate = lastTempCalendar.getTime();
+        logger.info("[录像巡查]移除合并任务临时文件 {} 之前的文件", formatter.format(lastTempDate));
+        File recordTempFile = new File(userSettings.getRecordTempPath());
+        if (recordTempFile.exists() && recordTempFile.isDirectory() && recordTempFile.canWrite()) {
+            File[] tempFiles = recordTempFile.listFiles();
+            if (tempFiles != null) {
+                for (File tempFile : tempFiles) {
+                    if (tempFile.isFile() && tempFile.lastModified() < lastTempDate.getTime()) {
+                        boolean result = FileUtils.deleteQuietly(tempFile);
+                        if (result) {
+                            logger.info("[录像巡查]成功移除合并任务临时文件 {} ", tempFile.getAbsolutePath());
+                        }else {
+                            logger.info("[录像巡查]合并任务临时文件移除失败 {} ", tempFile.getAbsolutePath());
+                        }
+                    }
+                }
+            }
+        }
+        // 清理redis记录
+        String key = String.format("%S_%S_*", AssistConstants.MERGEORCUT, userSettings.getId());
+        List<Object> taskKeys = redisUtil.scan(key);
+        for (Object taskKeyObj : taskKeys) {
+            String taskKey = (String) taskKeyObj;
+            MergeOrCutTaskInfo mergeOrCutTaskInfo = (MergeOrCutTaskInfo)redisUtil.get(taskKey);
+            try {
+                if (StringUtils.hasLength(mergeOrCutTaskInfo.getCreateTime())
+                        || simpleDateFormatForTime.parse(mergeOrCutTaskInfo.getCreateTime()).before(lastTempDate)) {
+                    redisUtil.del(taskKey);
+                }
+            } catch (ParseException e) {
+                logger.error("[清理过期的redis合并任务信息] 失败", e);
+            }
+        }
+    }
+}

+ 152 - 0
elevator-media-record/src/main/java/top/panll/assist/service/VideoFileFactory.java

@@ -0,0 +1,152 @@
+package top.panll.assist.service;
+
+import net.bramp.ffmpeg.probe.FFmpegProbeResult;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import top.panll.assist.dto.VideoFile;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+public class VideoFileFactory {
+
+    private final static Logger logger = LoggerFactory.getLogger(VideoFileFactory.class);
+
+    public static VideoFile createFile(FFmpegExecUtils ffmpegExecUtils, File file){
+        if (!file.exists()) {
+            return null;
+        }
+        if (!file.isFile()){
+            return null;
+        }
+        if (!file.getName().endsWith(".mp4")){
+            return null;
+        }
+        if (file.isHidden()){
+            return null;
+        }
+        String date = file.getParentFile().getName();
+        if (file.getName().indexOf(":") > 0) {
+            // 格式为 HH:mm:ss-HH:mm:ss-时长
+
+            String[] split = file.getName().split("-");
+            if (split.length != 3) {
+                return null;
+            }
+            String startTimeStr = date + " " + split[0];
+            String endTimeStr = date + " " + split[1];
+            SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+            VideoFile videoFile = new VideoFile();
+            videoFile.setFile(file);
+            videoFile.setTargetFormat(false);
+            try {
+                Date startTimeDate = simpleDateFormat.parse(startTimeStr);
+                videoFile.setStartTime(startTimeDate);
+                Date endTimeDate = simpleDateFormat.parse(endTimeStr);
+                videoFile.setEndTime(endTimeDate);
+                videoFile.setDuration((endTimeDate.getTime() - startTimeDate.getTime()));
+            } catch (ParseException e) {
+                logger.error("[构建视频文件对象] 格式化时间失败, file:{}", file.getAbsolutePath(), e);
+                return null;
+            }
+            return videoFile;
+
+        }else if (getStrCountInStr(file.getName(), "-") == 3){
+
+            // 格式为zlm的录制格式 HH-mm-ss-序号
+            String startStr = file.getName().substring(0, file.getName().lastIndexOf("-"));
+            String startTimeStr = date  + " " + startStr;
+            VideoFile videoFile = null;
+            try {
+                FFmpegProbeResult fFmpegProbeResult = ffmpegExecUtils.getFfprobe().probe(file.getAbsolutePath());
+                double duration = fFmpegProbeResult.getFormat().duration * 1000;
+                SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH-mm-ss");
+                Date startTimeDate = simpleDateFormat.parse(startTimeStr);
+                Date endTimeDate = new Date(startTimeDate.getTime() + new Double(duration).longValue());
+                videoFile = new VideoFile();
+                videoFile.setTargetFormat(false);
+                videoFile.setFile(file);
+                videoFile.setStartTime(startTimeDate);
+                videoFile.setEndTime(endTimeDate);
+                videoFile.setDuration((endTimeDate.getTime() - startTimeDate.getTime())/1000);
+            } catch (IOException e) {
+                logger.error("[构建视频文件对象] 获取视频时长失败, file:{}", file.getAbsolutePath(), e);
+                return null;
+            } catch (ParseException e) {
+                logger.error("[构建视频文件对象] 格式化时间失败, file:{}", file.getAbsolutePath(), e);
+                return null;
+            }
+            return videoFile;
+        }else if (getStrCountInStr(file.getName(), "-") == 2 && file.getName().length() == 10 ){
+            // 格式为zlm的录制格式 HH-mm-ss
+            String startTimeStr = date  + " " + file.getName();
+            VideoFile videoFile = null;
+            try {
+                FFmpegProbeResult fFmpegProbeResult = ffmpegExecUtils.getFfprobe().probe(file.getAbsolutePath());
+                double duration = fFmpegProbeResult.getFormat().duration * 1000;
+                SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH-mm-ss");
+                Date startTimeDate = simpleDateFormat.parse(startTimeStr);
+                Date endTimeDate = new Date(startTimeDate.getTime() + new Double(duration).longValue());
+                videoFile = new VideoFile();
+                videoFile.setTargetFormat(false);
+                videoFile.setFile(file);
+                videoFile.setStartTime(startTimeDate);
+                videoFile.setEndTime(endTimeDate);
+                videoFile.setDuration((endTimeDate.getTime() - startTimeDate.getTime())/1000);
+            } catch (IOException e) {
+                logger.error("[构建视频文件对象] 获取视频时长失败, file:{}", file.getAbsolutePath(), e);
+                return null;
+            } catch (ParseException e) {
+                logger.warn("[构建视频文件对象] 格式化时间失败, file:{}", file.getAbsolutePath(), e);
+                return null;
+            }
+            return videoFile;
+        }else if (getStrCountInStr(file.getName(), "-") == 1 ){
+            // 格式为zlm的录制格式 HH-mm-ss
+            // 格式为 HH:mm:ss-HH:mm:ss-时长
+
+            String[] split = file.getName().split("-");
+            if (split.length != 2) {
+                return null;
+            }
+            String startTimeStr = date + " " + split[0];
+            String endTimeStr = date + " " + split[1];
+            SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HHmmss");
+            VideoFile videoFile = new VideoFile();
+            videoFile.setTargetFormat(true);
+            videoFile.setFile(file);
+            try {
+                Date startTimeDate = simpleDateFormat.parse(startTimeStr);
+                videoFile.setStartTime(startTimeDate);
+                Date endTimeDate = simpleDateFormat.parse(endTimeStr);
+                videoFile.setEndTime(endTimeDate);
+                videoFile.setDuration((endTimeDate.getTime() - startTimeDate.getTime()));
+            } catch (ParseException e) {
+                logger.error("[构建视频文件对象] 格式化时间失败, file:{}", file.getAbsolutePath(), e);
+                return null;
+            }
+            return videoFile;
+        }else {
+            return null;
+        }
+    }
+
+
+    public static int getStrCountInStr(String sourceStr, String content) {
+        int index = sourceStr.indexOf(content);
+        if (index < 0) {
+            return 0;
+        }
+        int count = 1;
+        int lastIndex = sourceStr.lastIndexOf(content);
+        while (index != lastIndex) {
+            index = sourceStr.indexOf(content, index + 1);
+            count++;
+        }
+        return count;
+    }
+
+}

+ 505 - 0
elevator-media-record/src/main/java/top/panll/assist/service/VideoFileService.java

@@ -0,0 +1,505 @@
+package top.panll.assist.service;
+
+import net.bramp.ffmpeg.FFprobe;
+import net.bramp.ffmpeg.probe.FFmpegProbeResult;
+import net.bramp.ffmpeg.progress.Progress;
+import org.apache.commons.io.FileUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.util.DigestUtils;
+import org.springframework.util.ObjectUtils;
+import top.panll.assist.controller.bean.ControllerException;
+import top.panll.assist.controller.bean.ErrorCode;
+import top.panll.assist.dto.*;
+import top.panll.assist.utils.RedisUtil;
+import top.panll.assist.utils.DateUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Service
+public class VideoFileService {
+
+    private final static Logger logger = LoggerFactory.getLogger(VideoFileService.class);
+
+    @Autowired
+    private UserSettings userSettings;
+
+    @Autowired
+    private RedisUtil redisUtil;
+
+    @Autowired
+    private FFmpegExecUtils ffmpegExecUtils;
+
+
+
+    private final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
+    private final SimpleDateFormat simpleDateFormatForTime = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+
+    public List<File> getAppList(Boolean sort) {
+        File recordFile = new File(userSettings.getRecordTempPath());
+        if (recordFile.isDirectory()) {
+            File[] files = recordFile.listFiles((File dir, String name) -> {
+                File currentFile = new File(dir.getAbsolutePath() + File.separator + name);
+                return  currentFile.isDirectory() && !name.equals("recordTemp");
+            });
+            List<File> result = Arrays.asList(files);
+            if (sort != null && sort) {
+                Collections.sort(result);
+            }
+            return result;
+        }else {
+            return null;
+        }
+    }
+
+    public SpaceInfo getSpaceInfo(){
+        File recordFile = new File(userSettings.getRecordTempPath());
+        SpaceInfo spaceInfo = new SpaceInfo();
+        spaceInfo.setFree(recordFile.getFreeSpace());
+        spaceInfo.setTotal(recordFile.getTotalSpace());
+        return spaceInfo;
+    }
+
+
+    public List<File> getStreamList(File appFile, Boolean sort) {
+        if (appFile != null && appFile.isDirectory()) {
+            File[] files = appFile.listFiles((File dir, String name) -> {
+                File currentFile = new File(dir.getAbsolutePath() + File.separator + name);
+                return  currentFile.isDirectory();
+            });
+            List<File> result = Arrays.asList(files);
+            if (sort != null && sort) {
+                Collections.sort(result);
+            }
+            return result;
+        }else {
+            return null;
+        }
+    }
+
+    /**
+     * 获取制定推流的指定时间段内的推流
+     * @param app
+     * @param stream
+     * @param startTime
+     * @param endTime
+     * @return
+     */
+    public List<File> getFilesInTime(String app, String stream, Date startTime, Date endTime){
+
+        List<File> result = new ArrayList<>();
+        if (app == null || stream == null) {
+            return result;
+        }
+
+        SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HHmmss");
+        SimpleDateFormat formatterForDate = new SimpleDateFormat("yyyy-MM-dd");
+        String startTimeStr = null;
+        String endTimeStr = null;
+        if (startTime != null) {
+            startTimeStr = formatter.format(startTime);
+        }
+        if (endTime != null) {
+            endTimeStr = formatter.format(endTime);
+        }
+
+        logger.debug("获取[app: {}, stream: {}, statime: {}, endTime: {}]的视频", app, stream,
+                startTimeStr, endTimeStr);
+
+        File recordFile = new File(userSettings.getRecordTempPath());
+        File streamFile = new File(recordFile.getAbsolutePath() + File.separator + app + File.separator + stream + File.separator);
+        if (!streamFile.exists()) {
+            logger.warn("获取[app: {}, stream: {}, statime: {}, endTime: {}]的视频时未找到目录: {}", app, stream,
+                    startTimeStr, endTimeStr, stream);
+            return null;
+        }
+
+        File[] dateFiles = streamFile.listFiles((File dir, String name) -> {
+            Date fileDate = null;
+            Date startDate = null;
+            Date endDate = null;
+            if (new File(dir + File.separator + name).isFile()) {
+                return false;
+            }
+            if (startTime != null) {
+                startDate = new Date(startTime.getTime() - ((startTime.getTime() + 28800000) % (86400000)));
+            }
+            if (endTime != null) {
+                endDate = new Date(endTime.getTime() - ((endTime.getTime() + 28800000) % (86400000)));
+            }
+            try {
+                fileDate = formatterForDate.parse(name);
+            } catch (ParseException e) {
+                logger.error("过滤日期文件时异常: {}-{}", name, e.getMessage());
+                return false;
+            }
+            boolean filterResult = true;
+
+            if (startDate != null) {
+                filterResult = filterResult &&  DateUtils.getStartOfDay(startDate).compareTo(fileDate) <= 0;
+            }
+
+            if (endDate != null) {
+                filterResult = filterResult &&  DateUtils.getEndOfDay(endDate).compareTo(fileDate) >= 0;
+            }
+
+            return filterResult ;
+        });
+
+        if (dateFiles != null && dateFiles.length > 0) {
+            for (File dateFile : dateFiles) {
+                File[] files = dateFile.listFiles((File dir, String name) ->{
+                    File currentFile = new File(dir + File.separator + name);
+                    VideoFile videoFile = VideoFileFactory.createFile(ffmpegExecUtils, currentFile);
+                    if (videoFile == null ) {
+                        return false;
+                    }else {
+                        if (!videoFile.isTargetFormat()) {
+                            return false;
+                        }
+                        if (startTime == null && endTime == null) {
+                           return true;
+                        }else if (startTime == null && endTime != null) {
+                            return videoFile.getEndTime().before(endTime)
+                                    || videoFile.getEndTime().equals(endTime)
+                                    || (videoFile.getEndTime().after(endTime) && videoFile.getStartTime().before(endTime));
+                        }else if (startTime != null && endTime == null) {
+                            return videoFile.getStartTime().after(startTime)
+                                    || videoFile.getStartTime().equals(startTime)
+                                    || (videoFile.getStartTime().before(startTime) && videoFile.getEndTime().after(startTime));
+                        }else {
+                            return videoFile.getStartTime().after(startTime)
+                                    || videoFile.getStartTime().equals(startTime)
+                                    || (videoFile.getStartTime().before(startTime) && videoFile.getEndTime().after(startTime))
+                                    || videoFile.getEndTime().before(endTime)
+                                    || videoFile.getEndTime().equals(endTime)
+                                    || (videoFile.getEndTime().after(endTime) && videoFile.getStartTime().before(endTime));
+                        }
+                    }
+                });
+                if (files != null && files.length > 0) {
+                    result.addAll(Arrays.asList(files));
+                }
+            }
+        }
+        if (!result.isEmpty()) {
+            result.sort((File f1, File f2) -> {
+                VideoFile videoFile1 = VideoFileFactory.createFile(ffmpegExecUtils, f1);
+                VideoFile videoFile2 = VideoFileFactory.createFile(ffmpegExecUtils, f2);
+                if (videoFile1 == null || !videoFile1.isTargetFormat() || videoFile2 == null || !videoFile2.isTargetFormat()) {
+                    logger.warn("[根据时间获取视频文件] 排序错误,文件错误: {}/{}", f1.getName(), f2.getName());
+                    return 0;
+                }
+                return videoFile1.getStartTime().compareTo(videoFile2.getStartTime());
+            });
+        }
+        return result;
+    }
+
+
+    public String mergeOrCut(VideoTaskInfo videoTaskInfo) {
+        assert videoTaskInfo.getFilePathList() != null;
+        assert !videoTaskInfo.getFilePathList().isEmpty();
+        String taskId = DigestUtils.md5DigestAsHex(String.valueOf(System.currentTimeMillis()).getBytes());
+        String logInfo = String.format("app: %S, stream: %S, callId: %S,  任务ID:%S",
+                videoTaskInfo.getApp(), videoTaskInfo.getStream(), videoTaskInfo.getCallId(), taskId);
+        logger.info("[录像合并] 开始合并,{} ", logInfo);
+        List<File> fileList = new ArrayList<>();
+        for (String filePath : videoTaskInfo.getFilePathList()) {
+            File file = new File(filePath);
+            if (!file.exists()) {
+                logger.info("[录像合并] 失败,{} ", logInfo);
+                throw new ControllerException(ErrorCode.ERROR100.getCode(), filePath + "文件不存在");
+            }
+            logger.info("[录像合并] 添加文件,{}, 文件: {}", logInfo, filePath);
+            fileList.add(file);
+        }
+
+        File recordFile = new File(userSettings.getRecordTempPath() );
+        if (!recordFile.exists()) {
+            if (!recordFile.mkdirs()) {
+                logger.info("[录像合并] 失败,{}, 创建临时目录失败", logInfo);
+                throw new ControllerException(ErrorCode.ERROR100.getCode(), "创建临时目录失败");
+            }
+        }
+        MergeOrCutTaskInfo mergeOrCutTaskInfo = new MergeOrCutTaskInfo();
+        mergeOrCutTaskInfo.setId(taskId);
+        mergeOrCutTaskInfo.setApp(videoTaskInfo.getApp());
+        mergeOrCutTaskInfo.setStream(videoTaskInfo.getStream());
+        mergeOrCutTaskInfo.setCallId(videoTaskInfo.getCallId());
+        mergeOrCutTaskInfo.setStartTime(videoTaskInfo.getStartTime());
+        mergeOrCutTaskInfo.setEndTime(videoTaskInfo.getEndTime());
+        mergeOrCutTaskInfo.setCreateTime(simpleDateFormatForTime.format(System.currentTimeMillis()));
+        String destFileName = videoTaskInfo.getStream() + "_" + videoTaskInfo.getCallId();
+        if (fileList.size() == 1) {
+
+            // 文件只有一个则不合并,直接复制过去
+            mergeOrCutTaskInfo.setPercentage("1");
+            // 处理文件路径
+            String recordFileResultPath = recordFile.getAbsolutePath() + File.separator + destFileName + ".mp4";
+            File destFile = new File(recordFileResultPath);
+            destFile.deleteOnExit();
+            try {
+                Files.copy(fileList.get(0).toPath(), Paths.get(recordFileResultPath));
+            } catch (IOException e) {
+                logger.info("[录像合并] 失败, {}", logInfo, e);
+                throw new ControllerException(ErrorCode.ERROR100.getCode(), e.getMessage());
+            }
+            mergeOrCutTaskInfo.setRecordFile("/download/" + destFileName + ".mp4");
+            if (videoTaskInfo.getRemoteHost() != null) {
+                mergeOrCutTaskInfo.setDownloadFile(videoTaskInfo.getRemoteHost() + "/download.html?url=download/" + destFileName + ".mp4");
+                mergeOrCutTaskInfo.setPlayFile(videoTaskInfo.getRemoteHost() + "/download/" + destFileName + ".mp4");
+            }
+            String key = String.format("%S_%S_%S", AssistConstants.MERGEORCUT , userSettings.getId(), mergeOrCutTaskInfo.getId());
+            redisUtil.set(key, mergeOrCutTaskInfo);
+            logger.info("[录像合并] 成功, 任务ID:{}", taskId);
+        }else {
+            ffmpegExecUtils.mergeOrCutFile(fileList, recordFile, destFileName, (status, percentage, result)->{
+                // 发出redis通知
+                if (status.equals(Progress.Status.END.name())) {
+                    mergeOrCutTaskInfo.setPercentage("1");
+
+                    // 处理文件路径
+                    String relativize = new File(result).getName();
+                    mergeOrCutTaskInfo.setRecordFile(relativize.toString());
+                    if (videoTaskInfo.getRemoteHost() != null) {
+                        mergeOrCutTaskInfo.setDownloadFile(videoTaskInfo.getRemoteHost() + "/download.html?url=download/" + relativize);
+                        mergeOrCutTaskInfo.setPlayFile(videoTaskInfo.getRemoteHost() + "/download/" + relativize);
+                    }
+                    logger.info("[录像合并] 成功, {}", logInfo);
+                }else {
+                    mergeOrCutTaskInfo.setPercentage(percentage + "");
+                }
+                String key = String.format("%S_%S_%S", AssistConstants.MERGEORCUT, userSettings.getId(), mergeOrCutTaskInfo.getId());
+                redisUtil.set(key, mergeOrCutTaskInfo);
+            });
+        }
+
+        return taskId;
+    }
+
+    public List<File> getDateList(File streamFile, Integer year, Integer month, Boolean sort) {
+        if (!streamFile.exists() && streamFile.isDirectory()) {
+            logger.warn("获取[]的视频时未找到目录: {}",streamFile.getName());
+            return null;
+        }
+        File[] dateFiles = streamFile.listFiles((File dir, String name)->{
+            File currentFile = new File(dir.getAbsolutePath() + File.separator + name);
+            if (!currentFile.isDirectory()){
+                return false;
+            }
+            Date date = null;
+            try {
+                date = simpleDateFormat.parse(name);
+            } catch (ParseException e) {
+                logger.error("格式化时间{}错误", name);
+                return false;
+            }
+            Calendar c = Calendar.getInstance();
+            c.setTime(date);
+            int y = c.get(Calendar.YEAR);
+            int m = c.get(Calendar.MONTH) + 1;
+            if (year != null) {
+                if (month != null) {
+                    return  y == year && m == month;
+                }else {
+                    return  y == year;
+                }
+            }else {
+                return true;
+            }
+
+        });
+        if (dateFiles == null) {
+            return new ArrayList<>();
+        }
+        List<File> dateFileList = Arrays.asList(dateFiles);
+        if (sort != null && sort) {
+            dateFileList.sort((File f1, File f2)->{
+                int sortResult = 0;
+
+                try {
+                    sortResult = simpleDateFormat.parse(f1.getName()).compareTo(simpleDateFormat.parse(f2.getName()));
+                } catch (ParseException e) {
+                    logger.error("格式化时间{}/{}错误", f1.getName(), f2.getName());
+                }
+                return sortResult;
+            });
+        }
+
+        return dateFileList;
+    }
+
+    public List<MergeOrCutTaskInfo> getTaskListForDownload(String app, String stream, String callId, Boolean isEnd, String taskId) {
+        logger.info("[查询录像合成列表] app: {}, stream: {}, callId: {}, isEnd: {}, taskId: {}",
+                app, stream, callId, isEnd, taskId);
+        ArrayList<MergeOrCutTaskInfo> result = new ArrayList<>();
+        if (taskId == null) {
+            taskId = "*";
+        }
+        List<Object> taskCatch = redisUtil.scan(String.format("%S_%S_%S", AssistConstants.MERGEORCUT,
+                userSettings.getId(), taskId));
+        for (int i = 0; i < taskCatch.size(); i++) {
+            String keyItem = taskCatch.get(i).toString();
+            MergeOrCutTaskInfo mergeOrCutTaskInfo = (MergeOrCutTaskInfo)redisUtil.get(keyItem);
+            if (mergeOrCutTaskInfo != null){
+                if ((!ObjectUtils.isEmpty(app) && !mergeOrCutTaskInfo.getApp().equals(app))
+                        || (!ObjectUtils.isEmpty(stream) && !mergeOrCutTaskInfo.getStream().equals(stream))
+                        || (!ObjectUtils.isEmpty(callId) && !mergeOrCutTaskInfo.getCallId().equals(callId))
+                ) {
+                    continue;
+                }
+                if (mergeOrCutTaskInfo.getPercentage() != null){
+                    if (isEnd != null ) {
+                        if (isEnd) {
+                            if (Double.parseDouble(mergeOrCutTaskInfo.getPercentage()) == 1){
+                                result.add(mergeOrCutTaskInfo);
+                            }
+                        }else {
+                            if (Double.parseDouble(mergeOrCutTaskInfo.getPercentage()) < 1){
+                                result.add((MergeOrCutTaskInfo)redisUtil.get(keyItem));
+                            }
+                        }
+                    }else {
+                        result.add((MergeOrCutTaskInfo)redisUtil.get(keyItem));
+                    }
+                }
+            }
+        }
+        result.sort((MergeOrCutTaskInfo m1, MergeOrCutTaskInfo m2)->{
+            int sortResult = 0;
+            try {
+                sortResult = simpleDateFormatForTime.parse(m1.getCreateTime()).compareTo(simpleDateFormatForTime.parse(m2.getCreateTime()));
+                if (sortResult == 0) {
+                    sortResult = simpleDateFormatForTime.parse(m1.getCreateTime()).compareTo(simpleDateFormatForTime.parse(m2.getCreateTime()));
+                }
+                if (sortResult == 0) {
+                    sortResult = simpleDateFormatForTime.parse(m1.getCreateTime()).compareTo(simpleDateFormatForTime.parse(m2.getCreateTime()));
+                }
+            } catch (ParseException e) {
+                e.printStackTrace();
+            }
+            return sortResult * -1;
+        });
+
+        return result;
+    }
+
+    public boolean collection(String app, String stream, String type) {
+        File streamFile = new File(userSettings.getRecordTempPath() + File.separator + app + File.separator + stream);
+        boolean result = false;
+        if (streamFile.exists() && streamFile.isDirectory() && streamFile.canWrite()) {
+            File signFile = new File(streamFile.getAbsolutePath() + File.separator + type + ".sign");
+            try {
+                result = signFile.createNewFile();
+            } catch (IOException e) {
+                logger.error("[收藏文件]失败,{}/{}", app, stream);
+            }
+        }
+        return result;
+    }
+
+    public boolean removeCollection(String app, String stream, String type) {
+        File signFile = new File(userSettings.getRecordTempPath() + File.separator + app + File.separator + stream + File.separator + type + ".sign");
+        boolean result = false;
+        if (signFile.exists() && signFile.isFile()) {
+            result = signFile.delete();
+        }
+        return result;
+    }
+
+    public List<SignInfo> getCollectionList(String app, String stream, String type) {
+        List<File> appList = this.getAppList(true);
+        List<SignInfo> result = new ArrayList<>();
+        if (appList.size() > 0) {
+            for (File appFile : appList) {
+                if (app != null) {
+                    if (!app.equals(appFile.getName())) {
+                        continue;
+                    }
+                }
+                List<File> streamList = getStreamList(appFile, true);
+                if (streamList.size() > 0) {
+                    for (File streamFile : streamList) {
+                        if (stream != null) {
+                            if (!stream.equals(streamFile.getName())) {
+                                continue;
+                            }
+                        }
+
+                        if (type != null) {
+                            File signFile = new File(streamFile.getAbsolutePath() + File.separator + type + ".sign");
+                            if (signFile.exists()) {
+                                SignInfo signInfo = new SignInfo();
+                                signInfo.setApp(appFile.getName());
+                                signInfo.setStream(streamFile.getName());
+                                signInfo.setType(type);
+                                result.add(signInfo);
+                            }
+                        }else {
+                            streamFile.listFiles((File dir, String name) -> {
+                                File currentFile = new File(dir.getAbsolutePath() + File.separator + name);
+                                if (currentFile.isFile() && name.endsWith(".sign")){
+                                    String currentType = name.substring(0, name.length() - ".sign".length());
+                                    SignInfo signInfo = new SignInfo();
+                                    signInfo.setApp(appFile.getName());
+                                    signInfo.setStream(streamFile.getName());
+                                    signInfo.setType(currentType);
+                                    result.add(signInfo);
+                                }
+                                return false;
+                            });
+                        }
+                    }
+                }
+            }
+        }
+        return result;
+    }
+
+    public long fileDuration(String app, String stream) {
+        List<File> allFiles = getFilesInTime(app, stream, null, null);
+        long durationResult = 0;
+        if (allFiles != null && allFiles.size() > 0) {
+            for (File file : allFiles) {
+                try {
+                    durationResult += ffmpegExecUtils.duration(file);
+                } catch (IOException e) {
+                    logger.error("获取{}视频时长错误:{}", file.getAbsolutePath(), e.getMessage());
+                }
+            }
+        }
+        return durationResult;
+    }
+
+    public int deleteFile(List<String> filePathList) {
+        assert filePathList != null;
+        assert filePathList.isEmpty();
+        int deleteResult = 0;
+        for (String filePath : filePathList) {
+            File file = new File(filePath);
+            if (file.exists()) {
+                if (file.delete()) {
+                    deleteResult ++;
+                }
+            }else {
+                logger.warn("[删除文件] 文件不存在,{}", filePath);
+            }
+        }
+        if (deleteResult == 0) {
+            throw new ControllerException(ErrorCode.ERROR100.getCode(), "未删除任何文件");
+        }
+        return deleteResult;
+    }
+}

+ 46 - 0
elevator-media-record/src/main/java/top/panll/assist/utils/DateUtils.java

@@ -0,0 +1,46 @@
+package top.panll.assist.utils;
+
+import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Date;
+import java.util.Locale;
+
+public class DateUtils {
+
+    public static final String PATTERNForDateTime = "yyyy-MM-dd HH:mm:ss";
+
+    public static final String PATTERNForDate = "yyyy-MM-dd";
+
+    public static final String zoneStr = "Asia/Shanghai";
+
+
+
+    // 获得某天最大时间 2020-02-19 23:59:59
+    public static Date getEndOfDay(Date date) {
+        LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(date.getTime()), ZoneId.systemDefault());;
+        LocalDateTime endOfDay = localDateTime.with(LocalTime.MAX);
+        return Date.from(endOfDay.atZone(ZoneId.systemDefault()).toInstant());
+    }
+
+    // 获得某天最小时间 2020-02-17 00:00:00
+    public static Date getStartOfDay(Date date) {
+        LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(date.getTime()), ZoneId.systemDefault());
+        LocalDateTime startOfDay = localDateTime.with(LocalTime.MIN);
+        return Date.from(startOfDay.atZone(ZoneId.systemDefault()).toInstant());
+    }
+
+    public static String getDateStr(Date date) {
+        SimpleDateFormat formatter =  new SimpleDateFormat(PATTERNForDate);
+        return formatter.format(date);
+    }
+
+    public static String getDateTimeStr(Date date) {
+        SimpleDateFormat formatter =  new SimpleDateFormat(PATTERNForDateTime);
+        return formatter.format(date);
+    }
+
+}

+ 723 - 0
elevator-media-record/src/main/java/top/panll/assist/utils/RedisUtil.java

@@ -0,0 +1,723 @@
+package top.panll.assist.utils;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.*;
+import org.springframework.stereotype.Component;
+import org.springframework.util.CollectionUtils;
+
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+/**    
+ * @Description:Redis工具类
+ * @author: swwheihei
+ * @date:   2020年5月6日 下午8:27:29     
+ */
+@Component
+@SuppressWarnings(value = {"rawtypes", "unchecked"})
+public class RedisUtil {
+
+	@Autowired
+    private RedisTemplate<Object, Object> redisTemplate;
+	
+	/**
+     * 指定缓存失效时间
+     * @param key 键
+     * @param time 时间(秒)
+     * @return true / false
+     */
+    public boolean expire(String key, long time) {
+        try {
+            if (time > 0) {
+                redisTemplate.expire(key, time, TimeUnit.SECONDS);
+            }
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        }
+
+    }
+
+    /**
+     * 根据 key 获取过期时间
+     * @param key 键
+     * @return
+     */
+    public long getExpire(String key) {
+        return redisTemplate.getExpire(key, TimeUnit.SECONDS);
+    }
+
+    /**
+     * 判断 key 是否存在
+     * @param key 键
+     * @return true / false
+     */
+    public boolean hasKey(String key) {
+        try {
+            return redisTemplate.hasKey(key);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        }
+    }
+
+    /**
+     * 删除缓存
+     * @SuppressWarnings("unchecked") 忽略类型转换警告
+     * @param key 键(一个或者多个)
+     */
+    public boolean del(String... key) {
+    	try {
+    		if (key != null && key.length > 0) {
+                if (key.length == 1) {
+                    redisTemplate.delete(key[0]);
+                } else {
+//                    传入一个 Collection<String> 集合
+                    redisTemplate.delete(CollectionUtils.arrayToList(key));
+                }
+            }
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        }
+    }
+
+//    ============================== String ==============================
+
+    /**
+     * 普通缓存获取
+     * @param key 键
+     * @return 值
+     */
+    public Object get(String key) {
+        return key == null ? null : redisTemplate.opsForValue().get(key);
+    }
+
+    /**
+     * 普通缓存放入
+     * @param key 键
+     * @param value 值
+     * @return true / false
+     */
+    public boolean set(String key, Object value) {
+        try {
+            redisTemplate.opsForValue().set(key, value);
+            return true;
+        } catch (Exception e) {
+//            e.printStackTrace();
+            return false;
+        }
+    }
+
+    /**
+     * 普通缓存放入并设置时间
+     * @param key 键
+     * @param value 值
+     * @param time 时间(秒),如果 time < 0 则设置无限时间
+     * @return true / false
+     */
+    public boolean set(String key, Object value, long time) {
+        try {
+            if (time > 0) {
+                redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
+            } else {
+                set(key, value);
+            }
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        }
+    }
+
+    /**
+     * 递增
+     * @param key 键
+     * @param delta 递增大小
+     * @return
+     */
+    public long incr(String key, long delta) {
+        if (delta < 0) {
+            throw new RuntimeException("递增因子必须大于 0");
+        }
+        return redisTemplate.opsForValue().increment(key, delta);
+    }
+
+    /**
+     * 递减
+     * @param key 键
+     * @param delta 递减大小
+     * @return
+     */
+    public long decr(String key, long delta) {
+        if (delta < 0) {
+            throw new RuntimeException("递减因子必须大于 0");
+        }
+        return redisTemplate.opsForValue().increment(key, delta);
+    }
+
+//    ============================== Map ==============================
+
+    /**
+     * HashGet
+     * @param key 键(no null)
+     * @param item 项(no null)
+     * @return 值
+     */
+    public Object hget(String key, String item) {
+        return redisTemplate.opsForHash().get(key, item);
+    }
+
+    /**
+     * 获取 key 对应的 map
+     * @param key 键(no null)
+     * @return 对应的多个键值
+     */
+    public Map<Object, Object> hmget(String key) {
+        return redisTemplate.opsForHash().entries(key);
+    }
+
+    /**
+     * HashSet
+     * @param key 键
+     * @param map 值
+     * @return true / false
+     */
+    public boolean hmset(String key, Map<Object, Object> map) {
+        try {
+            redisTemplate.opsForHash().putAll(key, map);
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        }
+    }
+
+    /**
+     * HashSet 并设置时间
+     * @param key 键
+     * @param map 值
+     * @param time 时间
+     * @return true / false
+     */
+    public boolean hmset(String key, Map<Object, Object> map, long time) {
+        try {
+            redisTemplate.opsForHash().putAll(key, map);
+            if (time > 0) {
+                expire(key, time);
+            }
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        }
+    }
+
+    /**
+     * 向一张 Hash表 中放入数据,如不存在则创建
+     * @param key 键
+     * @param item 项
+     * @param value 值
+     * @return true / false
+     */
+    public boolean hset(String key, String item, Object value) {
+        try {
+            redisTemplate.opsForHash().put(key, item, value);
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        }
+    }
+
+    /**
+     * 向一张 Hash表 中放入数据,并设置时间,如不存在则创建
+     * @param key 键
+     * @param item 项
+     * @param value 值
+     * @param time 时间(如果原来的 Hash表 设置了时间,这里会覆盖)
+     * @return true / false
+     */
+    public boolean hset(String key, String item, Object value, long time) {
+        try {
+            redisTemplate.opsForHash().put(key, item, value);
+            if (time > 0) {
+                expire(key, time);
+            }
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        }
+    }
+
+    /**
+     * 删除 Hash表 中的值
+     * @param key 键
+     * @param item 项(可以多个,no null)
+     */
+    public void hdel(String key, Object... item) {
+        redisTemplate.opsForHash().delete(key, item);
+    }
+
+    /**
+     * 判断 Hash表 中是否有该键的值
+     * @param key 键(no null)
+     * @param item 值(no null)
+     * @return true / false
+     */
+    public boolean hHasKey(String key, String item) {
+        return redisTemplate.opsForHash().hasKey(key, item);
+    }
+
+    /**
+     * Hash递增,如果不存在则创建一个,并把新增的值返回
+     * @param key 键
+     * @param item 项
+     * @param by 递增大小 > 0
+     * @return
+     */
+    public Double hincr(String key, String item, Double by) {
+        return redisTemplate.opsForHash().increment(key, item, by);
+    }
+
+    /**
+     * Hash递减
+     * @param key 键
+     * @param item 项
+     * @param by 递减大小
+     * @return
+     */
+    public Double hdecr(String key, String item, Double by) {
+        return redisTemplate.opsForHash().increment(key, item, -by);
+    }
+
+//    ============================== Set ==============================
+
+    /**
+     * 根据 key 获取 set 中的所有值
+     * @param key 键
+     * @return 值
+     */
+    public Set<Object> sGet(String key) {
+        try {
+            return redisTemplate.opsForSet().members(key);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return null;
+        }
+    }
+
+    /**
+     * 从键为 key 的 set 中,根据 value 查询是否存在
+     * @param key 键
+     * @param value 值
+     * @return true / false
+     */
+    public boolean sHasKey(String key, Object value) {
+        try {
+            return redisTemplate.opsForSet().isMember(key, value);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        }
+    }
+
+    /**
+     * 将数据放入 set缓存
+     * @param key 键值
+     * @param values 值(可以多个)
+     * @return 成功个数
+     */
+    public long sSet(String key, Object... values) {
+        try {
+            return redisTemplate.opsForSet().add(key, values);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return 0;
+        }
+    }
+
+    /**
+     * 将数据放入 set缓存,并设置时间
+     * @param key 键
+     * @param time 时间
+     * @param values 值(可以多个)
+     * @return 成功放入个数
+     */
+    public long sSet(String key, long time, Object... values) {
+        try {
+            long count = redisTemplate.opsForSet().add(key, values);
+            if (time > 0) {
+                expire(key, time);
+            }
+            return count;
+        } catch (Exception e) {
+            e.printStackTrace();
+            return 0;
+        }
+    }
+
+    /**
+     * 获取 set缓存的长度
+     * @param key 键
+     * @return 长度
+     */
+    public long sGetSetSize(String key) {
+        try {
+            return redisTemplate.opsForSet().size(key);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return 0;
+        }
+    }
+
+    /**
+     * 移除 set缓存中,值为 value 的
+     * @param key 键
+     * @param values 值
+     * @return 成功移除个数
+     */
+    public long setRemove(String key, Object... values) {
+        try {
+            return redisTemplate.opsForSet().remove(key, values);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return 0;
+        }
+    }
+//    ============================== ZSet ==============================
+
+    /**
+     * 添加一个元素, zset与set最大的区别就是每个元素都有一个score,因此有个排序的辅助功能;  zadd
+     *
+     * @param key
+     * @param value
+     * @param score
+     */
+    public void zAdd(Object key, Object value, double score) {
+        redisTemplate.opsForZSet().add(key, value, score);
+    }
+
+    /**
+     * 删除元素 zrem
+     *
+     * @param key
+     * @param value
+     */
+    public void zRemove(Object key, Object value) {
+        redisTemplate.opsForZSet().remove(key, value);
+    }
+
+    /**
+     * score的增加or减少 zincrby
+     *
+     * @param key
+     * @param value
+     * @param score
+     */
+    public Double zIncrScore(Object key, Object value, double score) {
+        return redisTemplate.opsForZSet().incrementScore(key, value, score);
+    }
+
+    /**
+     * 查询value对应的score   zscore
+     *
+     * @param key
+     * @param value
+     * @return
+     */
+    public Double zScore(Object key, Object value) {
+        return redisTemplate.opsForZSet().score(key, value);
+    }
+
+    /**
+     * 判断value在zset中的排名  zrank
+     *
+     * @param key
+     * @param value
+     * @return
+     */
+    public Long zRank(Object key, Object value) {
+        return redisTemplate.opsForZSet().rank(key, value);
+    }
+
+    /**
+     * 返回集合的长度
+     *
+     * @param key
+     * @return
+     */
+    public Long zSize(Object key) {
+        return redisTemplate.opsForZSet().zCard(key);
+    }
+
+    /**
+     * 查询集合中指定顺序的值, 0 -1 表示获取全部的集合内容  zrange
+     *
+     * 返回有序的集合,score小的在前面
+     *
+     * @param key
+     * @param start
+     * @param end
+     * @return
+     */
+    public Set<Object> ZRange(Object key, int start, int end) {
+        return redisTemplate.opsForZSet().range(key, start, end);
+    }
+    /**
+     * 查询集合中指定顺序的值和score,0, -1 表示获取全部的集合内容
+     *
+     * @param key
+     * @param start
+     * @param end
+     * @return
+     */
+    public Set<ZSetOperations.TypedTuple<Object>> zRangeWithScore(Object key, int start, int end) {
+        return redisTemplate.opsForZSet().rangeWithScores(key, start, end);
+    }
+    /**
+     * 查询集合中指定顺序的值  zrevrange
+     *
+     * 返回有序的集合中,score大的在前面
+     *
+     * @param key
+     * @param start
+     * @param end
+     * @return
+     */
+    public Set<Object> zRevRange(Object key, int start, int end) {
+        return redisTemplate.opsForZSet().reverseRange(key, start, end);
+    }
+    /**
+     * 根据score的值,来获取满足条件的集合  zrangebyscore
+     *
+     * @param key
+     * @param min
+     * @param max
+     * @return
+     */
+    public Set<Object> zSortRange(Object key, int min, int max) {
+        return redisTemplate.opsForZSet().rangeByScore(key, min, max);
+    }
+
+
+//    ============================== List ==============================
+
+    /**
+     * 获取 list缓存的内容
+     * @param key 键
+     * @param start 开始
+     * @param end 结束(0 到 -1 代表所有值)
+     * @return
+     */
+    public List<Object> lGet(String key, long start, long end) {
+        try {
+            return redisTemplate.opsForList().range(key, start, end);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return null;
+        }
+    }
+
+    /**
+     * 获取 list缓存的长度
+     * @param key 键
+     * @return 长度
+     */
+    public long lGetListSize(String key) {
+        try {
+            return redisTemplate.opsForList().size(key);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return 0;
+        }
+    }
+
+    /**
+     * 根据索引 index 获取键为 key 的 list 中的元素
+     * @param key 键
+     * @param index 索引
+     *              当 index >= 0 时 {0:表头, 1:第二个元素}
+     *              当 index < 0 时 {-1:表尾, -2:倒数第二个元素}
+     * @return 值
+     */
+    public Object lGetIndex(String key, long index) {
+        try {
+            return redisTemplate.opsForList().index(key, index);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return null;
+        }
+    }
+
+    /**
+     * 将值 value 插入键为 key 的 list 中,如果 list 不存在则创建空 list
+     * @param key 键
+     * @param value 值
+     * @return true / false
+     */
+    public boolean lSet(String key, Object value) {
+        try {
+            redisTemplate.opsForList().rightPush(key, value);
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        }
+    }
+
+    /**
+     * 将值 value 插入键为 key 的 list 中,并设置时间
+     * @param key 键
+     * @param value 值
+     * @param time 时间
+     * @return true / false
+     */
+    public boolean lSet(String key, Object value, long time) {
+        try {
+            redisTemplate.opsForList().rightPush(key, value);
+            if (time > 0) {
+                expire(key, time);
+            }
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        }
+    }
+
+    /**
+     * 将 values 插入键为 key 的 list 中
+     * @param key 键
+     * @param values 值
+     * @return true / false
+     */
+    public boolean lSetList(String key, List<Object> values) {
+        try {
+            redisTemplate.opsForList().rightPushAll(key, values);
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        }
+    }
+
+    /**
+     * 将 values 插入键为 key 的 list 中,并设置时间
+     * @param key 键
+     * @param values 值
+     * @param time 时间
+     * @return true / false
+     */
+    public boolean lSetList(String key, List<Object> values, long time) {
+        try {
+            redisTemplate.opsForList().rightPushAll(key, values);
+            if (time > 0) {
+                expire(key, time);
+            }
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        }
+    }
+
+    /**
+     * 根据索引 index 修改键为 key 的值
+     * @param key 键
+     * @param index 索引
+     * @param value 值
+     * @return true / false
+     */
+    public boolean lUpdateIndex(String key, long index, Object value) {
+        try {
+            redisTemplate.opsForList().set(key, index, value);
+            return true;
+        } catch (Exception e) {
+            e.printStackTrace();
+            return false;
+        }
+    }
+
+    /**
+     * 在键为 key 的 list 中删除值为 value 的元素
+     * @param key 键
+     * @param count 如果 count == 0 则删除 list 中所有值为 value 的元素
+     *              如果 count > 0 则删除 list 中最左边那个值为 value 的元素
+     *              如果 count < 0 则删除 list 中最右边那个值为 value 的元素
+     * @param value
+     * @return
+     */
+    public long lRemove(String key, long count, Object value) {
+        try {
+            return redisTemplate.opsForList().remove(key, count, value);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return 0;
+        }
+    }
+
+    /**
+     * 模糊查询
+     * @param key 键
+     * @return true / false
+     */
+    public List<Object> keys(String key) {
+        try {
+            Set<Object> set = redisTemplate.keys(key);
+            return new ArrayList<>(set);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return null;
+        }
+    }
+
+
+    /**
+     * 模糊查询
+     * @param query 查询参数
+     * @return
+     */
+//    public List<Object> scan(String query) {
+//        List<Object> result = new ArrayList<>();
+//        try {
+//            Cursor<Map.Entry<Object,Object>> cursor = redisTemplate.opsForHash().scan("field",
+//                    ScanOptions.scanOptions().match(query).count(1000).build());
+//            while (cursor.hasNext()) {
+//                Map.Entry<Object,Object> entry = cursor.next();
+//                result.add(entry.getKey());
+//                Object key = entry.getKey();
+//                Object valueSet = entry.getValue();
+//            }
+//            //关闭cursor
+//            cursor.close();
+//        } catch (Exception e) {
+//            e.printStackTrace();
+//        }
+//        return result;
+//    }
+
+    /**
+     * 模糊查询
+     * @param query 查询参数
+     * @return
+     */
+    public List<Object> scan(String query) {
+        Set<String> resultKeys = redisTemplate.execute((RedisCallback<Set<String>>) connection -> {
+            ScanOptions scanOptions = ScanOptions.scanOptions().match("*" + query + "*").count(1000).build();
+            Cursor<byte[]> scan = connection.scan(scanOptions);
+            Set<String> keys = new HashSet<>();
+            while (scan.hasNext()) {
+                byte[] next = scan.next();
+                keys.add(new String(next));
+            }
+            return keys;
+        });
+
+        return new ArrayList<>(resultKeys);
+    }
+
+}

+ 57 - 0
elevator-media-record/src/main/resources/all-application.yml

@@ -0,0 +1,57 @@
+spring:
+    # REDIS数据库配置
+    redis:
+        # [必须修改] Redis服务器IP, REDIS安装在本机的,使用127.0.0.1
+        host: 127.0.0.1
+        # [必须修改] 端口号
+        port: 6379
+        # [可选] 数据库 DB
+        database: 8
+        # [可选] 访问密码,若你的redis服务器没有设置密码,就不需要用密码去连接
+        password:
+        # [可选] 超时时间
+        timeout: 10000
+
+# [必选] WVP监听的HTTP端口, 网页和接口调用都是这个端口
+server:
+    port: 18081
+    # [可选] HTTPS配置, 默认不开启
+    ssl:
+        # [可选] 是否开启HTTPS访问
+        enabled: false
+        # [可选] 证书文件路径,放置在resource/目录下即可,修改xxx为文件名
+        key-store: classpath:xxx.jks
+        # [可选] 证书密码
+        key-password: password
+        # [可选] 证书类型, 默认为jks,根据实际修改
+        key-store-type: JKS
+
+# [根据业务需求配置]
+user-settings:
+    # [可选 ] 临时录像路径
+    record-temp-path: ./recordTemp
+    # [可选 ] 录像保存时长(单位: 天)每天晚12点自动对过期文件执行清理, 不配置则不删除
+    record-temp-day: 7
+    # [可选 ] 录像下载合成临时文件保存时长, 不配置默认取值recordDay(单位: 天)每天晚12点自动对过期文件执行清理
+    # recordTempDay: 7
+    # [必选 ] ffmpeg路径
+    ffmpeg: /usr/bin/ffmpeg
+    # [必选 ] ffprobe路径, 一般安装ffmpeg就会自带, 一般跟ffmpeg在同一目录,用于查询文件的信息
+    ffprobe: /usr/bin/ffprobe
+    # [可选 ] 限制 ffmpeg 合并文件使用的线程数,间接限制cpu使用率, 默认2 限制到50%
+    threads: 2
+
+swagger-ui:
+
+# [可选] 日志配置, 一般不需要改
+logging:
+    file:
+        name: logs/wvp.log
+        max-history: 30
+        max-size: 10MB
+        total-size-cap: 300MB
+    level:
+        root: WARN
+        top:
+            panll:
+                assist: info

+ 58 - 0
elevator-media-record/src/main/resources/application-dev.yml

@@ -0,0 +1,58 @@
+spring:
+    # REDIS数据库配置
+    redis:
+        # [必须修改] Redis服务器IP, REDIS安装在本机的,使用127.0.0.1
+        host: 192.168.2.120
+        # [必须修改] 端口号
+        port: 6379
+        # [可选] 数据库 DB
+        database: 8
+        # [可选] 访问密码,若你的redis服务器没有设置密码,就不需要用密码去连接
+        password: Hycpb@123
+        # [可选] 超时时间
+        timeout: 10000
+
+# [可选] WVP监听的HTTP端口, 网页和接口调用都是这个端口
+server:
+    port: 18081
+    # [可选] HTTPS配置, 默认不开启
+    ssl:
+        # [可选] 是否开启HTTPS访问
+        enabled: false
+        # [可选] 证书文件路径,放置在resource/目录下即可,修改xxx为文件名
+        key-store: classpath:xxx.jks
+        # [可选] 证书密码
+        key-password: password
+        # [可选] 证书类型, 默认为jks,根据实际修改
+        key-store-type: JKS
+
+# [根据业务需求配置]
+userSettings:
+    id: 33443
+    # [必选 ] ffmpeg路径
+    ffmpeg: /usr/bin/ffmpeg
+    # [必选 ] ffprobe路径, 一般安装ffmpeg就会自带, 一般跟ffmpeg在同一目录,用于查询文件的信息
+    ffprobe: /usr/bin/ffprobe
+    # [可选 ] 限制 ffmpeg 合并文件使用的线程数,间接限制cpu使用率, 默认2 限制到50%
+    threads: 2
+    # [可选 ] 临时录像路径
+    record-temp: /home/ZLMediaKit/release/linux/Debug/www/record
+    # [可选 ] 录像保存时长(单位: 天)每天晚12点自动对过期文件执行清理, 不配置则不删除
+    record-temp-day: 7
+    # [可选 ] 录像下载合成临时文件保存时长, 不配置默认取值recordDay(单位: 天)每天晚12点自动对过期文件执行清理
+    # recordTempDay: 7
+
+swagger-ui:
+
+# [可选] 日志配置, 一般不需要改
+logging:
+    file:
+        name: /home/wvp-pro-assist/logs/wvp.log
+        max-history: 30
+        max-size: 10MB
+        total-size-cap: 300MB
+    level:
+        root: INFO
+        top:
+            panll:
+                assist: info

+ 57 - 0
elevator-media-record/src/main/resources/application-local.yml

@@ -0,0 +1,57 @@
+spring:
+    # REDIS数据库配置
+    redis:
+        # [可选] 超时时间
+        timeout: 10000
+        # 以下为单机配置
+        # [必须修改] Redis服务器IP, REDIS安装在本机的,使用127.0.0.1
+        host: 127.0.0.1
+        # [必须修改] 端口号
+        port: 6379
+        # [可选] 数据库 DB
+        database: 1
+        # [可选] 访问密码,若你的redis服务器没有设置密码,就不需要用密码去连接
+        password:
+        # 以下为集群配置
+#        cluster:
+#            nodes: 192.168.1.242:7001
+#        password: 4767cb971b40a1300fa09b7f87b09d1c
+
+# [可选] WVP监听的HTTP端口, 网页和接口调用都是这个端口
+server:
+    port: 18089
+    # [可选] HTTPS配置, 默认不开启
+    ssl:
+        # [可选] 是否开启HTTPS访问
+        enabled: false
+        # [可选] 证书文件路径,放置在resource/目录下即可,修改xxx为文件名
+        key-store: classpath:xxx.jks
+        # [可选] 证书密码
+        key-password: password
+        # [可选] 证书类型, 默认为jks,根据实际修改
+        key-store-type: JKS
+
+# [根据业务需求配置]
+userSettings:
+    id: 111
+    # [可选 ] 录像保存时长(单位: 天)每天晚12点自动对过期文件执行清理, 不配置则不删除
+    record-temp-day: 7
+    # [必选 ] ffmpeg路径
+    ffmpeg: ./lib/ffmpeg
+    # [必选 ] ffprobe路径, 一般安装ffmpeg就会自带, 一般跟ffmpeg在同一目录,用于查询文件的信息,
+    ffprobe: ./lib/ffprobe
+    # [可选 ] 限制 ffmpeg 合并文件使用的线程数,间接限制cpu使用率, 默认2 限制到50%
+    threads: 2
+
+# [可选] 日志配置, 一般不需要改
+logging:
+    file:
+        name: logs/wvp.log
+        max-history: 30
+        max-size: 10MB
+        total-size-cap: 300MB
+    level:
+        root: WARN
+        top:
+            panll:
+                assist: info

+ 3 - 0
elevator-media-record/src/main/resources/application.yml

@@ -0,0 +1,3 @@
+spring:
+  profiles:
+    active: dev

+ 25 - 0
elevator-media-record/src/main/resources/static/download.html

@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>下载</title>
+</head>
+<body>
+<a id="download" download></a>
+<script>
+    (function () {
+        let searchParams = new URLSearchParams(location.search);
+        var download = document.getElementById("download");
+        download.setAttribute("href", searchParams.get("url"))
+        download.click()
+        setTimeout(() => {
+            window.location.href = "about:blank";
+            window.close();
+        }, 200)
+    })();
+
+</script>
+</body>
+</html>

+ 13 - 0
elevator-media-record/src/test/java/top/panll/assist/WvpProAssistApplicationTests.java

@@ -0,0 +1,13 @@
+package top.panll.assist;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+class WvpProAssistApplicationTests {
+
+    @Test
+    void contextLoads() {
+    }
+
+}