package com.ard.work.service.impl; import com.ard.common.constant.CacheConstants; import com.ard.common.core.domain.entity.SysUser; import com.ard.common.core.redis.RedisCache; import com.ard.common.utils.DateUtils; import com.ard.common.utils.file.MimeTypeUtils; import com.ard.common.utils.uuid.IdUtils; import com.ard.oss.service.impl.OssServiceContext; import com.ard.work.component.forest.TyClient; import com.ard.work.domain.dto.BoxHistoryDTO; import com.ard.work.domain.dto.BoxPhotosDTO; import com.ard.work.domain.entity.ArdFlightTask; import com.ard.work.domain.entity.ArdMedia; import com.ard.work.domain.receiver.BoxHistoryReceiver; import com.ard.work.domain.receiver.BoxPhotosReceiver; import com.ard.work.domain.receiver.TyHttpCommonReceiver; import com.ard.work.mapper.ArdFlightTaskMapper; import com.ard.work.mapper.ArdMediaMapper; import com.ard.work.service.IArdFlightTaskService; import com.ard.work.service.IArdServerInfoService; import com.ard.work.service.control.UavControlService; import com.ard.work.util.osSelect; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Service; import javax.annotation.Resource; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLConnection; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; @Slf4j @Service public class ArdFlightTaskServiceImpl implements IArdFlightTaskService { @Resource private ArdFlightTaskMapper ardFlightTaskMapper; @Resource private ArdMediaMapper ardMediaMapper; @Resource private IArdServerInfoService ardServerInfoService; @Resource private TyClient tyClient; @Resource private OssServiceContext ossServiceContext; @Resource private ThreadPoolTaskExecutor threadPoolTaskExecutor; // 使用线程池执行异步任务 @Resource private UavControlService uavControlService; @Autowired private RedisCache redisCache; /** * 查询飞行架次列表 * * @param ardFlightTask 飞行架次 * @return 飞行架次集合 */ @Override public List selectArdFlightTask(ArdFlightTask ardFlightTask) { return ardFlightTaskMapper.selectArdFlightTask(ardFlightTask); } /** * 同步所有服务的飞行历史 */ @Scheduled(fixedDelay = 10000) public void syncAllFlightHistory() { List serverIds = ardServerInfoService.getServerIdsList(); for (String serverId : serverIds) { // 获取上次同步的时间(即数据库中最大 startTime) Date lastSyncTime = getLastSyncTimeForServer(serverId); // 根据上次同步时间同步飞行历史 syncFlightHistory(serverId, lastSyncTime); } } // 获取上次同步时间(获取最大 startTime) private Date getLastSyncTimeForServer(String serverId) { Date maxStartTime = ardFlightTaskMapper.findMaxStartTimeByServerId(serverId); return maxStartTime != null ? maxStartTime : new Date(0); // 如果没有数据,返回 Unix 纪元时间作为起点 } // 根据上次同步时间同步飞行历史 public void syncFlightHistory(String serverId, Date startTime) { // 创建查询条件 BoxHistoryDTO,并设置 startTime 作为过滤条件 BoxHistoryDTO boxHistoryDTO = BoxHistoryDTO.builder() .startTime(DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD, startTime)) .build(); // 获取飞行历史 List historyList = getBoxHistory(serverId, boxHistoryDTO); // 如果获取到数据,处理并同步到本地数据库 if (historyList != null && !historyList.isEmpty()) { for (BoxHistoryReceiver boxHistory : historyList) { syncToLocalDatabase(serverId, boxHistory); } } } // 将数据同步到本地数据库 private void syncToLocalDatabase(String serverId, BoxHistoryReceiver boxHistoryReceiver) { // 检查是否已经存在相同任务ID的记录 ArdFlightTask ardFlightTask = ardFlightTaskMapper.getArdFlightTaskById(boxHistoryReceiver.getTaskId()); if (ardFlightTask == null) { // 如果不存在,则插入新记录 log.debug("insert task {}", boxHistoryReceiver.getTaskId()); Date endTime = null; if (boxHistoryReceiver.getEndTime() != null) { endTime = DateUtils.dateTime(DateUtils.YYYY_MM_DD_HH_MM_SS, boxHistoryReceiver.getEndTime()); } // 创建FlightTask实体对象并填充数据 ArdFlightTask flightTask = ArdFlightTask.builder() .taskId(boxHistoryReceiver.getTaskId()) .boxSn(boxHistoryReceiver.getBoxSn()) .boxName(boxHistoryReceiver.getBoxName()) .taskName(boxHistoryReceiver.getTaskName()) .photoCount(Integer.parseInt(boxHistoryReceiver.getPhotoCount())) .videoCount((boxHistoryReceiver.getVideoPathList() != null) ? boxHistoryReceiver.getVideoPathList().size() : 0) .videoPath(boxHistoryReceiver.getVideoPath()) .serverId(serverId) .startTime(DateUtils.dateTime(DateUtils.YYYY_MM_DD_HH_MM_SS, boxHistoryReceiver.getStartTime())) .endTime(endTime) .flightMileage(boxHistoryReceiver.getFlightMileage()) .flightDuration(boxHistoryReceiver.getFlightDuration()) .aveSpeed(boxHistoryReceiver.getAveSpeed()) .maxSpeed(boxHistoryReceiver.getMaxSpeed()) .build(); Long flyUserId = uavControlService.getCurrentController(flightTask.getBoxSn(), "fly"); if (flyUserId != null) { SysUser flyUser = redisCache.getCacheObject(CacheConstants.SYS_USER_KEY + flyUserId); if (flyUser != null && flyUser.getNickName() != null) { flightTask.setCreateBy(flyUser.getNickName()); } } flightTask.setCreateTime(DateUtils.getNowDate()); // 插入新记录 int rows = ardFlightTaskMapper.insertArdFlightTask(flightTask); if (rows > 0) { // 删除旧的媒体记录,防止重复插入 ardMediaMapper.deleteArdMediaByTaskId(flightTask.getTaskId()); // 插入视频 insertVideoToDatabase(flightTask, boxHistoryReceiver.getVideoPathList()); // 插入图片 insertPhotoToDatabase(flightTask, serverId, boxHistoryReceiver.getBoxSn()); } } else { // 如果存在,则更新记录 log.debug("Updating task {}", boxHistoryReceiver.getTaskId()); updateFlightTask(ardFlightTask, boxHistoryReceiver); // 增量更新 updateVideoToDatabase(ardFlightTask, boxHistoryReceiver.getVideoPathList()); // 增量更新图片 updatePhotoToDatabase(ardFlightTask, serverId, boxHistoryReceiver.getBoxSn()); } } private void updateFlightTask(ArdFlightTask local, BoxHistoryReceiver remote) { boolean needUpdate = false; // endTime 更新(重要!!!) if (remote.getEndTime() != null) { Date newEnd = DateUtils.dateTime(DateUtils.YYYY_MM_DD_HH_MM_SS, remote.getEndTime()); if (local.getEndTime() == null || !local.getEndTime().equals(newEnd)) { local.setEndTime(newEnd); needUpdate = true; } } // photoCount 更新 int remotePhotoCount = Integer.parseInt(remote.getPhotoCount()); if (local.getPhotoCount() != remotePhotoCount) { local.setPhotoCount(remotePhotoCount); needUpdate = true; } // videoCount 更新 int remoteVideoCount = remote.getVideoPathList() != null ? remote.getVideoPathList().size() : 0; if (local.getVideoCount() != remoteVideoCount) { local.setVideoCount(remoteVideoCount); needUpdate = true; } if (needUpdate) { ardFlightTaskMapper.updateArdFlightTask(local); } } // 调用接口获取飞行历史数据 private List getBoxHistory(String serverId, BoxHistoryDTO boxHistoryDTO) { TyHttpCommonReceiver> boxHistory = tyClient.getBoxHistory(serverId, boxHistoryDTO); return boxHistory.getContent(); } //调用接口获取架次图片 private List getBoxPhotos(String serverId, BoxPhotosDTO boxPhotosDTO) { TyHttpCommonReceiver> boxPhotos = tyClient.getBoxPhotos(serverId, boxPhotosDTO); return boxPhotos.getContent(); } // 插入视频记录 private void insertVideoToDatabase(ArdFlightTask flightTask, List videoPathList) { if (videoPathList == null || videoPathList.isEmpty()) { return; } String boxSn = flightTask.getBoxSn(); for (String videoPath : videoPathList) { //videoPath = "https://192.168.1.3:9001/record/DBM250974065008/8212fd8e89814d66b6c32f48d05f5101.mp4"; ArdMedia media = ArdMedia.builder() .id(IdUtils.simpleUUID()) .taskId(flightTask.getTaskId()) .mediaType(1) .sourceUrl(videoPath) .fileSize(getFileSizeByUrl(videoPath)) .build(); // ---- 1. 视频上传(失败不抛异常,返回 null) ---- CompletableFuture videoFuture = CompletableFuture.supplyAsync(() -> { try (InputStream inputStream = openUrlInputStream(videoPath, 5000, 10000)) { if (inputStream != null) { return ossServiceContext.upload( "record", boxSn + "/" + media.getId() + ".mp4", inputStream, MimeTypeUtils.VIDEO_MP4 ); } } catch (Exception e) { e.printStackTrace(); } return null; }, threadPoolTaskExecutor); // ---- 2. 缩略图上传(失败不抛异常,返回 null) ---- CompletableFuture thumbFuture = CompletableFuture.supplyAsync(() -> { try { try (ByteArrayInputStream thumbStream = generateThumbnail(videoPath, 1, 200)) { if (thumbStream != null) { return ossServiceContext.upload( "record", boxSn + "/" + media.getId() + ".jpg", thumbStream, MimeTypeUtils.IMAGE_JPEG ); } } } catch (Exception e) { e.printStackTrace(); } return null; }, threadPoolTaskExecutor); // 两个完成后写数据库(任意失败不阻断流程) videoFuture.thenCombine(thumbFuture, (vUrl, tUrl) -> { media.setMediaUrl(vUrl); media.setThumbnailUrl(tUrl); media.setCreateTime(new Date()); return media; }).thenAcceptAsync(ardMediaMapper::insertArdMedia, threadPoolTaskExecutor); } } private void updateVideoToDatabase(ArdFlightTask flightTask, List videoPathList) { if (videoPathList == null) { return; } // 需要插入新的视频 List existingVideos = ardMediaMapper.selectArdMediaByTaskIdAndType(flightTask.getTaskId(), 1); Integer count = existingVideos.size();//获取本地库视频数量 int size = videoPathList.size();//获取接口返回的视频数量 if (count != null && size > count) { log.info("任务【{}】录像数量不一致,需要更新数据库", flightTask.getTaskId()); // 提取现有的视频路径 List existingVideoPaths = existingVideos.stream() .map(ArdMedia::getSourceUrl) // 获取每个视频的 URL .collect(Collectors.toList()); // 从接口返回的视频路径中找出新的视频 List newVideos = videoPathList.stream() .filter(videoPath -> !existingVideoPaths.contains(videoPath)) // 过滤出新的视频 .collect(Collectors.toList()); if (!newVideos.isEmpty()) { String boxSn = flightTask.getBoxSn(); // 插入新的视频记录 for (String videoPath : newVideos) { ArdMedia ardMedia = ArdMedia.builder() .id(IdUtils.simpleUUID()) .taskId(flightTask.getTaskId()) .mediaType(1) // 1表示视频 .fileSize(getFileSizeByUrl(videoPath)) .sourceUrl(videoPath) .build(); // ---- 1. 视频上传(失败不抛异常,返回 null) ---- CompletableFuture videoFuture = CompletableFuture.supplyAsync(() -> { try (InputStream inputStream = openUrlInputStream(videoPath, 5000, 10000)) { if (inputStream != null) { return ossServiceContext.upload( "record", boxSn + "/" + ardMedia.getId() + ".mp4", inputStream, MimeTypeUtils.VIDEO_MP4 ); } } catch (Exception e) { e.printStackTrace(); } return null; }, threadPoolTaskExecutor); // ---- 2. 缩略图上传(失败不抛异常,返回 null) ---- CompletableFuture thumbFuture = CompletableFuture.supplyAsync(() -> { try { try (ByteArrayInputStream thumbStream = generateThumbnail(videoPath, 1, 200)) { if (thumbStream != null) { return ossServiceContext.upload( "record", boxSn + "/" + ardMedia.getId() + ".jpg", thumbStream, MimeTypeUtils.IMAGE_JPEG ); } } } catch (Exception e) { e.printStackTrace(); } return null; }, threadPoolTaskExecutor); // 两个完成后写数据库(任意失败不阻断流程) videoFuture.thenCombine(thumbFuture, (vUrl, tUrl) -> { ardMedia.setMediaUrl(vUrl); ardMedia.setThumbnailUrl(tUrl); ardMedia.setCreateTime(new Date()); return ardMedia; }).thenAcceptAsync(ardMediaMapper::insertArdMedia, threadPoolTaskExecutor); } // 更新任务的视频数量 flightTask.setVideoCount(flightTask.getVideoCount() + newVideos.size()); ardFlightTaskMapper.updateArdFlightTask(flightTask); } } } // 插入图片记录 private void insertPhotoToDatabase(ArdFlightTask flightTask, String serverId, String boxSn) { // 查询图片 BoxPhotosDTO boxPhotosDTO = new BoxPhotosDTO(); boxPhotosDTO.setTaskId(flightTask.getTaskId()); boxPhotosDTO.setBoxSn(boxSn); boxPhotosDTO.setPageIndex(1); boxPhotosDTO.setPageSize(500); List photoList = getBoxPhotos(serverId, boxPhotosDTO); if (photoList != null && !photoList.isEmpty()) { addPhotos(flightTask, photoList); } } private void updatePhotoToDatabase(ArdFlightTask flightTask, String serverId, String boxSn) { // 获取本地数据库中已存在的图片记录(类型为 0 表示图片) List existingPhotos = ardMediaMapper.selectArdMediaByTaskIdAndType(flightTask.getTaskId(), 0); int count = existingPhotos.size(); //获取本地库图片数量 // 查询图片 BoxPhotosDTO boxPhotosDTO = BoxPhotosDTO.builder() .taskId(flightTask.getTaskId()) .boxSn(boxSn) .pageIndex(1) .pageSize(500) .build(); TyHttpCommonReceiver> boxPhotos = tyClient.getBoxPhotos(serverId, boxPhotosDTO); List photoList = boxPhotos.getContent(); int size = photoList.size(); if (size > count) { log.info("任务【{}】图片数量不一致,需要更新数据库", flightTask.getTaskId()); // 需要插入新的视频 // 提取现有的图片路径(根据 imgUrl 来判断是否重复) List existingPhotoUrls = existingPhotos.stream() .map(ArdMedia::getSourceUrl) // 提取 ArdMedia 中的图片 URL .collect(Collectors.toList()); // 筛选出 photoList 中没有出现在 existingPhotos 中的新图片 List newPhotos = photoList.stream() .filter(photo -> !existingPhotoUrls.contains(photo.getImgUrl())) // 过滤出新图片 .collect(Collectors.toList()); if (!newPhotos.isEmpty()) { // 插入新的视频记录 addPhotos(flightTask, newPhotos); // 更新任务的视频数量 flightTask.setPhotoCount(flightTask.getPhotoCount() + newPhotos.size()); ardFlightTaskMapper.updateArdFlightTask(flightTask); } } } private void addPhotos(ArdFlightTask flightTask, List photoList) { String boxSn = flightTask.getBoxSn(); for (BoxPhotosReceiver photo : photoList) { ArdMedia ardMedia = ArdMedia.builder() .id(IdUtils.simpleUUID()) .taskId(flightTask.getTaskId()) .mediaType(0) // 0表示图片 .fileSize(photo.getFileSize()) .fileMd5(photo.getFileMd5()) .sourceUrl(photo.getImgUrl()) .photoId(photo.getPhotoId()) .longitude(photo.getLng()) .latitude(photo.getLat()) .height(photo.getHeight()) .captureTime(DateUtils.dateTime(DateUtils.YYYY_MM_DD_HH_MM_SS, photo.getTimeStamp())) .build(); // 异步上传文件到OSS,并在上传完成后插入数据库 CompletableFuture.supplyAsync(() -> { try (InputStream inputStream = openUrlInputStream(photo.getImgUrl(), 5000, 10000)) { if (inputStream != null) { return ossServiceContext.upload( "pic", boxSn + "/" + ardMedia.getId() + ".jpg", inputStream, MimeTypeUtils.IMAGE_JPG ); } } catch (Exception e) { e.printStackTrace(); } return null; }, threadPoolTaskExecutor) .thenAccept(url -> { ardMedia.setMediaUrl(url); ardMedia.setCreateTime(new Date()); ardMediaMapper.insertArdMedia(ardMedia); }); } } // 获取文件大小 private long getFileSizeByUrl(String fileUrl) { try { URL url = new URL(fileUrl); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("HEAD"); conn.connect(); return conn.getContentLengthLong(); // 返回字节大小 } catch (Exception e) { return -1; } } // 生成视频缩略图 public ByteArrayInputStream generateThumbnail( String videoFile, int skipSeconds, int targetWidth) { String ffmpegPath = ""; if (osSelect.isWindows()) { ffmpegPath = "ffmpeg"; } else if (osSelect.isLinux()) { ffmpegPath = "/usr/local/ffmpeg/bin/ffmpeg"; } try { List command = new ArrayList<>(); command.add(ffmpegPath); command.add("-timeout"); command.add("3000000"); command.add("-rw_timeout"); command.add("3000000"); command.add("-ss"); command.add(String.format("00:00:%02d", skipSeconds)); command.add("-i"); command.add(videoFile); command.add("-vframes"); command.add("1"); // 等比缩放 command.add("-vf"); command.add("scale=" + targetWidth + ":-1"); // **确保输出 JPEG** command.add("-f"); command.add("mjpeg"); // 关键点:输出格式固定为 JPEG command.add("-vcodec"); command.add("mjpeg"); // 输出到管道 command.add("pipe:1"); // ⭐ 打印最终命令(可复制) log.info("ffmpeg command: {}", String.join(" ", command)); ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(false); Process process = pb.start(); // 读取 FFmpeg 输出(JPEG 数据)到内存 ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (InputStream is = process.getInputStream()) { byte[] buffer = new byte[8192]; int len; while ((len = is.read(buffer)) != -1) { baos.write(buffer, 0, len); } } int exitCode = process.waitFor(); if (exitCode != 0) { throw new RuntimeException("FFmpeg 执行失败,退出码: " + exitCode); } return new ByteArrayInputStream(baos.toByteArray()); } catch (IOException | InterruptedException e) { throw new RuntimeException("FFmpeg 生成缩略图失败", e); } } /** * 将 URL 转成 InputStream * * @param fileUrl 文件 URL * @param connectTimeoutMillis 连接超时(毫秒) * @param readTimeoutMillis 读取超时(毫秒) * @return InputStream,如果失败返回 null */ public InputStream openUrlInputStream(String fileUrl, int connectTimeoutMillis, int readTimeoutMillis) { try { URL url = new URL(fileUrl); // 支持 HTTPS 信任所有证书(如果需要访问自签名证书) if ("https".equalsIgnoreCase(url.getProtocol())) { TrustManager[] trustAllCerts = new TrustManager[]{ new X509TrustManager() { @Override public void checkClientTrusted(X509Certificate[] chain, String authType) { } @Override public void checkServerTrusted(X509Certificate[] chain, String authType) { } @Override public X509Certificate[] getAcceptedIssuers() { return null; } } }; SSLContext sc = SSLContext.getInstance("SSL"); sc.init(null, trustAllCerts, new java.security.SecureRandom()); HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); } URLConnection connection = url.openConnection(); connection.setConnectTimeout(connectTimeoutMillis); connection.setReadTimeout(readTimeoutMillis); return connection.getInputStream(); } catch (Exception e) { e.printStackTrace(); return null; } } }