From 3f43e40ed1a1e9f66b3ad2862da9dbc4b710c712 Mon Sep 17 00:00:00 2001
From: nishiwei <13126973358@163.com>
Date: Fri, 23 Oct 2026 14:09:47 +0800
Subject: [PATCH] 智云3.0机场1适配内容提交
---
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/media/Dock1MediaController.java | 80
ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1DroneAuthorityInfoReceiver.java | 35
ard-route/src/main/java/com/ard/route/util/InitializationUtils.java | 17
ard-route/src/main/java/com/ard/route/util/RouteFileUtils.java | 6
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/ICommandService.java | 26
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1WaylineFileServiceImpl.java | 289 ++
ard-admin/src/main/resources/application-daqing.yml | 16
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1TopologyController.java | 45
ard-sample/src/main/java/com/ard/sample/component/websocket/handler/DockWebSocketHandler.java | 80
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/ILiveStreamService.java | 73
ard-sample/src/main/java/com/ard/sample/component/mqtt/handler/StateLiveStatusHandler.java | 10
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/IWaylineTrajectoryService.java | 28
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1FlightTaskServiceImpl.java | 288 ++
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1WaylineTrajectoryServiceImpl.java | 274 ++
ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockModelResolver.java | 41
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1GatewayOSDServiceImpl.java | 70
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1CommandController.java | 38
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1LiveStreamServiceImpl.java | 544 +++++
ard-sample/src/main/java/com/ard/sample/dock1/wayline/model/dto/Dock1WaylineTaskCreateDTO.java | 79
ard-route/src/main/java/com/ard/route/util/UavRouteReqUtils.java | 7
ard-route/src/main/java/com/ard/route/util/ToolUtils.java | 17
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/media/Dock1FileController.java | 95
ard-sample/src/main/java/com/ard/sample/manage/model/enums/DockModelEnum.java | 64
ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockOsdDispatcher.java | 60
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1DockOSDServiceImpl.java | 125 +
sql/dock1_dictionary.sql | 58
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DevicePayloadController.java | 39
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceController.java | 279 ++
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1LiveStreamController.java | 163 +
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineJobController.java | 116 +
ard-admin/pom.xml | 17
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1ControlServiceImpl.java | 246 ++
ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockOnlineServiceImpl.java | 108
ard-sample/src/main/java/com/ard/sample/media/service/impl/FileServiceImpl.java | 26
ard-sample/src/main/java/com/ard/sample/manage/service/impl/DeviceServiceImpl.java | 46
ard-sample/src/main/java/com/ard/sample/component/mqtt/config/MqttMessageChannel.java | 8
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineTrajectoryController.java | 46
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineFileController.java | 170 +
ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1DroneAuthorityPayloadReceiver.java | 33
ard-route/src/main/java/com/ard/route/domain/djuav/kml/KmlPayloadInfo.java | 6
ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1OsdDockReceiver.java | 306 ++
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IWaylineFileService.java | 80
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceFirmwareController.java | 111 +
ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1OsdSubDeviceReceiver.java | 88
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DockController.java | 43
ard-route/src/main/java/com/ard/route/service/impl/ArdRouteInfoServiceImpl.java | 20
ard-sample/src/main/java/com/ard/sample/component/mqtt/model/ChannelName.java | 5
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/IControlService.java | 35
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IFlightTaskService.java | 22
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IWaylineJobService.java | 129 +
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1WaylineJobServiceImpl.java | 1160 ++++++++++
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceHmsController.java | 82
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1FlightSetupExceptionHandler.java | 68
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1CommandServiceImpl.java | 126 +
ard-sample/src/main/java/com/ard/sample/storage/service/impl/StorageServiceImpl.java | 13
ard-admin/src/main/resources/application-daqing-druid.yml | 8
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1DeviceOSDServiceImpl.java | 142 +
sql/dock1_sys_dict.sql | 34
ard-sample/src/main/java/com/ard/sample/component/mqtt/model/EventsMethodEnum.java | 5
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceLogsController.java | 124 +
ard-admin/src/main/java/com/ard/web/controller/sample/media/FileController.java | 6
ard-sample/src/main/java/com/ard/sample/component/websocket/handler/LiveStreamHandler.java | 71
62 files changed, 6,345 insertions(+), 101 deletions(-)
diff --git a/ard-admin/pom.xml b/ard-admin/pom.xml
index 6e065ff..7ba3ef5 100644
--- a/ard-admin/pom.xml
+++ b/ard-admin/pom.xml
@@ -125,9 +125,20 @@
</execution>
</executions>
</plugin>
- <plugin>
- <groupId>org.apache.maven.plugins</groupId>
- <artifactId>maven-war-plugin</artifactId>
+ <!-- 资源过滤使用 @ 作为分隔符,替换 application.yml 中的 @spring.profiles.active@ -->
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-resources-plugin</artifactId>
+ <configuration>
+ <delimiters>
+ <delimiter>@</delimiter>
+ </delimiters>
+ <useDefaultDelimiters>false</useDefaultDelimiters>
+ </configuration>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-war-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<failOnMissingWebXml>false</failOnMissingWebXml>
diff --git a/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1CommandController.java b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1CommandController.java
new file mode 100644
index 0000000..868e2b4
--- /dev/null
+++ b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1CommandController.java
@@ -0,0 +1,38 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.web.controller.sample.CommandController 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.web.controller.sample.dock1;
+
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.manage.model.param.CommandParam;
+import com.ard.sample.dock1.manage.service.ICommandService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import com.ard.web.controller.sample.CommandController;
+
+/**
+ * @author sean
+ * @version 1.2
+ * @date 2022/7/29
+ */
+@Api(tags = "指令控制")
+@RestController
+@Slf4j
+@RequestMapping("/dock1/devices")
+public class Dock1CommandController {
+
+ @Autowired
+ private ICommandService commandService;
+
+ @ApiOperation("负载控制-拍照")
+ @PostMapping("/{sn}/command/cameraPhotoTake")
+ public ResponseResult cameraPhotoTake(@PathVariable String sn,
+ @RequestBody CommandParam param) {
+ return commandService.cameraPhotoTake(sn, param);
+ }
+
+}
diff --git a/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceController.java b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceController.java
new file mode 100644
index 0000000..f27f1dc
--- /dev/null
+++ b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceController.java
@@ -0,0 +1,279 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.web.controller.sample.DeviceController 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.web.controller.sample.dock1;
+
+import com.ard.common.core.controller.BaseController;
+import com.ard.common.core.page.TableDataInfo;
+import com.ard.device.camera.domain.ArdCamera;
+import com.ard.sample.common.error.CommonErrorEnum;
+import com.ard.sample.common.model.PaginationData;
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.component.mqtt.model.ChannelName;
+import com.ard.sample.component.mqtt.model.CommonTopicReceiver;
+import com.ard.sample.component.mqtt.model.CommonTopicResponse;
+import com.ard.sample.manage.model.dto.DeviceDTO;
+import com.ard.sample.manage.model.dto.DeviceFirmwareUpgradeDTO;
+import com.ard.sample.manage.model.entity.DeviceEntity;
+import com.ard.sample.manage.model.enums.DeviceFirmwareStatusEnum;
+import com.ard.sample.manage.model.enums.DeviceSetPropertyEnum;
+import com.ard.sample.manage.model.receiver.StatusGatewayReceiver;
+import com.ard.sample.manage.service.IDeviceService;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.ZoneId;
+import java.util.List;
+import java.util.Optional;
+
+import static com.ard.common.utils.PageUtils.startPage;
+import com.ard.web.controller.sample.DeviceController;
+
+/**
+ * @author sean.zhou
+ * @version 0.1
+ * @date 2021/11/15
+ */
+@Api(tags = "设备管理")
+@RestController
+@Slf4j
+@RequestMapping("/dock1/device")
+public class Dock1DeviceController extends BaseController {
+
+ @Autowired
+ private IDeviceService deviceService;
+
+ /**
+ * 处理上线的消息
+ *
+ * @param receiver The drone information is not empty.
+ */
+ // 机场1:不复制原 ServiceActivator(inputChannel = ChannelName.INBOUND_STATUS_ONLINE),消费职责由共享层原类承担
+ public void deviceOnline(CommonTopicReceiver<StatusGatewayReceiver> receiver) {
+ boolean online = deviceService.deviceOnline(receiver.getData());
+ if (online) {
+ // Notify pilot that the drone is online successfully.
+ deviceService.publishStatusReply(receiver.getData().getSn(),
+ CommonTopicResponse.builder()
+ .tid(receiver.getTid())
+ .bid(receiver.getBid())
+ .timestamp(System.currentTimeMillis())
+ .method(receiver.getMethod())
+ .build());
+ }
+ }
+
+ /**
+ * 处理离线的消息
+ *
+ * @param receiver The drone information is empty.
+ */
+ // 机场1:不复制原 ServiceActivator(inputChannel = ChannelName.INBOUND_STATUS_OFFLINE),消费职责由共享层原类承担
+ public void deviceOffline(CommonTopicReceiver<StatusGatewayReceiver> receiver) {
+
+ boolean offline = deviceService.deviceOffline(receiver.getData());
+ if (offline) {
+ // Notify pilot that the device is offline successfully.
+ deviceService.publishStatusReply(receiver.getData().getSn(),
+ CommonTopicResponse.builder()
+ .tid(receiver.getTid())
+ .bid(receiver.getBid())
+ .timestamp(System.currentTimeMillis())
+ .method(receiver.getMethod())
+ .build());
+
+ }
+ }
+
+ /**
+ * 获取所有在线设备列表
+ *
+ * @return
+ */
+ @ApiOperation("设备列表")
+ @PreAuthorize("@ss.hasPermi('device:dock:list')")
+ @GetMapping("/list")
+ public ResponseResult<List<DeviceDTO>> getDevices(@RequestParam(required = false) String nickname) {
+// List<DeviceDTO> devicesList = deviceService.getDevicesTopoForWeb();
+ List<DeviceDTO> devicesList = deviceService.getDockList(nickname);
+ return ResponseResult.success(devicesList);
+ }
+
+// /**
+// * 获取按nickname筛选在线设备列表
+// *
+// * @return
+// */
+// @ApiOperation("设备列表-按nickname筛选")
+// @GetMapping("/cameralist")
+// public ResponseResult<List<DeviceDTO>> getCameraDevices(@RequestParam(required = false) String nickname) {
+// List<DeviceDTO> devicesList = deviceService.getDockCameraList(nickname);
+// return ResponseResult.success(devicesList);
+// }
+
+ /**
+ * 绑定设备
+ *
+ * @param device
+ * @param deviceSn
+ * @return
+ */
+ @ApiOperation("设备绑定")
+ @PostMapping("/{device_sn}/binding")
+ public ResponseResult bindDevice(@RequestBody DeviceDTO device, @PathVariable("device_sn") String deviceSn) {
+ device.setDeviceSn(deviceSn);
+ boolean isUpd = deviceService.bindDevice(device);
+ return isUpd ? ResponseResult.success() : ResponseResult.error();
+ }
+
+ /**
+ * Obtain device information according to device sn.
+ *
+ * @param deviceSn
+ * @return
+ */
+ @ApiOperation("设备信息")
+ @GetMapping("/{device_sn}")
+ public ResponseResult getDevice(@PathVariable("device_sn") String deviceSn) {
+ Optional<DeviceDTO> deviceOpt = deviceService.getDeviceBySn(deviceSn);
+ return deviceOpt.isEmpty() ? ResponseResult.error("device not found.") :
+ ResponseResult.success(deviceOpt.get());
+ }
+
+ /**
+ * Get the binding devices list
+ * 获取绑定设备列表
+ *
+ * @param page
+ * @param pageSize
+ * @return
+ */
+ @ApiOperation("绑定设备列表")
+ @GetMapping("/list/bound")
+ @PreAuthorize("@ss.hasPermi('device:dock:list')")
+ public ResponseResult<PaginationData<DeviceDTO>> getBoundDevicesWithDomain(Integer domain,
+ @RequestParam(defaultValue = "1") Long page,
+ @RequestParam(value = "page_size",
+ defaultValue = "50") Long pageSize,
+ @RequestParam(required = false) Long deptId) {
+ PaginationData<DeviceDTO> devices = deviceService.getBoundDevicesWithDomain(page, pageSize, domain, deptId);
+
+ return ResponseResult.success(devices);
+ }
+
+ /**
+ * Removing the binding state of the device.
+ *
+ * @param deviceSn
+ * @return
+ */
+ @ApiOperation("设备解绑")
+ @DeleteMapping("/{device_sn}/unbinding")
+ public ResponseResult unbindingDevice(@PathVariable("device_sn") String deviceSn) {
+ deviceService.unbindDevice(deviceSn);
+ return ResponseResult.success();
+ }
+
+ /**
+ * Update device information.
+ *
+ * @param device
+ * @param deviceSn
+ * @return
+ */
+ @ApiOperation("设备更新")
+ @PutMapping("/{device_sn}")
+ public ResponseResult updateDevice(@RequestBody DeviceDTO device,
+ @PathVariable("device_sn") String deviceSn) {
+ device.setDeviceSn(deviceSn);
+ boolean isUpd = deviceService.updateDevice(device);
+ return isUpd ? ResponseResult.success() : ResponseResult.error();
+ }
+
+ /**
+ * Delivers offline firmware upgrade tasks.
+ *
+ * @param upgradeDTOS
+ * @return
+ */
+ @ApiOperation("固件升级")
+ @PostMapping("/ota")
+ public ResponseResult createOtaJob(@RequestBody List<DeviceFirmwareUpgradeDTO> upgradeDTOS) {
+ return deviceService.createDeviceOtaJob(upgradeDTOS);
+ }
+
+ /**
+ * Set the property parameters of the drone.
+ *
+ * @param dockSn
+ * @param param
+ * @return
+ */
+ @ApiOperation("属性设置")
+ @PutMapping("{device_sn}/property")
+ public ResponseResult devicePropertySet(@PathVariable("device_sn") String dockSn,
+ @RequestBody JsonNode param) {
+ if (param.size() != 1) {
+ //return ResponseResult.error(CommonErrorEnum.ILLEGAL_ARGUMENT);
+ return ResponseResult.error(999,"参数不合法");
+ }
+ String property = param.fieldNames().next();
+ Optional<DeviceSetPropertyEnum> propertyEnumOpt = DeviceSetPropertyEnum.find(property);
+ if (propertyEnumOpt.isEmpty()) {
+ //return ResponseResult.error(CommonErrorEnum.ILLEGAL_ARGUMENT);
+ return ResponseResult.error(999,"参数无效");
+ }
+ return deviceService.devicePropertySet(dockSn, propertyEnumOpt.get(), param);
+// return ResponseResult.success();
+ }
+
+ //.get(property)
+
+// /**
+// * 查询机场设备列表
+// */
+// @PreAuthorize("@ss.hasPermi('device:camera:list')")
+// @GetMapping("/test/list")
+// @ApiOperation("查询机场设备列表")
+// public TableDataInfo list(DeviceDTO device) {
+// startPage();
+// DeviceEntity deviceEntity = deviceDTO2Entity(device);
+// QueryWrapper<DeviceEntity> queryWrapper=new QueryWrapper<>(deviceEntity);
+// List<DeviceEntity> list = deviceService.selectList(queryWrapper);
+// return getDataTable(list);
+// }
+
+ private DeviceEntity deviceDTO2Entity(DeviceDTO dto) {
+ DeviceEntity.DeviceEntityBuilder builder = DeviceEntity.builder();
+ if (dto == null) {
+ return builder.build();
+ }
+
+ return builder.deviceSn(dto.getDeviceSn())
+ .userId(dto.getUserId())
+ .nickname(dto.getNickname())
+ .boundStatus(dto.getBoundStatus())
+ .loginTime(dto.getLoginTime() != null ?
+ dto.getLoginTime().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() : null)
+ .boundTime(dto.getBoundTime() != null ?
+ dto.getBoundTime().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() : null)
+ .childSn(dto.getChildDeviceSn())
+ .domain(dto.getDomain())
+ .firmwareVersion(dto.getFirmwareVersion())
+ .workType(dto.getWorkType())
+ .webrtcUrl(dto.getWebrtcUrl())
+ .cameraWebrtcUrl(dto.getCameraWebrtcUrl())
+ .cameraState(dto.getCameraState())
+ .compatibleStatus(dto.getFirmwareStatus() == null ? null :
+ DeviceFirmwareStatusEnum.CONSISTENT_UPGRADE != DeviceFirmwareStatusEnum.find(dto.getFirmwareStatus()))
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceFirmwareController.java b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceFirmwareController.java
new file mode 100644
index 0000000..046a292
--- /dev/null
+++ b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceFirmwareController.java
@@ -0,0 +1,111 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.web.controller.sample.DeviceFirmwareController 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.web.controller.sample.dock1;
+
+import com.ard.common.core.domain.model.LoginUser;
+import com.ard.common.utils.SecurityUtils;
+import com.ard.sample.common.model.PaginationData;
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.manage.model.dto.DeviceFirmwareDTO;
+import com.ard.sample.manage.model.dto.DeviceFirmwareNoteDTO;
+import com.ard.sample.manage.model.dto.FirmwareFileProperties;
+import com.ard.sample.manage.model.param.DeviceFirmwareQueryParam;
+import com.ard.sample.manage.model.param.DeviceFirmwareUpdateParam;
+import com.ard.sample.manage.model.param.DeviceFirmwareUploadParam;
+import com.ard.sample.manage.service.IDeviceFirmwareService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import com.ard.web.controller.sample.DeviceFirmwareController;
+
+/**
+ * @author sean
+ * @version 1.2
+ * @date 2022/8/16
+ */
+@RestController
+@RequestMapping("/dock1/workspaces")
+@Validated
+public class Dock1DeviceFirmwareController {
+
+ @Autowired
+ private IDeviceFirmwareService service;
+
+ /**
+ * Get the latest firmware version information for this device model.
+ * @param deviceNames
+ * @return
+ */
+ @GetMapping("/firmware-release-notes/latest")
+ public ResponseResult<List<DeviceFirmwareNoteDTO>> getLatestFirmwareNote(@RequestParam("device_name") List<String> deviceNames) {
+
+ List<DeviceFirmwareNoteDTO> releaseNotes = deviceNames.stream()
+ .map(deviceName -> service.getLatestFirmwareReleaseNote(deviceName))
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .collect(Collectors.toList());
+
+ return ResponseResult.success(releaseNotes);
+ }
+
+ /**
+ * Query firmware information based on parameters.
+ * @param workspaceId
+ * @param param
+ * @return
+ */
+ @GetMapping("/firmwares")
+ public ResponseResult<PaginationData<DeviceFirmwareDTO>> getAllFirmwarePagination(@Valid DeviceFirmwareQueryParam param) {
+
+ PaginationData<DeviceFirmwareDTO> data = service.getAllFirmwarePagination(param);
+ return ResponseResult.success(data);
+ }
+
+ /**
+ * Import firmware file for device upgrades.
+ * @param file
+ * @param param
+ * @return
+ */
+ @PostMapping("/firmwares/file/upload")
+ public ResponseResult importFirmwareFile(@NotNull(message = "No file received.") MultipartFile file,
+ @Valid DeviceFirmwareUploadParam param) {
+
+ if (!file.getOriginalFilename().endsWith(FirmwareFileProperties.FIRMWARE_FILE_SUFFIX)) {
+ return ResponseResult.error("The file format is incorrect.");
+ }
+ LoginUser loginUser = SecurityUtils.getLoginUser();
+ String creator = loginUser.getUsername();
+
+ service.importFirmwareFile(creator, param, file);
+ return ResponseResult.success();
+ }
+
+ /**
+ * Change the firmware availability status.
+ * @param workspaceId
+ * @param firmwareId
+ * @param param
+ * @return
+ */
+ @PutMapping("/{workspace_id}/firmwares/{firmware_id}")
+ public ResponseResult changeFirmwareStatus(@PathVariable("workspace_id") String workspaceId,
+ @PathVariable("firmware_id") String firmwareId,
+ @Valid @RequestBody DeviceFirmwareUpdateParam param) {
+
+ service.updateFirmwareInfo(DeviceFirmwareDTO.builder()
+ .firmwareId(firmwareId).firmwareStatus(param.getStatus()).build());
+ return ResponseResult.success();
+ }
+
+
+}
diff --git a/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceHmsController.java b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceHmsController.java
new file mode 100644
index 0000000..5c47330
--- /dev/null
+++ b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceHmsController.java
@@ -0,0 +1,82 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.web.controller.sample.DeviceHmsController 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.web.controller.sample.dock1;
+
+import com.ard.sample.common.model.PaginationData;
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.manage.model.dto.DeviceHmsDTO;
+import com.ard.sample.manage.model.param.DeviceHmsQueryParam;
+import com.ard.sample.manage.service.IDeviceHmsService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.v3.oas.annotations.Operation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import com.ard.web.controller.sample.DeviceHmsController;
+
+/**
+ * @author sean
+ * @version 1.1
+ * @date 2022/7/7
+ */
+@Api(tags = "设备HMS管理")
+@RestController
+@Slf4j
+@RequestMapping("/dock1/devices")
+public class Dock1DeviceHmsController {
+
+ @Autowired
+ private IDeviceHmsService deviceHmsService;
+
+ /**
+ * 查询设备hms信息的页面。/{workspace_id},@PathVariable("workspace_id") String workspaceId
+ * @param param
+ * @return
+ */
+ @Operation(summary = "获取设备HMS信息", description = "参数传空则忽略该条件,返回所有符合其他条件的数据")
+ @ApiOperation("获取设备HMS信息")
+ @GetMapping("/devices/hms")
+ public ResponseResult<PaginationData<DeviceHmsDTO>> getHmsInformation(DeviceHmsQueryParam param) {
+ if (param == null) {
+ param = new DeviceHmsQueryParam();
+ }
+ PaginationData<DeviceHmsDTO> devices = deviceHmsService.getDeviceHmsByParam(param);
+
+ return ResponseResult.success(devices);
+ }
+
+ /**
+ * Update unread hms messages to read status.
+ * @param deviceSn
+ * @return
+ */
+ @ApiOperation("更新HMS消息为已读状态")
+ @PutMapping("/devices/hms/{device_sn}")
+ public ResponseResult updateReadHmsByDeviceSn(@PathVariable("device_sn") String deviceSn) {
+ deviceHmsService.updateUnreadHms(deviceSn);
+ return ResponseResult.success();
+ }
+
+ /**
+ * Get hms messages for a single device.
+ * @param deviceSn
+ * @return
+ */
+ @ApiOperation("获取设备未读HMS消息")
+ @GetMapping("/devices/hms/{device_sn}")
+ public ResponseResult<List<DeviceHmsDTO>> getUnreadHmsByDeviceSn(@PathVariable("device_sn") String deviceSn) {
+ PaginationData<DeviceHmsDTO> paginationData = deviceHmsService.getDeviceHmsByParam(
+ DeviceHmsQueryParam.builder()
+ .deviceSn(new HashSet<>(Set.of(deviceSn)))
+ .updateTime(0L)
+ .build());
+ return ResponseResult.success(paginationData.getList());
+ }
+}
diff --git a/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceLogsController.java b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceLogsController.java
new file mode 100644
index 0000000..3306a39
--- /dev/null
+++ b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceLogsController.java
@@ -0,0 +1,124 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.web.controller.sample.DeviceLogsController 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.web.controller.sample.dock1;
+
+import com.ard.common.core.domain.model.LoginUser;
+import com.ard.common.utils.SecurityUtils;
+import com.ard.sample.common.model.PaginationData;
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.manage.model.dto.DeviceLogsDTO;
+import com.ard.sample.manage.model.param.DeviceLogsCreateParam;
+import com.ard.sample.manage.model.param.DeviceLogsQueryParam;
+import com.ard.sample.manage.model.param.LogsFileUpdateParam;
+import com.ard.sample.manage.service.IDeviceLogsService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.net.URL;
+import java.util.List;
+import com.ard.web.controller.sample.DeviceLogsController;
+
+/**
+ * @author sean
+ * @version 1.2
+ * @date 2022/9/7
+ */
+@RestController
+@Slf4j
+@RequestMapping("/dock1/workspaces")
+public class Dock1DeviceLogsController {
+
+ @Autowired
+ private IDeviceLogsService deviceLogsService;
+
+ /**
+ * Obtain the device upload log list by paging according to the query parameters.
+ * @param workspaceId
+ * @param deviceSn
+ * @param param
+ * @return
+ */
+ @GetMapping("/{workspace_id}/devices/{device_sn}/logs-uploaded")
+ public ResponseResult getUploadedLogs(DeviceLogsQueryParam param, @PathVariable("workspace_id") String workspaceId,
+ @PathVariable("device_sn") String deviceSn) {
+ PaginationData<DeviceLogsDTO> data = deviceLogsService.getUploadedLogs(deviceSn, param);
+ return ResponseResult.success(data);
+ }
+
+ /**
+ * Get a list of log files that can be uploaded in real time.
+ * @param workspaceId
+ * @param deviceSn
+ * @param domainList
+ * @return
+ */
+ @GetMapping("/{workspace_id}/devices/{device_sn}/logs")
+ public ResponseResult getLogsBySn(@PathVariable("workspace_id") String workspaceId,
+ @PathVariable("device_sn") String deviceSn,
+ @RequestParam("domain_list") List<String> domainList) {
+ return deviceLogsService.getRealTimeLogs(deviceSn, domainList);
+ }
+
+ /**
+ * Initiate a log upload request to the gateway.
+ * @return
+ */
+ @PostMapping("/{workspace_id}/devices/{device_sn}/logs")
+ public ResponseResult uploadLogs(@PathVariable("workspace_id") String workspaceId,
+ @PathVariable("device_sn") String deviceSn,
+ HttpServletRequest request, @RequestBody DeviceLogsCreateParam param) {
+
+ LoginUser loginUser = SecurityUtils.getLoginUser();
+ return deviceLogsService.pushFileUpload(loginUser.getUsername(), deviceSn, param);
+ }
+
+ /**
+ * Cancel logs file upload.
+ * @return
+ */
+ @DeleteMapping("/{workspace_id}/devices/{device_sn}/logs")
+ public ResponseResult cancelUploadedLogs(@PathVariable("workspace_id") String workspaceId,
+ @PathVariable("device_sn") String deviceSn,
+ @RequestBody LogsFileUpdateParam param) {
+
+ return deviceLogsService.pushUpdateFile(deviceSn, param);
+ }
+
+ /**
+ * Delete upload history.
+ * @return
+ */
+ @DeleteMapping("/{workspace_id}/devices/{device_sn}/logs/{logs_id}")
+ public ResponseResult deleteUploadedLogs(@PathVariable("workspace_id") String workspaceId,
+ @PathVariable("device_sn") String deviceSn,
+ @PathVariable("logs_id") String logsId) {
+ deviceLogsService.deleteLogs(deviceSn, logsId);
+ return ResponseResult.success();
+ }
+ /**
+ * Query the download address of the file according to the wayline file id,
+ * and redirect to this address directly for download.
+ * @param workspaceId
+ * @param fileId
+ * @param logsId
+ * @param response
+ */
+ @GetMapping("/{workspace_id}/logs/{logs_id}/url/{file_id}")
+ public ResponseResult getFileUrl(@PathVariable(name = "workspace_id") String workspaceId,
+ @PathVariable(name = "file_id") String fileId,
+ @PathVariable(name = "logs_id") String logsId, HttpServletResponse response) {
+
+ try {
+ URL url = deviceLogsService.getLogsFileUrl(logsId, fileId);
+ return ResponseResult.success(url.toString());
+ } catch (Exception e) {
+ log.error("Failed to get the logs file download address.");
+ e.printStackTrace();
+ }
+ return ResponseResult.error("Failed to get the logs file download address.");
+ }
+}
diff --git a/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DevicePayloadController.java b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DevicePayloadController.java
new file mode 100644
index 0000000..0f6a278
--- /dev/null
+++ b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DevicePayloadController.java
@@ -0,0 +1,39 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.web.controller.sample.DevicePayloadController 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.web.controller.sample.dock1;
+
+import com.ard.sample.component.mqtt.model.ChannelName;
+import com.ard.sample.manage.model.receiver.DeviceBasicReceiver;
+import com.ard.sample.manage.service.IDevicePayloadService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.messaging.MessageHeaders;
+import org.springframework.web.bind.annotation.RestController;
+import com.ard.web.controller.sample.DevicePayloadController;
+
+/**
+ * @author sean.zhou
+ * @date 2021/11/19
+ * @version 0.1
+ */
+@RestController
+public class Dock1DevicePayloadController {
+
+ @Autowired
+ private IDevicePayloadService devicePayloadService;
+
+ /**
+ * Handles messages in the state topic about basic drone data.
+ *
+ * Note: Only the data of the drone payload is handled here. You can handle other data from the drone
+ * according to your business needs.
+ * @param deviceBasic basic drone data
+ * @param headers
+ */
+ // 机场1:不复制原 ServiceActivator(inputChannel = ChannelName.INBOUND_STATE_BASIC),消费职责由共享层原类承担
+ public void stateBasic(DeviceBasicReceiver deviceBasic, MessageHeaders headers) {
+ // 由于收到的负载数据的payloadSn为null 而且同时收到2个 暂时无法处理 先注释掉 以后再说
+ // devicePayloadService.saveDeviceBasicPayload(deviceBasic.getPayloads(), headers.getTimestamp());
+ }
+}
\ No newline at end of file
diff --git a/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DockController.java b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DockController.java
new file mode 100644
index 0000000..9d33b09
--- /dev/null
+++ b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DockController.java
@@ -0,0 +1,43 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.web.controller.sample.DockController 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.web.controller.sample.dock1;
+
+import com.ard.common.core.domain.model.LoginUser;
+import com.ard.common.utils.SecurityUtils;
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.manage.model.param.RemoteDebugParam;
+import com.ard.sample.dock1.manage.service.IControlService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import com.ard.web.controller.sample.DockController;
+
+/**
+ * @author sean
+ * @version 1.2
+ * @date 2022/7/29
+ */
+@Api(tags = "机场控制")
+@RestController
+@Slf4j
+@RequestMapping("/dock1/devices")
+public class Dock1DockController {
+
+ @Autowired
+ private IControlService controlService;
+
+ @ApiOperation("创建控制任务")
+ @PostMapping("/{sn}/jobs/{service_identifier}")
+ public ResponseResult createControlJob(@PathVariable String sn,
+ @PathVariable("service_identifier") String serviceIdentifier,
+ @RequestBody(required = false) RemoteDebugParam param) {
+ // 获取谁在调接口,后续可能要websocket发给前端在多路直播中显示
+ //LoginUser loginUser = SecurityUtils.getLoginUser();
+ return controlService.controlDock(sn, serviceIdentifier, param);
+ }
+
+}
diff --git a/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1LiveStreamController.java b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1LiveStreamController.java
new file mode 100644
index 0000000..26afd8b
--- /dev/null
+++ b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1LiveStreamController.java
@@ -0,0 +1,163 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.web.controller.sample.LiveStreamController 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.web.controller.sample.dock1;
+
+import com.ard.common.core.domain.entity.SysUser;
+import com.ard.common.utils.SecurityUtils;
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.component.mqtt.model.ChannelName;
+import com.ard.sample.component.websocket.handler.LiveStreamHandler;
+import com.ard.sample.manage.model.dto.CapacityDeviceDTO;
+import com.ard.sample.manage.model.dto.LiveTypeDTO;
+import com.ard.sample.manage.model.param.RemoteDebugParam;
+import com.ard.sample.manage.model.receiver.LiveCapacityReceiver;
+import com.ard.sample.manage.model.receiver.LiveStatusTwoReceiver;
+import com.ard.sample.manage.service.IDeviceService;
+import com.ard.sample.dock1.manage.service.ILiveStreamService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.messaging.MessageHeaders;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.List;
+import com.ard.web.controller.sample.LiveStreamController;
+
+/**
+ * @author sean.zhou
+ * @version 0.1
+ * @date 2021/11/19
+ */
+
+@Api(tags = "直播功能")
+@RestController
+@Slf4j
+@RequestMapping("/dock1/live")
+public class Dock1LiveStreamController {
+
+ @Autowired
+ private ILiveStreamService liveStreamService;
+
+ @Autowired
+ private LiveStreamHandler _liveStreamHandler;
+
+ @Autowired
+ private ObjectMapper mapper;
+
+ /**
+ * Analyze the live streaming capabilities of drones.
+ * This data is necessary if drones are required for live streaming.
+ *
+ * @param liveCapacity the capacity of drone and dock
+ */
+ // 机场1:不复制原 ServiceActivator(inputChannel = ChannelName.INBOUND_STATE_CAPACITY),消费职责由共享层原类承担
+ public void stateCapacity(LiveCapacityReceiver liveCapacity, MessageHeaders headers) {
+ liveStreamService.saveLiveCapacity(liveCapacity, headers.getTimestamp());
+ }
+
+
+ /**
+ * 处理摄像头返回状态
+ * */
+ // 机场1:不复制原 ServiceActivator(inputChannel = ChannelName.INBOUND_STATE_STATUS),消费职责由共享层原类承担
+ public void LiveStateStatus(LiveStatusTwoReceiver liveReceiver, MessageHeaders headers) {
+// log.info("摄像头返回状态通道INBOUND_STATE_STATUS:{}", liveReceiver);
+ }
+
+ /**
+ * Get live capability data of all drones in the current user's workspace from the database.
+ *
+ * @param request
+ * @return live capability
+ */
+ @ApiOperation(value = "获取直播能力数据", notes = "获取当前用户工作空间下所有无人机的直播能力数据")
+// @PreAuthorize("@ss.hasPermi('dock:live:capacity')")
+ @GetMapping("/capacity")
+ public ResponseResult<List<CapacityDeviceDTO>> getLiveCapacity(HttpServletRequest request) {
+ // Get information about the current user.
+ List<CapacityDeviceDTO> liveCapacity = liveStreamService.getLiveCapacity();
+
+ return ResponseResult.success(liveCapacity);
+ }
+
+ @ApiOperation(value = "获取sn设备的摄像头模式", notes = "获取sn设备的直播能力数据中摄像头的videotype数组")
+ @GetMapping("/videotype")
+ public ResponseResult<List<String>> getVideoType(String sn){
+ return liveStreamService.getLiveLens(sn);
+ }
+
+ @ApiOperation("获取video_id")
+ @PostMapping("/videoid/{sn}")
+ public ResponseResult<LiveTypeDTO> getVideoId(@PathVariable String sn) {
+ LiveTypeDTO liveTypeDTO = new LiveTypeDTO();
+ try{
+ liveTypeDTO = _liveStreamHandler.getLiveParam(sn);
+
+ }
+ catch (Exception e){
+ // videoId = e.getMessage();
+ e.printStackTrace();
+ }
+ return ResponseResult.success(liveTypeDTO);
+ }
+
+ /**
+ * Live streaming according to the parameters passed in from the web side.
+ *
+ * @param liveParam Live streaming parameters.
+ * @return
+ */
+ @ApiOperation(value = "开始直播", notes = "根据前端传入的参数开始直播")
+// @PreAuthorize("@ss.hasPermi('dock:live:start')")
+ @PostMapping("/streams/start")
+ public ResponseResult liveStart(@RequestBody LiveTypeDTO liveParam) {
+ return liveStreamService.liveStart(liveParam);
+ }
+
+ /**
+ * Stop live streaming according to the parameters passed in from the web side.
+ *
+ * @param liveParam Live streaming parameters.
+ * @return
+ */
+ @ApiOperation(value = "停止直播", notes = "根据前端传入的参数停止直播")
+// @PreAuthorize("@ss.hasPermi('dock:live:stop')")
+ @PostMapping("/streams/stop")
+ public ResponseResult liveStop(@RequestBody LiveTypeDTO liveParam) {
+ return liveStreamService.liveStop(liveParam.getVideoId());
+ }
+
+ /**
+ * Set the quality of the live streaming according to the parameters passed in from the web side.
+ *
+ * @param liveParam Live streaming parameters.
+ * @return
+ */
+ @ApiOperation(value = "设置直播质量", notes = "根据前端传入的参数设置直播质量")
+// @PreAuthorize("@ss.hasPermi('dock:live:quality')")
+ @PostMapping("/quality/switch")
+ public ResponseResult liveSetQuality(@RequestBody LiveTypeDTO liveParam) {
+ return liveStreamService.liveSetQuality(liveParam);
+ }
+
+ @ApiOperation(value = "切换镜头", notes = "根据前端传入的参数切换镜头")
+// @PreAuthorize("@ss.hasPermi('dock:live:lens')")
+ @PostMapping("/lens/switch")
+ public ResponseResult liveLensChange(@RequestBody LiveTypeDTO liveParam) {
+ return liveStreamService.liveLensChange(liveParam);
+ }
+
+ @ApiOperation(value = "机场直播相机切换", notes = "根据前端传入的参数切换相机")
+// @PreAuthorize("@ss.hasPermi('dock:live:camera')")
+ @PostMapping("/camera/switch")
+ public ResponseResult liveCameraChange(@RequestBody LiveTypeDTO liveParam) {
+ return liveStreamService.liveCameraChange(liveParam);
+ }
+
+}
\ No newline at end of file
diff --git a/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1TopologyController.java b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1TopologyController.java
new file mode 100644
index 0000000..7321356
--- /dev/null
+++ b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1TopologyController.java
@@ -0,0 +1,45 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.web.controller.sample.TopologyController 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.web.controller.sample.dock1;
+
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.manage.model.dto.TopologyDTO;
+import com.ard.sample.manage.service.ITopologyService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import com.ard.web.controller.sample.TopologyController;
+
+/**
+ * @author sean
+ * @version 0.2
+ * @date 2021/12/8
+ */
+@RestController
+@RequestMapping("/dock1/workspaces")
+public class Dock1TopologyController {
+
+ @Autowired
+ private ITopologyService topologyService;
+
+ /**
+ * Get the topology list of all devices in the current user workspace for pilot display.
+ * @param workspaceId
+ * @return
+ */
+ @GetMapping("/{workspace_id}/devices/topologies")
+ public ResponseResult<Map<String, List<TopologyDTO>>> getDevicesTopologiesForPilot(
+ @PathVariable(name = "workspace_id") String workspaceId) {
+ List<TopologyDTO> topologyList = topologyService.getDeviceTopology(workspaceId);
+ return ResponseResult.success(new ConcurrentHashMap<>(Map.of("list", topologyList)));
+ }
+
+}
diff --git a/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/media/Dock1FileController.java b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/media/Dock1FileController.java
new file mode 100644
index 0000000..f4fbcb3
--- /dev/null
+++ b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/media/Dock1FileController.java
@@ -0,0 +1,95 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.web.controller.sample.media.FileController 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.web.controller.sample.dock1.media;
+
+import com.ard.sample.common.model.PaginationData;
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.media.model.MediaFileDTO;
+import com.ard.sample.media.service.IFileService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.net.URL;
+import com.ard.web.controller.sample.media.FileController;
+
+/**
+ * @author sean
+ * @version 0.2
+ * @date 2021/12/9
+ */
+@Api(tags = "媒体文件管理")
+@RestController
+@RequestMapping("/dock1/media/files")
+public class Dock1FileController {
+
+ @Autowired
+ private IFileService fileService;
+
+ /**
+ * Get information about all the media files in this workspace based on the workspace id.
+ * @return
+ */
+ @ApiOperation("获取所有媒体文件列表")
+ @GetMapping("/files")
+ public ResponseResult<PaginationData<MediaFileDTO>> getFilesList(@RequestParam(defaultValue = "1") Long page,
+ @RequestParam(name = "page_size", defaultValue = "10") Long pageSize,
+ @ApiParam(value = "任务ID,筛选指定任务下的文件", required = false)
+ @RequestParam(name = "job_id", required = false) String jobId,
+ @ApiParam(value = "开始时间", required = false)
+ @RequestParam(name = "start_time", required = false) Long startTime,
+ @ApiParam(value = "结束时间", required = false)
+ @RequestParam(name = "end_time", required = false) Long endTime) {
+ PaginationData<MediaFileDTO> filesList = fileService.getMediaFilesPagination(page, pageSize, jobId, startTime, endTime);
+ return ResponseResult.success(filesList);
+ }
+
+ /**
+ * Query the download address of the file according to the media file id,
+ * and redirect to this address directly for download.
+ * @param fileId
+ * @param response
+ */
+ @ApiOperation("根据id获取媒体文件地址")
+ @GetMapping("/file/{file_id}/url")
+ public void getFileUrl(@PathVariable(name = "file_id") String fileId, HttpServletResponse response) {
+
+ try {
+ URL url = fileService.getObjectUrl(fileId);
+ response.sendRedirect(url.toString());
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ /**
+ * 根据任务id获取全部媒体库文件列表
+ *
+ * @param page 页码,默认值为1,从1开始计数
+ * @param pageSize 每页条数,默认值为10,参数名映射为page_size(前端传参用)
+ * @param jobId 任务ID,必填参数,用于筛选指定任务下的媒体文件
+ * @param isType 文件类型筛选标识,非必填(若需必填可加required = true):
+ * 1 - 筛选图片文件(按 .jpeg/.jpg 扩展名,兼容各机型镜头前缀 W/Z/T/V);
+ * 0 - 筛选视频文件(按 .mp4 扩展名)
+ * @return 分页的媒体文件列表数据
+ * */
+ @ApiOperation("获取任务中媒体文件列表")
+ @GetMapping("/jobfiles")
+ public ResponseResult<PaginationData<MediaFileDTO>> getJobFiles(@ApiParam(value = "页码,默认1", defaultValue = "1")
+ @RequestParam(defaultValue = "1") Long page,
+ @ApiParam(value = "每页条数,默认10", defaultValue = "10")
+ @RequestParam(name = "page_size", defaultValue = "10") Long pageSize,
+ @ApiParam(value = "任务ID(必填),筛选指定任务下的文件", required = true)
+ @RequestParam(name = "job_id") String jobId,
+ @ApiParam(value = "文件类型筛选:1=图片文件,0=视频文件")
+ @RequestParam(name = "isType") String isType) {
+ PaginationData<MediaFileDTO> filesList = fileService.getJobFiles(page, pageSize, jobId, isType);
+ return ResponseResult.success(filesList);
+ }
+}
diff --git a/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/media/Dock1MediaController.java b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/media/Dock1MediaController.java
new file mode 100644
index 0000000..6301b00
--- /dev/null
+++ b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/media/Dock1MediaController.java
@@ -0,0 +1,80 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.web.controller.sample.media.MediaController 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.web.controller.sample.dock1.media;
+
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.component.mqtt.model.MapKeyConst;
+import com.ard.sample.media.model.FileUploadDTO;
+import com.ard.sample.media.service.IMediaService;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import com.ard.web.controller.sample.media.MediaController;
+
+/**
+ * @author sean/workspaces
+ * @version 0.2
+ * @date 2021/12/7
+ */
+@Api(tags = "媒体库管理")
+@Slf4j
+@RestController
+@RequestMapping("/dock1/media")
+public class Dock1MediaController {
+
+ @Autowired
+ private IMediaService mediaService;
+
+ /**
+ * Check if the file has been uploaded by the fingerprint.
+ * @param file
+ * @return
+ */
+ @ApiOperation("检查文件是否已上传")
+ @PostMapping("/fast-upload")
+ public ResponseResult fastUpload( @RequestBody FileUploadDTO file) {
+
+ boolean isExist = mediaService.fastUpload(file.getFingerprint());
+
+ return isExist ? ResponseResult.success() : ResponseResult.error(file.getFingerprint() + "don't exist.");
+ }
+
+ /**
+ * When the file is uploaded to the storage server by pilot,
+ * the basic information of the file is reported through this interface.
+ * @param file
+ * @return
+ */
+ @ApiOperation("上报文件基本信息")
+ @PostMapping("/upload-callback")
+ public ResponseResult<String> uploadCallback(@RequestBody FileUploadDTO file) {
+ mediaService.saveMediaFile(file);
+ return ResponseResult.success(file.getObjectKey());
+
+ }
+
+ /**
+ * 根据工作区 ID 和微小指纹的集合查询此工作区中已存在的文件。
+ * @param tinyFingerprints There is only one tiny_fingerprint parameter in the body.
+ * But it is not recommended to use Map to receive the parameter.
+ * @return
+ */
+ @ApiOperation("查询已存在的文件")
+ @PostMapping("/files/tiny-fingerprints")
+ public ResponseResult<Map<String, List<String>>> uploadCallback(
+ @RequestBody Map<String, List<String>> tinyFingerprints){
+
+ List<String> existingList = mediaService.getExistTinyFingerprints(tinyFingerprints.get(MapKeyConst.TINY_FINGERPRINTS));
+ return ResponseResult.success(new ConcurrentHashMap<>(Map.of(MapKeyConst.TINY_FINGERPRINTS, existingList)));
+ }
+
+}
\ No newline at end of file
diff --git a/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineFileController.java b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineFileController.java
new file mode 100644
index 0000000..1fdf8cc
--- /dev/null
+++ b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineFileController.java
@@ -0,0 +1,170 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.web.controller.sample.wayline.WaylineFileController 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.web.controller.sample.dock1.wayline;
+
+import com.ard.common.constant.HttpStatus;
+import com.ard.common.core.controller.BaseController;
+import com.ard.common.core.domain.model.LoginUser;
+import com.ard.common.core.page.TableDataInfo;
+import com.ard.common.utils.SecurityUtils;
+import com.ard.sample.common.model.PaginationData;
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.wayline.model.dto.WaylineFileDTO;
+import com.ard.sample.wayline.model.dto.WaylineFileUploadDTO;
+import com.ard.sample.wayline.model.param.WaylineQueryParam;
+import com.ard.sample.dock1.wayline.service.IWaylineFileService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.net.URL;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Objects;
+import com.ard.web.controller.sample.wayline.WaylineFileController;
+
+/**
+ * @author sean
+ * @version 0.3
+ * @date 2021/12/22
+ */
+@Api(tags="航线管理")
+@RestController
+@RequestMapping("/dock1/wayline")
+public class Dock1WaylineFileController extends BaseController {
+
+ @Autowired
+ private IWaylineFileService waylineFileService;
+
+ /**
+ * Query the basic data of the wayline file according to the query conditions.
+ * The query condition field in pilot is fixed.
+ * @return
+ */
+ @ApiOperation("查询航线列表")
+ @GetMapping("/list")
+ public TableDataInfo getWaylinesPagination(WaylineQueryParam param) {
+
+ PaginationData<WaylineFileDTO> data = waylineFileService.getWaylinesByParam(param);
+
+ TableDataInfo rspData = new TableDataInfo();
+ rspData.setCode(HttpStatus.SUCCESS);
+ rspData.setMsg("查询成功");
+ rspData.setRows(data.getList());
+ rspData.setTotal(data.getPagination().getTotal());
+ return rspData;
+ }
+
+ /**
+ * Query the download address of the file according to the wayline file id,
+ * and redirect to this address directly for download.
+ * @param waylineId
+ * @param response
+ */
+ @ApiOperation("下载航线文件")
+ @GetMapping("/download")
+ public void getFileUrl(String waylineId, HttpServletResponse response) {
+ try {
+ URL url = waylineFileService.getObjectUrl(waylineId);
+ response.sendRedirect(url.toString());
+
+ } catch (IOException | SQLException e) {
+ e.printStackTrace();
+ }
+ }
+
+ /**
+ * When the wayline file is uploaded to the storage server by pilot,
+ * the basic information of the file is reported through this interface.
+ * @param uploadFile
+ * @return
+ */
+ @ApiOperation("上传航线")
+ @PostMapping("/upload")
+ public ResponseResult uploadCallBack(@RequestBody WaylineFileUploadDTO uploadFile) {
+
+ LoginUser loginUser = SecurityUtils.getLoginUser();
+ WaylineFileDTO metadata = uploadFile.getMetadata();
+ metadata.setUsername(loginUser.getUsername());
+ metadata.setObjectKey(uploadFile.getObjectKey());
+ metadata.setName(uploadFile.getName());
+
+ int id = waylineFileService.saveWaylineFile(metadata);
+
+ return id <= 0 ? ResponseResult.error() : ResponseResult.success();
+ }
+
+ /**
+ * Favorite the wayline file according to the wayline file id.
+ * @param ids wayline file id
+ * @return
+ */
+ @ApiOperation("收藏航线")
+ @PostMapping("/favorites")
+ public ResponseResult markFavorite(@RequestParam(name = "id") List<String> ids) {
+ boolean isMark = waylineFileService.markFavorite(ids, true);
+
+ return isMark ? ResponseResult.success() : ResponseResult.error();
+ }
+
+ /**
+ * Delete the favorites of this wayline file based on the wayline file id.
+ * @param ids wayline file id
+ * @return
+ */
+ @ApiOperation("取消收藏航线")
+ @DeleteMapping("/favorites")
+ public ResponseResult unmarkFavorite(@RequestParam(name = "id") List<String> ids) {
+ boolean isMark = waylineFileService.markFavorite(ids, false);
+
+ return isMark ? ResponseResult.success() : ResponseResult.error();
+ }
+
+ /**
+ * Checking whether the name already exists according to the wayline name must ensure the uniqueness of the wayline name.
+ * This interface will be called when uploading waylines and must be available.
+ * @param names
+ * @return
+ */
+ @ApiOperation("检查航线名称是否重复")
+ @GetMapping("/checkDuplicateNames")
+ public ResponseResult checkDuplicateNames(@RequestParam(name = "name") List<String> names) {
+ List<String> existNamesList = waylineFileService.getDuplicateNames(names);
+ return ResponseResult.success(existNamesList);
+ }
+
+ /**
+ * Delete the wayline file in the workspace according to the wayline id.
+ * @param waylineId
+ * @return
+ */
+ @ApiOperation("删除航线")
+ @DeleteMapping("/delete/{wayline_id}")
+ public ResponseResult deleteWayline(@PathVariable(name = "wayline_id") String waylineId) {
+ boolean isDel = waylineFileService.deleteByWaylineId(waylineId);
+ return isDel ? ResponseResult.success() : ResponseResult.error("Failed to delete wayline.");
+ }
+
+ /**
+ * Import kmz wayline files.
+ * @param file
+ * @return
+ */
+ @ApiOperation("导入航线")
+ @PostMapping("/importKmzFile")
+ public ResponseResult importKmzFile(@RequestPart("file") MultipartFile file) {
+ if (Objects.isNull(file)) {
+ return ResponseResult.error("No file received.");
+ }
+ LoginUser loginUser = SecurityUtils.getLoginUser();
+ String creator = loginUser.getUsername();
+ waylineFileService.importKmzFile(file, creator);
+ return ResponseResult.success();
+ }
+}
diff --git a/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineJobController.java b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineJobController.java
new file mode 100644
index 0000000..273a0bc
--- /dev/null
+++ b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineJobController.java
@@ -0,0 +1,116 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.web.controller.sample.wayline.WaylineJobController 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.web.controller.sample.dock1.wayline;
+
+import com.ard.common.core.domain.model.LoginUser;
+import com.ard.common.utils.SecurityUtils;
+import com.ard.sample.common.model.PaginationData;
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.wayline.model.dto.WaylineJobDTO;
+import com.ard.sample.wayline.model.param.CreateJobParam;
+import com.ard.sample.dock1.wayline.service.IWaylineJobService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import javax.validation.Valid;
+import java.sql.SQLException;
+import java.util.Optional;
+import java.util.Set;
+import com.ard.web.controller.sample.wayline.WaylineJobController;
+
+/**
+ * @author sean
+ * @version 1.1
+ * @date 2022/6/1
+ */
+@Api(tags = "航线任务")
+@RequestMapping("/dock1/wayline")
+@RestController
+public class Dock1WaylineJobController {
+
+ @Autowired
+ private IWaylineJobService waylineJobService;
+
+ /**
+ * Create a wayline task for the Dock.
+ * @param param
+ * @return
+ * @throws SQLException
+ */
+ @ApiOperation("创建航线任务")
+ @PostMapping("/flight-tasks")
+ public ResponseResult createJob(@Valid @RequestBody CreateJobParam param) throws SQLException {
+ LoginUser loginUser = SecurityUtils.getLoginUser();
+ return waylineJobService.publishFlightTask(param, loginUser);
+ }
+
+ @ApiOperation("获取航线任务")
+ @GetMapping("/flight-id")
+ public ResponseResult<WaylineJobDTO> getJob(@RequestParam(name = "job_id") String jobId) {
+ try {
+ if(jobId == null){
+ return ResponseResult.error(98,"任务ID不能为空");
+ }
+ Optional<WaylineJobDTO> waylineJobDTO = waylineJobService.getJobByJobId(jobId);
+ if(waylineJobDTO.isEmpty()) {
+ return ResponseResult.error(99,"任务不存在");
+ }
+ return ResponseResult.success(waylineJobDTO.get());
+ } catch (Exception e) {
+ return ResponseResult.error(e.getMessage());
+ }
+ }
+
+ /**
+ * Paginate through all jobs.
+ * @param page
+ * @param pageSize
+ * @return
+ */
+ @ApiOperation("航线任务列表")
+ @GetMapping("/jobs")
+ public ResponseResult<PaginationData<WaylineJobDTO>> getJobs(@RequestParam(defaultValue = "1") Long page,
+ @RequestParam(name = "page_size", defaultValue = "10") Long pageSize,
+ @RequestParam(required = false) Integer status,
+ @RequestParam(required = false) Long startTime,
+ @RequestParam(required = false) Long endTime,
+ @RequestParam(required = false) Integer taskType,
+ @RequestParam(required = false) String name,
+ @RequestParam(required = false) Boolean isComplete) {
+ try {
+// PaginationData<WaylineJobDTO> data = waylineJobService.getJobsByWorkspaceId(page, pageSize);defaultValue
+ PaginationData<WaylineJobDTO> data = waylineJobService.getJobsList(page, pageSize, status, startTime, endTime, taskType, name, isComplete);
+ return ResponseResult.success(data);
+ }
+ catch (Exception e) {
+ return ResponseResult.error(e.getMessage());
+ }
+ }
+
+ /**
+ * Send the command to cancel the jobs.
+ * @param jobIds
+ * @return
+ * @throws SQLException
+ */
+ @ApiOperation("取消航线任务")
+ @DeleteMapping("/jobs")
+ public ResponseResult publishCancelJob(@RequestParam(name = "job_id") Set<String> jobIds){
+ return waylineJobService.cancelFlightTask(jobIds);
+ }
+
+ /**
+ * Set the media files for this job to upload immediately.
+ * @param jobId
+ * @return
+ */
+ @ApiOperation("设置航线任务上传最高优先级")
+ @PostMapping("/{workspace_id}/jobs/{job_id}/media-highest")
+ public ResponseResult uploadMediaHighestPriority(@PathVariable(name = "job_id") String jobId) {
+ waylineJobService.uploadMediaHighestPriority(jobId);
+ return ResponseResult.success();
+ }
+}
diff --git a/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineTrajectoryController.java b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineTrajectoryController.java
new file mode 100644
index 0000000..d13465a
--- /dev/null
+++ b/ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineTrajectoryController.java
@@ -0,0 +1,46 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.web.controller.sample.wayline.WaylineTrajectoryController 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.web.controller.sample.dock1.wayline;
+
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.manage.model.dto.WaylineTrajectoryDTO;
+// 机场1:与 OSD 轨迹写入共用同一个轨迹服务实例,保证 job_id 能查到完整轨迹。
+import com.ard.sample.manage.service.IWaylineTrajectoryService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Optional;
+import com.ard.web.controller.sample.wayline.WaylineTrajectoryController;
+
+@Api(tags = "航线历史任务轨迹")
+@RequestMapping("/dock1/waylineTrajectory")
+@RestController
+public class Dock1WaylineTrajectoryController {
+ @Autowired
+ private IWaylineTrajectoryService _waylineTrajectoryService;
+
+ @ApiOperation("获取航线任务轨迹")
+ @GetMapping("/flight-id")
+ public ResponseResult<WaylineTrajectoryDTO> getJob(@RequestParam(name = "job_id") String jobId) {
+ try {
+ if(jobId == null){
+ return ResponseResult.error(98,"任务ID不能为空");
+ }
+ WaylineTrajectoryDTO waylineJobDTO = _waylineTrajectoryService.getWayLineTrajectorybyJobID(jobId);
+ if(waylineJobDTO==null) {
+ return ResponseResult.error(99,"任务不存在");
+ }
+ return ResponseResult.success(waylineJobDTO);
+ } catch (Exception e) {
+ return ResponseResult.error(97, "获取航线轨迹发生异常:"+e.getMessage());
+ }
+ }
+}
+
diff --git a/ard-admin/src/main/java/com/ard/web/controller/sample/media/FileController.java b/ard-admin/src/main/java/com/ard/web/controller/sample/media/FileController.java
index 9ef6638..21896de 100644
--- a/ard-admin/src/main/java/com/ard/web/controller/sample/media/FileController.java
+++ b/ard-admin/src/main/java/com/ard/web/controller/sample/media/FileController.java
@@ -70,8 +70,8 @@
* @param pageSize 每页条数,默认值为10,参数名映射为page_size(前端传参用)
* @param jobId 任务ID,必填参数,用于筛选指定任务下的媒体文件
* @param isType 文件类型筛选标识,非必填(若需必填可加required = true):
- * 1 - 筛选文件名包含V.jpeg的文件;
- * 0 - 筛选文件名包含V.mp4的文件
+ * 1 - 筛选图片文件(按 .jpeg/.jpg 扩展名,兼容各机型镜头前缀 W/Z/T/V);
+ * 0 - 筛选视频文件(按 .mp4 扩展名)
* @return 分页的媒体文件列表数据
* */
@ApiOperation("获取任务中媒体文件列表")
@@ -82,7 +82,7 @@
@RequestParam(name = "page_size", defaultValue = "10") Long pageSize,
@ApiParam(value = "任务ID(必填),筛选指定任务下的文件", required = true)
@RequestParam(name = "job_id") String jobId,
- @ApiParam(value = "文件类型筛选:1=V.jpeg文件,0=V.mp4文件")
+ @ApiParam(value = "文件类型筛选:1=图片文件,0=视频文件")
@RequestParam(name = "isType") String isType) {
PaginationData<MediaFileDTO> filesList = fileService.getJobFiles(page, pageSize, jobId, isType);
return ResponseResult.success(filesList);
diff --git a/ard-admin/src/main/resources/application-daqing-druid.yml b/ard-admin/src/main/resources/application-daqing-druid.yml
index d62e8c2..a6a92dc 100644
--- a/ard-admin/src/main/resources/application-daqing-druid.yml
+++ b/ard-admin/src/main/resources/application-daqing-druid.yml
@@ -6,9 +6,13 @@
druid:
# 主库数据源
master:
- url: jdbc:mysql://192.168.10.139:3306/ard-uav?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+# url: jdbc:mysql://10.67.52.22:3306/ard-uav?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+# username: root
+# password: Ard12345*
+ #四厂数据源
+ url: jdbc:mysql://10.67.52.22:3306/ard-uav-inspection?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
- password: Ard12345*
+ password: Uh09100802*
# 从库数据源
slave:
# 从数据源开关/默认关闭
diff --git a/ard-admin/src/main/resources/application-daqing.yml b/ard-admin/src/main/resources/application-daqing.yml
index 40791c4..1a5b093 100644
--- a/ard-admin/src/main/resources/application-daqing.yml
+++ b/ard-admin/src/main/resources/application-daqing.yml
@@ -49,15 +49,15 @@
id: 143640
key: da5babf38a5d05b0f46a7d82910da1b
license: khjd7FzUTzJgaArNv97XdA2nKQtYfESOvBHLqjC6MpvPqxCfuQ82nOlTgzoiLp7UoZujARopN2A1ctPFaJ8oHiHQrn3IRV4wqN3hdSM40xXj02YVvTWQBT54WS8Ks+E8Zatp3gk2T0+7cM5a5a3mWzxLMv7CNqw2z9pFBoOBdpY=
- host: 192.168.10.139
+ host: 10.67.52.22
port: 123
#流媒体
mediamtx:
- apiHost: 192.168.10.139
- playHost: 192.168.10.139
+ apiHost: 10.67.52.22
+ playHost: 10.67.52.22
https: true
- pushHost: 192.168.10.139
+ pushHost: 10.67.52.22
rtmpPort: 1935
rtspPort: 8554
webrtcPort: 8889
@@ -71,7 +71,7 @@
# mqtt配置
mqtt:
protocol: tcp
- host: 192.168.10.139
+ host: 10.67.52.22
port: 1883
username: admin
password: public
@@ -82,7 +82,7 @@
oss:
enable: true
provider: minio
- endpoint: https://192.168.10.139:9001
+ endpoint: https://10.67.52.22:9001
access-key: admin
secret-key: ard12345
bucket: cloud-bucket
@@ -92,9 +92,9 @@
# 机场单独用的oss配置,要http地址
dock:
- endpoint: http://192.168.10.139:9011
+ endpoint: http://10.67.52.22:9011
bucket: dockfile
# 点云文件夹路径配置
pointCloud:
- url : https://192.168.10.139:3001/tileset/
\ No newline at end of file
+ url : https://10.67.52.22:3001/tileset/
\ No newline at end of file
diff --git a/ard-route/src/main/java/com/ard/route/domain/djuav/kml/KmlPayloadInfo.java b/ard-route/src/main/java/com/ard/route/domain/djuav/kml/KmlPayloadInfo.java
index f6e074d..ecdf9ca 100644
--- a/ard-route/src/main/java/com/ard/route/domain/djuav/kml/KmlPayloadInfo.java
+++ b/ard-route/src/main/java/com/ard/route/domain/djuav/kml/KmlPayloadInfo.java
@@ -12,6 +12,12 @@
@XStreamAlias("wpml:payloadEnumValue")
private String payloadEnumValue;
+ /**
+ * 负载子类型。机场1(M30/M30T)生成 KMZ 时需要该字段,其余机型保持原输出。
+ */
+ @XStreamAlias("wpml:payloadSubEnumValue")
+ private String payloadSubEnumValue;
+
@XStreamAlias("wpml:payloadPositionIndex")
private String payloadPositionIndex;
diff --git a/ard-route/src/main/java/com/ard/route/service/impl/ArdRouteInfoServiceImpl.java b/ard-route/src/main/java/com/ard/route/service/impl/ArdRouteInfoServiceImpl.java
index 67677b2..eddf3c2 100644
--- a/ard-route/src/main/java/com/ard/route/service/impl/ArdRouteInfoServiceImpl.java
+++ b/ard-route/src/main/java/com/ard/route/service/impl/ArdRouteInfoServiceImpl.java
@@ -44,6 +44,7 @@
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
+import static com.ard.route.enums.kml.DroneEnumValueEnums.M30_M30T;
import static com.ard.route.enums.kml.DroneEnumValueEnums.M350_RTK;
import static com.ard.route.util.InitializationUtils.divided23;
import static com.ard.route.util.ToolUtils.*;
@@ -174,8 +175,14 @@
}else {
ardRouteInfo.getUavRouteReq().setHeightMode("relativeToStartPoint");
}
- ardRouteInfo.getUavRouteReq().setDroneType(subStringDroneType(ardRouteInfo.getAircraft()));
- KmzResultVo kmzResult = routeService.buildKmz(ardRouteInfo.getUavRouteReq(),ardRouteInfo.getName());
+ UavRouteReq uavRouteReq = ardRouteInfo.getUavRouteReq();
+ uavRouteReq.setDroneType(subStringDroneType(ardRouteInfo.getAircraft()));
+ // 机场1:M30/M30T 需保留 aircraft 中的子类型(0-67-0 / 0-67-1),
+ // 否则 M30T 会按 M30 生成 KMZ(payload 52 而非 53)
+ if (M30_M30T.getValue().equals(uavRouteReq.getDroneType())) {
+ uavRouteReq.setSubDroneType(subStringSubDroneType(ardRouteInfo.getAircraft()));
+ }
+ KmzResultVo kmzResult = routeService.buildKmz(uavRouteReq,ardRouteInfo.getName());
zoomProcessing(ardRouteInfo);
ardRouteInfo.setPath(kmzResult.getPath());
ardRouteInfo.setReadOnly("0");
@@ -271,9 +278,14 @@
// 先删除航线
delOssService(route);
// 再生成航线
- ardRouteInfo.getUavRouteReq().setDroneType(subStringDroneType(ardRouteInfo.getAircraft()));
+ UavRouteReq uavRouteReq = ardRouteInfo.getUavRouteReq();
+ uavRouteReq.setDroneType(subStringDroneType(ardRouteInfo.getAircraft()));
+ // 机场1:M30T(0-67-1) 需同步子类型,避免编辑后按 M30 重新生成 KMZ
+ if (M30_M30T.getValue().equals(uavRouteReq.getDroneType())) {
+ uavRouteReq.setSubDroneType(subStringSubDroneType(ardRouteInfo.getAircraft()));
+ }
zoomProcessing(ardRouteInfo);
- kmzResult = routeService.buildKmz(ardRouteInfo.getUavRouteReq(), ardRouteInfo.getName());
+ kmzResult = routeService.buildKmz(uavRouteReq, ardRouteInfo.getName());
if ("更新航线kmz文件失败".equals(kmzResult.getData())) {
return 0;
}
diff --git a/ard-route/src/main/java/com/ard/route/util/InitializationUtils.java b/ard-route/src/main/java/com/ard/route/util/InitializationUtils.java
index 17fc9b1..56734ea 100644
--- a/ard-route/src/main/java/com/ard/route/util/InitializationUtils.java
+++ b/ard-route/src/main/java/com/ard/route/util/InitializationUtils.java
@@ -4,9 +4,12 @@
import com.ard.route.domain.sixhe.ArdRouteActions;
import com.ard.route.domain.sixhe.ArdRouteChange;
import com.ard.route.domain.sixhe.ArdRoutePoints;
+import com.ard.route.enums.kml.DroneEnumValueEnums;
+import com.ard.route.enums.kml.PayloadTypeEnums;
import java.util.ArrayList;
import java.util.List;
+import java.util.Objects;
import static com.ard.route.enums.kml.DroneEnumValueEnums.M350_RTK;
import static com.ard.route.util.ToolUtils.getDoublePoint;
@@ -38,8 +41,18 @@
uavRouteReq.getRoutePointList().get(0).getHeight());
}
//uavRouteReq.setDroneType(89);
- uavRouteReq.setSubDroneType(0);
- uavRouteReq.setPayloadType(43);
+ // 机场1:M30/M30T 使用机身自带双光/三光相机,payloadEnumValue 分别为 52/53,
+ // 不能沿用 H20T(43)。其余机型保持原有逻辑,机场2/3 的航线生成结果不变。
+ if (Objects.equals(DroneEnumValueEnums.M30_M30T.getValue(), uavRouteReq.getDroneType())) {
+ if (uavRouteReq.getSubDroneType() == null) {
+ uavRouteReq.setSubDroneType(0);
+ }
+ uavRouteReq.setPayloadType(Objects.equals(1, uavRouteReq.getSubDroneType())
+ ? PayloadTypeEnums.M30T.getValue() : PayloadTypeEnums.M30.getValue());
+ } else {
+ uavRouteReq.setSubDroneType(0);
+ uavRouteReq.setPayloadType(43);
+ }
uavRouteReq.setPayloadPosition(0);
uavRouteReq.setImageFormat("wide");
uavRouteReq.setFinishAction("goHome");
diff --git a/ard-route/src/main/java/com/ard/route/util/RouteFileUtils.java b/ard-route/src/main/java/com/ard/route/util/RouteFileUtils.java
index b79dc6a..a364264 100644
--- a/ard-route/src/main/java/com/ard/route/util/RouteFileUtils.java
+++ b/ard-route/src/main/java/com/ard/route/util/RouteFileUtils.java
@@ -167,6 +167,12 @@
public static KmlPayloadInfo buildKmlPayloadInfo(Integer payloadType, Integer payloadPosition) {
KmlPayloadInfo kmlPayloadInfo = new KmlPayloadInfo();
kmlPayloadInfo.setPayloadEnumValue(String.valueOf(payloadType));
+ // 机场1:M30/M30T 的 WPML 需要 payloadSubEnumValue,否则机场侧校验可能不通过;
+ // 其余机型保持原有输出,不影响机场2/3。
+ if (Objects.equals(PayloadTypeEnums.M30.getValue(), payloadType)
+ || Objects.equals(PayloadTypeEnums.M30T.getValue(), payloadType)) {
+ kmlPayloadInfo.setPayloadSubEnumValue("0");
+ }
kmlPayloadInfo.setPayloadPositionIndex(String.valueOf(payloadPosition));
return kmlPayloadInfo;
}
diff --git a/ard-route/src/main/java/com/ard/route/util/ToolUtils.java b/ard-route/src/main/java/com/ard/route/util/ToolUtils.java
index dcab57b..ef51939 100644
--- a/ard-route/src/main/java/com/ard/route/util/ToolUtils.java
+++ b/ard-route/src/main/java/com/ard/route/util/ToolUtils.java
@@ -2,10 +2,12 @@
import com.ard.inspection.domain.ArdPointCloud;
import com.ard.route.domain.djuav.UavRouteReq;
+import com.ard.route.enums.kml.DroneEnumValueEnums;
import java.io.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
+import java.util.Objects;
/**
* 工具类
@@ -139,6 +141,15 @@
}
/**
+ * 截取机型子类型
+ * 参数: "0-67-1" 返回 1;格式不足三段时返回 0
+ */
+ public static Integer subStringSubDroneType(String str) {
+ String[] parts = str.split("-");
+ return parts.length > 2 ? Integer.valueOf(parts[2]) : 0;
+ }
+
+ /**
* 截取字符串
* 获取经纬度高度
*/
@@ -173,6 +184,12 @@
* 获取无人机类型方法
*/
public static String droneTypeData(UavRouteReq uavRouteReq) {
+ // 机场1:M30(67-0) / M30T(67-1) 需要保留 subType,避免航线记录的 aircraft 都变成 0-67-0。
+ // 其它机型保持原有返回值,机场2/3 不受影响。
+ if (Objects.equals(DroneEnumValueEnums.M30_M30T.getValue(), uavRouteReq.getDroneType())) {
+ return "0-" + uavRouteReq.getDroneType() + "-"
+ + (uavRouteReq.getSubDroneType() == null ? 0 : uavRouteReq.getSubDroneType());
+ }
return "0-" + uavRouteReq.getDroneType() + "-0";
}
diff --git a/ard-route/src/main/java/com/ard/route/util/UavRouteReqUtils.java b/ard-route/src/main/java/com/ard/route/util/UavRouteReqUtils.java
index b9d620e..55ef274 100644
--- a/ard-route/src/main/java/com/ard/route/util/UavRouteReqUtils.java
+++ b/ard-route/src/main/java/com/ard/route/util/UavRouteReqUtils.java
@@ -51,7 +51,12 @@
if (vo.getKmlInfo().getDocument().getFolder().getTemplateType() != null){
uavRouteReq.setTemplateType(vo.getKmlInfo().getDocument().getFolder().getTemplateType());
}
- uavRouteReq.setDroneType(Integer.valueOf(vo.getKmlInfo().getDocument().getKmlMissionConfig().getDroneInfo().getDroneEnumValue()));
+ KmlDroneInfo droneInfo = vo.getKmlInfo().getDocument().getKmlMissionConfig().getDroneInfo();
+ uavRouteReq.setDroneType(Integer.valueOf(droneInfo.getDroneEnumValue()));
+ // 机场1:M30/M30T 依赖 droneSubEnumValue 区分(0-67-0 / 0-67-1),导入 KMZ 时需一并保留
+ if (droneInfo.getDroneSubEnumValue() != null && !droneInfo.getDroneSubEnumValue().isEmpty()) {
+ uavRouteReq.setSubDroneType(Integer.valueOf(droneInfo.getDroneSubEnumValue()));
+ }
uavRouteReq.setImageFormat(vo.getKmlInfo().getDocument().getFolder().getPayloadParam().getImageFormat());
uavRouteReq.setGlobalHeight(Double.valueOf(vo.getKmlInfo().getDocument().getFolder().getGlobalHeight()));
uavRouteReq.setAutoFlightSpeed(Double.valueOf(vo.getKmlInfo().getDocument().getFolder().getAutoFlightSpeed()));
diff --git a/ard-sample/src/main/java/com/ard/sample/component/mqtt/config/MqttMessageChannel.java b/ard-sample/src/main/java/com/ard/sample/component/mqtt/config/MqttMessageChannel.java
index ce6fc87..b94fbaf 100644
--- a/ard-sample/src/main/java/com/ard/sample/component/mqtt/config/MqttMessageChannel.java
+++ b/ard-sample/src/main/java/com/ard/sample/component/mqtt/config/MqttMessageChannel.java
@@ -115,6 +115,14 @@
return new DirectChannel();
}
+ /**
+ * 机场1:航线准备异常通知通道。
+ */
+ @Bean(name = ChannelName.INBOUND_EVENTS_FLIGHT_SETUP_EXCEPTION_NOTIFY)
+ public MessageChannel eventsFlightSetupExceptionNotifyChannel() {
+ return new DirectChannel();
+ }
+
@Bean(name = ChannelName.INBOUND_EVENTS_FILE_UPLOAD_CALLBACK)
public MessageChannel eventsFileUploadCallbackChannel() {
return new DirectChannel();
diff --git a/ard-sample/src/main/java/com/ard/sample/component/mqtt/handler/StateLiveStatusHandler.java b/ard-sample/src/main/java/com/ard/sample/component/mqtt/handler/StateLiveStatusHandler.java
index 7bc77ef..1b22057 100644
--- a/ard-sample/src/main/java/com/ard/sample/component/mqtt/handler/StateLiveStatusHandler.java
+++ b/ard-sample/src/main/java/com/ard/sample/component/mqtt/handler/StateLiveStatusHandler.java
@@ -17,6 +17,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Map;
@@ -43,7 +44,9 @@
List<LiveStatusTwoReceiver> list = mapper.convertValue(dataNode.get(StateDataEnum.LIVESTATUS.getDesc()),
new TypeReference<List<LiveStatusTwoReceiver>>() {});
for (LiveStatusTwoReceiver receiver : list) {
- if(receiver.getVideoId()!=null && receiver.getVideoId().contains(childSn)){
+ // 机场1(Dock1)未连接飞行器时 childDeviceSn 为空,跳过匹配,避免 String.contains(null) 抛 NPE
+ if (StringUtils.hasText(childSn) && receiver.getVideoId() != null
+ && receiver.getVideoId().contains(childSn)) {
stateReceiver.setData(receiver);
continue;
}
@@ -56,7 +59,10 @@
catch (Exception e){
log.error("接收state直播设备状态反馈转换异常:{}",e);
}
- _deviceService.setChildRedisCamera(childSn, liveStatus);
+ // 无子设备 SN 时不写缓存,避免产生 COVER_STATE_PREFIX + null 的非法 Redis key
+ if (StringUtils.hasText(childSn)) {
+ _deviceService.setChildRedisCamera(childSn, liveStatus);
+ }
}
return stateReceiver;
diff --git a/ard-sample/src/main/java/com/ard/sample/component/mqtt/model/ChannelName.java b/ard-sample/src/main/java/com/ard/sample/component/mqtt/model/ChannelName.java
index 2ac0fc6..8cca3fa 100644
--- a/ard-sample/src/main/java/com/ard/sample/component/mqtt/model/ChannelName.java
+++ b/ard-sample/src/main/java/com/ard/sample/component/mqtt/model/ChannelName.java
@@ -55,6 +55,11 @@
public static final String INBOUND_EVENTS_FLIGHT_TASK_PROGRESS = "inboundEventsFlightTaskProgress";
+ /**
+ * 机场1:航线准备异常通知(flight_setup_exception_notify),Dock1/M30 新增事件。
+ */
+ public static final String INBOUND_EVENTS_FLIGHT_SETUP_EXCEPTION_NOTIFY = "inboundEventsFlightSetupExceptionNotify";
+
public static final String INBOUND_EVENTS_FILE_UPLOAD_CALLBACK = "inboundEventsFileUploadCallback";
public static final String INBOUND_REQUESTS_AIRPORT_BIND_STATUS = "inboundRequestsAirportBindStatus";
diff --git a/ard-sample/src/main/java/com/ard/sample/component/mqtt/model/EventsMethodEnum.java b/ard-sample/src/main/java/com/ard/sample/component/mqtt/model/EventsMethodEnum.java
index be4130c..b24f1a0 100644
--- a/ard-sample/src/main/java/com/ard/sample/component/mqtt/model/EventsMethodEnum.java
+++ b/ard-sample/src/main/java/com/ard/sample/component/mqtt/model/EventsMethodEnum.java
@@ -11,6 +11,11 @@
FLIGHT_TASK_PROGRESS("flighttask_progress", ChannelName.INBOUND_EVENTS_FLIGHT_TASK_PROGRESS),
+ /**
+ * 机场1:航线准备异常通知(Dock1/M30 新增事件,共享层原枚举缺少该方法)。
+ */
+ FLIGHT_SETUP_EXCEPTION_NOTIFY("flight_setup_exception_notify", ChannelName.INBOUND_EVENTS_FLIGHT_SETUP_EXCEPTION_NOTIFY),
+
FILE_UPLOAD_CALLBACK("file_upload_callback", ChannelName.INBOUND_EVENTS_FILE_UPLOAD_CALLBACK),
HMS("hms", ChannelName.INBOUND_EVENTS_HMS),
diff --git a/ard-sample/src/main/java/com/ard/sample/component/websocket/handler/DockWebSocketHandler.java b/ard-sample/src/main/java/com/ard/sample/component/websocket/handler/DockWebSocketHandler.java
index 2d39797..6ad0a20 100644
--- a/ard-sample/src/main/java/com/ard/sample/component/websocket/handler/DockWebSocketHandler.java
+++ b/ard-sample/src/main/java/com/ard/sample/component/websocket/handler/DockWebSocketHandler.java
@@ -18,6 +18,7 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
/**
@@ -38,6 +39,12 @@
private static final long HEARTBEAT_TIMEOUT = 90;
/** 心跳检测任务间隔(秒):定时扫描超时连接 */
private static final long HEARTBEAT_CHECK_INTERVAL = 10;
+ /**
+ * 停流延迟(秒):页面刷新或网络抖动会先断开旧连接再建立新连接,
+ * 断开时立即 live_stop 会让新页面需要重新起流,视频出现空档甚至丢失。
+ * 延迟期间重新订阅则取消停止。
+ */
+ private static final long LIVE_STOP_DELAY_SECONDS = 30;
// ====================== 核心存储(线程安全) ======================
/** 用户会话映射:userId -> WebSocketSession */
@@ -48,12 +55,21 @@
private final Map<WebSocketSession, Object> sessionLocks = new ConcurrentHashMap<>();
/** 用户最后心跳时间:userId -> 最后心跳时间戳(毫秒) */
private final Map<String, Long> userLastHeartbeatTime = new ConcurrentHashMap<>();
+ /** 待执行的延迟停流任务:设备SN -> 定时任务 */
+ private final Map<String, ScheduledFuture<?>> pendingLiveStops = new ConcurrentHashMap<>();
// ====================== 线程池 ======================
/** 心跳检测线程池(单线程守护线程) */
private final ScheduledExecutorService heartbeatCheckExecutor = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "websocket-heartbeat-check-thread");
thread.setDaemon(true); // 守护线程,不阻塞JVM退出
+ return thread;
+ });
+
+ /** 延迟停流线程池(单线程守护线程),停流可能走 HTTP,独立线程避免阻塞心跳检测 */
+ private final ScheduledExecutorService liveStopExecutor = Executors.newSingleThreadScheduledExecutor(runnable -> {
+ Thread thread = new Thread(runnable, "websocket-live-stop-thread");
+ thread.setDaemon(true);
return thread;
});
@@ -188,6 +204,10 @@
if (isSubscribe) {
// 订阅操作:不存在则创建,存在则添加用户
subscriptions.computeIfAbsent(subscriptionKey, k -> ConcurrentHashMap.newKeySet()).add(userId);
+ // 页面刷新会先断连再重连,新订阅到达时取消上一次断连触发的延迟停流
+ if (MessageType.LIVESTREAM.getType().equals(type)) {
+ cancelPendingLiveStop(sn);
+ }
// log.info("用户[{}]订阅设备[{}]的[{}]类型消息", userId, sn, type);
// // 直播类型订阅:触发开启直播
@@ -202,12 +222,12 @@
subscriberIds.remove(userId);
log.info("用户[{}]取消订阅设备[{}]的[{}]类型消息", userId, sn, type);
- // 订阅组为空时移除,并停止直播(直播类型)
+ // 订阅组为空时移除,并延迟停止直播(直播类型)。
+ // 不立即停流:页面刷新会先断连再重连,立即停流会导致视频长时间黑屏。
if (subscriberIds.isEmpty()) {
subscriptions.remove(subscriptionKey);
if (MessageType.LIVESTREAM.getType().equals(type)) {
- _liveStreamService.StopLiveStreaming(sn);
- log.info("设备[{}]直播流已停止(无订阅用户)", sn);
+ scheduleStopLiveStream(sn);
}
}
}
@@ -283,10 +303,13 @@
// 线程安全发送消息
try {
- if(MessageType.LIVESTREAM.getType().equals(type)) {
+ // 指定了接收用户时只推给该用户;uId 为空(如后台定时任务起流)时推给全部订阅者
+ if (MessageType.LIVESTREAM.getType().equals(type) && uId != null && !uId.isEmpty()) {
WebSocketSession liveSession = userSessions.get(uId);
- sendMessage(liveSession, content);
- log.info("单独向用户[{}]推送设备[{}]的[{}]类型消息:{}", uId, sn, type, content);
+ if (liveSession != null && liveSession.isOpen()) {
+ sendMessage(liveSession, content);
+ log.info("单独向用户[{}]推送设备[{}]的[{}]类型消息:{}", uId, sn, type, content);
+ }
return;
}
sendMessage(session, content);
@@ -367,17 +390,52 @@
if (subscriberIds.remove(userId)) {
log.debug("用户[{}]已移除订阅:{}", userId, entry.getKey());
- // 订阅组为空时移除,并停止直播(直播类型)
+ // 订阅组为空时移除,并延迟停止直播(直播类型)。
+ // 页面刷新/短暂断网会重建连接,延迟停流可避免视频空档;期间重新订阅会取消停止。
if (subscriberIds.isEmpty()) {
String subscriptionKey = entry.getKey();
if (subscriptionKey.contains(MessageType.LIVESTREAM.getType())) {
String sn = subscriptionKey.split(":")[0];
- _liveStreamService.StopLiveStreaming(sn);
- log.info("设备[{}]无订阅用户,已停止直播流", sn);
+ scheduleStopLiveStream(sn);
}
iterator.remove(); // 安全移除空订阅组
}
}
+ }
+ }
+
+ /**
+ * 延迟停止直播流。
+ * 页面刷新/网络抖动会先断开旧连接再建立新连接,断开时立即停流会让新页面重新起流,
+ * 视频出现空档甚至丢失;这里延迟 LIVE_STOP_DELAY_SECONDS 秒再停,期间有新订阅则取消。
+ */
+ private void scheduleStopLiveStream(String sn) {
+ String subscriptionKey = getSubscriptionKey(sn, MessageType.LIVESTREAM.getType());
+ pendingLiveStops.compute(sn, (key, oldFuture) -> {
+ if (oldFuture != null && !oldFuture.isDone()) {
+ oldFuture.cancel(false);
+ }
+ return liveStopExecutor.schedule(() -> {
+ pendingLiveStops.remove(sn);
+ Set<String> subscriberIds = subscriptions.get(subscriptionKey);
+ // 延迟期间又有用户订阅(如页面刷新重连),不停止直播
+ if (subscriberIds != null && !subscriberIds.isEmpty()) {
+ log.info("设备[{}]延迟停流期间重新订阅,继续直播", sn);
+ return;
+ }
+ _liveStreamService.StopLiveStreaming(sn);
+ log.info("设备[{}]无订阅用户(已延迟{}秒),停止直播流", sn, LIVE_STOP_DELAY_SECONDS);
+ }, LIVE_STOP_DELAY_SECONDS, TimeUnit.SECONDS);
+ });
+ }
+
+ /**
+ * 取消待执行的延迟停流任务(设备重新被订阅时调用)。
+ */
+ private void cancelPendingLiveStop(String sn) {
+ ScheduledFuture<?> future = pendingLiveStops.remove(sn);
+ if (future != null && !future.isDone()) {
+ future.cancel(false);
}
}
@@ -402,6 +460,10 @@
log.error("WebSocket心跳检测线程池关闭被中断", e);
}
}
+ // 关闭延迟停流线程池
+ if (liveStopExecutor != null && !liveStopExecutor.isShutdown()) {
+ liveStopExecutor.shutdownNow();
+ }
log.info("WebSocket处理器资源已销毁");
}
}
\ No newline at end of file
diff --git a/ard-sample/src/main/java/com/ard/sample/component/websocket/handler/LiveStreamHandler.java b/ard-sample/src/main/java/com/ard/sample/component/websocket/handler/LiveStreamHandler.java
index 0cfc482..d8ea6e6 100644
--- a/ard-sample/src/main/java/com/ard/sample/component/websocket/handler/LiveStreamHandler.java
+++ b/ard-sample/src/main/java/com/ard/sample/component/websocket/handler/LiveStreamHandler.java
@@ -79,23 +79,21 @@
public void StopLiveStreaming(String sn) {
ResponseResult rlive = null;
-// String resultLive = "";
try {
// 获取直播参数
LiveTypeDTO param = getLiveParam(sn);
// 停止直播逻辑
rlive = _liveStreamService.liveStop(param.getVideoId());
log.info("SN: {}-停止直播!返回值:{}", sn, rlive);
-// rlive.setGateway(sn);
-// rlive.setType(MessageType.LIVESTREAM.getType());
-// resultLive = _objectMapper.writeValueAsString(rlive);
- RedisOpsUtils.del(RedisConst.WEBSOCKET_VIDEO_ADDRESS_PREFIX + sn);
- log.info("SN: {}-停止直播,并删除redis直播信息!", sn);
} catch (Exception e) {
- log.info("SN: {}-停止直播时发生异常,原因:{}", sn,e.getMessage());
- e.printStackTrace();
+ log.info("SN: {}-停止直播时发生异常,原因:{}", sn, e.getMessage(), e);
+ } finally {
+ // 无论停流是否成功都清理缓存地址:
+ // 设备离线等原因导致停流异常时,若不清 Redis,后续订阅会直接拿到失效地址,
+ // 页面持续黑屏,只有等设备离线清理缓存后才能恢复
+ RedisOpsUtils.del(RedisConst.WEBSOCKET_VIDEO_ADDRESS_PREFIX + sn);
+ log.info("SN: {}-已删除redis直播信息!", sn);
}
- // 停止直播不返回数据,直接删除redis中的直播地址标记即可;并且方法在websocket连接断开后执行。
}
public String getResult(String sn, String msg) {
@@ -140,32 +138,43 @@
continue;
}
List<CapacityCameraDTO> camerasList = device.getCamerasList();
+ if (camerasList == null || camerasList.isEmpty()) {
+ continue;
+ }
for (CapacityCameraDTO camera : camerasList) {
- // 机场3中飞行器,没有name,需要其他条件确定主摄像头参数
- for(CapacityVideoDTO video : camera.getVideosList())
- {
- if(video.getSwitchVideoTypes().size() == 1 && camera.getName() == null)
- {
- continue;
+ List<CapacityVideoDTO> videosList = camera.getVideosList();
+ if (videosList == null || videosList.isEmpty()) {
+ continue;
+ }
+ // 机场1:M30 / M30T 相机索引为 52-x-x / 53-x-x,live_capacity 可能同时上报
+ // normal-0、zoom-0、ir-0 多条码流。默认优先选 normal 码流(可见光),
+ // 避免原来“循环取最后一条”时默认落到红外。其他机型保持原行为(取最后一条)。
+ boolean m30SeriesCamera = camera.getIndex() != null
+ && (camera.getIndex().startsWith("52-") || camera.getIndex().startsWith("53-"));
+ CapacityVideoDTO selectedVideo = null;
+ for (CapacityVideoDTO video : videosList) {
+ selectedVideo = video;
+ if (m30SeriesCamera && video.getIndex() != null
+ && video.getIndex().startsWith("normal")) {
+ break;
}
}
-// camera.getVideosList().get(0).getSwitchVideoTypes().add("main");
-// if(camera.getVideosList().size() == 1 && camera.getName() == null) {
-// continue;
-// }
-// if (camera.getName() == null || camera.getName().isEmpty()) {
-// continue;
-// }
- String videoid = device.getSn() + "/" + camera.getIndex();
- List<CapacityVideoDTO> videosList = camera.getVideosList();
- for (CapacityVideoDTO video : videosList) {
- videoid = videoid + "/" + video.getIndex();
- param.setUrlType(1);
- param.setUrl("");
- param.setVideoId(videoid);
- param.setVideoQuality(1);
- param.setVideoType(video.getSwitchVideoTypes().get(0));
+ if (selectedVideo == null) {
+ continue;
}
+ List<String> switchVideoTypes = selectedVideo.getSwitchVideoTypes();
+ if (switchVideoTypes == null || switchVideoTypes.isEmpty()) {
+ // 没有镜头类型信息时跳过该相机,避免 get(0) 越界
+ continue;
+ }
+ // 机场1:video_id 格式固定为 {sn}/{camera_index}/{video_index}。
+ // 按选中的码流独立构造,单码流设备结果与原来完全一致。
+ String videoid = device.getSn() + "/" + camera.getIndex() + "/" + selectedVideo.getIndex();
+ param.setUrlType(1);
+ param.setUrl("");
+ param.setVideoId(videoid);
+ param.setVideoQuality(1);
+ param.setVideoType(switchVideoTypes.get(0));
}
}
return param;
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1DroneAuthorityInfoReceiver.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1DroneAuthorityInfoReceiver.java
new file mode 100644
index 0000000..f1c14b4
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1DroneAuthorityInfoReceiver.java
@@ -0,0 +1,35 @@
+/*
+ * 【机场1】dock1 独有 OSD 子结构(DJI Dock 1 / M30)。
+ * 官方定义见上云 API Dock1 分册 properties:drone_authority_info(飞行器控制权状态更新)。
+ */
+package com.ard.sample.dock1.manage.model.receiver;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 【机场1】飞行器控制权状态更新 struct(dock2 无此结构)
+ */
+@Data
+public class Dock1DroneAuthorityInfoReceiver {
+
+ /**
+ * 飞行控制权 enum_string {"":"未获取","A":"A控","B":"B控"}
+ * */
+ @JsonProperty("control_source")
+ private String controlSource;
+
+ /**
+ * 飞行控制权是否锁定(负载控制权不支持锁定) bool
+ * */
+ @JsonProperty("locked")
+ private Boolean locked;
+
+ /**
+ * 负载 array {"size": -, "item_type": struct}
+ * */
+ @JsonProperty("payloads")
+ private List<Dock1DroneAuthorityPayloadReceiver> payloads;
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1DroneAuthorityPayloadReceiver.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1DroneAuthorityPayloadReceiver.java
new file mode 100644
index 0000000..655e4fc
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1DroneAuthorityPayloadReceiver.java
@@ -0,0 +1,33 @@
+/*
+ * 【机场1】dock1 独有 OSD 子结构(DJI Dock 1 / M30)。
+ * 官方定义见上云 API Dock1 分册 properties:drone_authority_info.payloads 数组元素。
+ */
+package com.ard.sample.dock1.manage.model.receiver;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/**
+ * 【机场1】飞行器控制权 - 负载控制权 struct(drone_authority_info.payloads 元素)
+ */
+@Data
+public class Dock1DroneAuthorityPayloadReceiver {
+
+ /**
+ * 负载控制权 enum_string {"":"未获取","A":"A控","B":"B控"}
+ * */
+ @JsonProperty("control_source")
+ private String controlSource;
+
+ /**
+ * 负载编号 text 格式 {type-subtype-gimbalindex}
+ * */
+ @JsonProperty("payload_index")
+ private String payloadIndex;
+
+ /**
+ * 负载序列号(SN) text
+ * */
+ @JsonProperty("sn")
+ private String sn;
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1OsdDockReceiver.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1OsdDockReceiver.java
new file mode 100644
index 0000000..5c567e7
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1OsdDockReceiver.java
@@ -0,0 +1,306 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.manage.model.receiver.OsdDockReceiver 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.manage.model.receiver;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+import com.ard.sample.manage.model.receiver.AlternateLandPointReceiver;
+import com.ard.sample.manage.model.receiver.BackupBatteryReceiver;
+import com.ard.sample.manage.model.receiver.DeviceMaintainStatusReceiver;
+import com.ard.sample.manage.model.receiver.DockMediaFileDetailReceiver;
+import com.ard.sample.manage.model.receiver.DockSdrReceiver;
+import com.ard.sample.manage.model.receiver.DockSubDeviceReceiver;
+import com.ard.sample.manage.model.receiver.DockWirelessLinkReceiver;
+import com.ard.sample.manage.model.receiver.DroneBatteryMaintenanceInfoReceiver;
+import com.ard.sample.manage.model.receiver.DroneChargeStateReceiver;
+import com.ard.sample.manage.model.receiver.NetworkStateReceiver;
+import com.ard.sample.manage.model.receiver.OsdDockReceiver;
+import com.ard.sample.manage.model.receiver.PositionStateReceiver;
+import com.ard.sample.manage.model.receiver.StorageReceiver;
+
+/**
+ * @author sean
+ * @version 1.3
+ * @date 2022/11/3
+ */
+@Data
+public class Dock1OsdDockReceiver {
+
+ /**
+ * 网络状态 struct
+ * */
+ @JsonProperty("network_state")
+ private NetworkStateReceiver networkState;
+
+ /**
+ * 飞行器是否在舱 enum_int {"0":"舱外","1":"舱内"}
+ * */
+ @JsonProperty("drone_in_dock")
+ private Integer droneInDock;
+
+ /**
+ * 飞行器充电状态 struct
+ * */
+ @JsonProperty("drone_charge_state")
+ private DroneChargeStateReceiver droneChargeState;
+
+ /**
+ * 降雨量 enum_int
+ * "0":"无雨"
+ * "1":"小雨"
+ * "2":"中雨"
+ * "3":"大雨"
+ * */
+ @JsonProperty("rainfall")
+ private Integer rainfall;
+
+ /**
+ * 风速 float {"unit_name":"米每秒 / m/s"}
+ * */
+ @JsonProperty("wind_speed")
+ private Float windSpeed;
+
+ /**
+ * 环境温度 float {"unit_name":"摄氏度 / °C"}
+ * */
+ @JsonProperty("environment_temperature")
+ private Float environmentTemperature;
+
+ /**
+ * 舱内温度 float {"unit_name":"摄氏度 / °C"}
+ * */
+ @JsonProperty("temperature")
+ private Float temperature;
+
+ /**
+ * 舱内湿度 float {"unit_name":"相对湿度 / %RH"}
+ * */
+ @JsonProperty("humidity")
+ private Integer humidity;
+
+ /**
+ * 纬度 double {"max":"90","min":"-90","step":"0.01"}
+ * */
+ @JsonProperty("latitude")
+ private Double latitude;
+
+ /**
+ * 经度 double {"max":"180","min":"-180","step":"0.01"}
+ * */
+ @JsonProperty("longitude")
+ private Double longitude;
+
+ /**
+ * 椭球高度 double {"unit_name":"米 / m"}
+ * */
+ @JsonProperty("height")
+ private Double height;
+
+ /**
+ * 备降点 struct
+ * */
+ @JsonProperty("alternate_land_point")
+ private AlternateLandPointReceiver alternateLandPoint;
+
+ /**
+ * 首次上电时间 int {"unit_name":"毫秒 / ms"}
+ * */
+ @JsonProperty("first_power_on")
+ private Long firstPowerOn;
+
+ /**
+ * 搜星状态 struct
+ * */
+ @JsonProperty("position_state")
+ private PositionStateReceiver positionState;
+
+ /**
+ * 存储容量 struct
+ * */
+ @JsonProperty("storage")
+ private StorageReceiver storage;
+
+ /**
+ * 机场状态 enum_int
+ * "0":"空闲中",
+ * "1":"现场调试",
+ * "2":"远程调试",
+ * "3":"固件升级中",
+ * "4":"作业中",
+ * "5":"待标定"
+ * */
+ @JsonProperty("mode_code")
+ private Integer modeCode;
+
+ /**
+ * 舱盖状态 enum_int
+ * "0":"关闭",
+ * "1":"打开",
+ * "2":"半开",
+ * "3":"舱盖状态异常"
+ * */
+ @JsonProperty("cover_state")
+ private Integer coverState;
+
+ /**
+ *机场静音模式 enum_int {"0":"非静音模式","1":"静音模式"}
+ * */
+ @JsonProperty("silent_mode")
+ private Integer silentMode;
+
+ /**
+ * 补光灯状态 enum_int {"0":"关闭","1":"打开"}
+ * */
+ @JsonProperty("supplement_light_state")
+ private Integer supplementLightState;
+
+ /**
+ * 紧急停止按钮状态 enum_int {"0":"关闭","1":"开启"}
+ * */
+ @JsonProperty("emergency_stop_state")
+ private Integer emergencyStopState;
+
+ /**
+ * 空调模式 上云文档中没有注释
+ * */
+ @JsonProperty("air_conditioner_mode")
+ private Integer airConditionerMode;
+
+ /**
+ * 电池运行模式 enum_int {"1":"计划模式","2":"待命模式"}
+ * */
+ @JsonProperty("battery_store_mode")
+ private Integer batteryStoreMode;
+
+ /**
+ * 机场声光报警状态 enum_int {"0":"关闭","1":"开启"}
+ * */
+ @JsonProperty("alarm_state")
+ private Integer alarmState;
+
+ /**
+ * 推杆状态 enum_int {"0":"关闭","1":"打开","2":"半开","3":"推杆状态异常"}
+ * */
+ @JsonProperty("putter_state")
+ private Integer putterState;
+
+ /**
+ * 子设备状态 struct
+ * */
+ @JsonProperty("sub_device")
+ private DockSubDeviceReceiver subDevice;
+
+ /**
+ * 机场累计作业次数 int {"unit_name":"次 / count"}
+ * */
+ @JsonProperty("job_number")
+ private Integer jobNumber;
+
+ /**
+ * 机场累计运行时长 int {"unit_name":"秒 / s"}
+ * */
+ @JsonProperty("acc_time")
+ private Long accTime;
+
+ /**
+ * 机场激活时间(unix 时间戳) int {"unit_name":"秒 / s"}
+ * */
+ @JsonProperty("activation_time")
+ private Long activationTime;
+
+ /**
+ * 保养信息 struct
+ * */
+ @JsonProperty("maintain_status")
+ private DeviceMaintainStatusReceiver maintainStatus;
+
+ /**
+ * 市电电压 int {"unit_name":"伏特 / V"}
+ * */
+ @JsonProperty("electric_supply_voltage")
+ private Integer electricSupplyVoltage;
+
+ /**
+ * 工作电压 int {"unit_name":"毫伏 / mV"}
+ * */
+ @JsonProperty("working_voltage")
+ private Integer workingVoltage;
+
+ /**
+ * 工作电流 float {"unit_name":"毫安 / mA"}
+ * */
+ @JsonProperty("working_current")
+ private Integer workingCurrent;
+
+ /**
+ * 机场备用电池信息 struct
+ * */
+ @JsonProperty("backup_battery")
+ private BackupBatteryReceiver backupBattery;
+
+ /**
+ * 飞行器电池保养信息 struct
+ * */
+ @JsonProperty("drone_battery_maintenance_info")
+ private DroneBatteryMaintenanceInfoReceiver droneBatteryMaintenanceInfo;
+
+ /**
+ * 机场任务状态 enum_int
+ * "0":"作业准备中",
+ * "1":"飞行作业中",
+ * "2":"作业后状态恢复",
+ * "3":"自定义飞行区更新中",
+ * "4":"地形障碍物更新中",
+ * "5":"任务空闲",
+ * "255":"飞行器异常",
+ * "256":"未知状态"
+ * */
+ @JsonProperty("flighttask_step_code")
+ private Integer flighttaskStepCode;
+
+ /**
+ * 飞行任务准备容量 上云文档中没有
+ * */
+ @JsonProperty("flighttask_prepare_capacity")
+ private Integer flighttaskPrepareCapacity;
+
+ /**
+ * 媒体文件上传细节 struct
+ * */
+ @JsonProperty("media_file_detail")
+ private DockMediaFileDetailReceiver mediaFileDetail;
+
+ /**
+ *
+ * */
+ @JsonProperty("sdr")
+ private DockSdrReceiver sdr;
+
+ /**
+ * 图传链路 struct
+ * */
+ @JsonProperty("wireless_link")
+ private DockWirelessLinkReceiver wirelessLink;
+
+ // 机场1:C2 以下 3 个字段为 dock1 独有(dock2 分册无此定义),官方文案取自上云 API Dock1 properties
+
+ /**
+ * DRC 链路的状态 enum_int {"0":"未连接","1":"连接中","2":"已连接"}
+ * */
+ @JsonProperty("drc_state")
+ private Integer drcState;
+
+ /**
+ * 飞行器控制权状态更新 struct
+ * */
+ @JsonProperty("drone_authority_info")
+ private Dock1DroneAuthorityInfoReceiver droneAuthorityInfo;
+
+ /**
+ * 机场的航线解析库版本号 text
+ * */
+ @JsonProperty("wpmz_version")
+ private String wpmzVersion;
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1OsdSubDeviceReceiver.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1OsdSubDeviceReceiver.java
new file mode 100644
index 0000000..53f7b3d
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1OsdSubDeviceReceiver.java
@@ -0,0 +1,88 @@
+/*
+ * 【机场1】M30 / M30T 飞行器 OSD 接收器。
+ * 共享层 OsdSubDeviceReceiver 只覆盖机场2/3 机型字段,这里在其基础上补充 M30 专属字段;
+ * 不影响共享类与机场2/3 的解析逻辑。
+ */
+package com.ard.sample.dock1.manage.model.receiver;
+
+import com.ard.sample.manage.model.receiver.OsdSubDeviceReceiver;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.PropertyNamingStrategy;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.List;
+
+/**
+ * 机场1(M30 / M30T)飞行器 OSD 数据。
+ *
+ * @author ard
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class)
+public class Dock1OsdSubDeviceReceiver extends OsdSubDeviceReceiver {
+
+ /**
+ * 国家/地区代码。
+ */
+ @JsonProperty("country")
+ private String country;
+
+ /**
+ * 当前指挥官飞行模式。
+ * 注意:M30/M30T 不同固件可能上报枚举整数或布尔值,用 Object 兼容,避免整帧 OSD 解析失败。
+ */
+ @JsonProperty("current_commander_flight_mode")
+ private Object currentCommanderFlightMode;
+
+ /**
+ * 遥控器失联后是否退出航线。
+ * 注意:M30/M30T 不同固件可能上报枚举整数或布尔值,用 Object 兼容。
+ */
+ @JsonProperty("exit_wayline_when_rc_lost")
+ private Object exitWaylineWhenRcLost;
+
+ /**
+ * PSDK UI 资源。
+ */
+ @JsonProperty("psdk_ui_resource")
+ private List<Object> psdkUiResource;
+
+ /**
+ * PSDK 控件值。
+ */
+ @JsonProperty("psdk_widget_values")
+ private List<Object> psdkWidgetValues;
+
+ /**
+ * 遥控器失联动作。
+ * 注意:M30/M30T 不同固件可能上报枚举整数或布尔值,用 Object 兼容。
+ */
+ @JsonProperty("rc_lost_action")
+ private Object rcLostAction;
+
+ /**
+ * RID 状态。
+ * 注意:M30T 实测上报布尔值(false/true),部分固件上报整数,
+ * 原定义为 Integer 会导致整帧 OSD 反序列化失败(状态/遥测全部丢失),这里用 Object 兼容。
+ */
+ @JsonProperty("rid_state")
+ private Object ridState;
+
+ /**
+ * 返航高度。
+ */
+ @JsonProperty("rth_altitude")
+ private Integer rthAltitude;
+
+ /**
+ * 航线文件版本。
+ */
+ @JsonProperty("wpmz_version")
+ private String wpmzVersion;
+
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/ICommandService.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/ICommandService.java
new file mode 100644
index 0000000..c6f7f7a
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/ICommandService.java
@@ -0,0 +1,26 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.manage.service.ICommandService 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.manage.service;
+
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.manage.model.param.CommandParam;
+import org.springframework.messaging.MessageHeaders;
+
+/**
+ * @author sean
+ * @version 1.2
+ * @date 2022/7/29
+ */
+public interface ICommandService {
+
+ /**
+ * 负载控制-拍照
+ * @param sn
+ * @return
+ */
+ ResponseResult cameraPhotoTake(String sn, CommandParam param);
+
+
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/IControlService.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/IControlService.java
new file mode 100644
index 0000000..4bd6a43
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/IControlService.java
@@ -0,0 +1,35 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.manage.service.IControlService 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.manage.service;
+
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.component.mqtt.model.CommonTopicReceiver;
+import com.ard.sample.manage.model.param.RemoteDebugParam;
+import org.springframework.messaging.MessageHeaders;
+
+/**
+ * @author sean
+ * @version 1.2
+ * @date 2022/7/29
+ */
+public interface IControlService {
+
+ /**
+ * Remotely debug the dock via commands.
+ * @param sn
+ * @param serviceIdentifier
+ * @param param
+ * @return
+ */
+ ResponseResult controlDock(String sn, String serviceIdentifier, RemoteDebugParam param);
+
+ /**
+ * Handles multi-state command progress information.
+ * @param receiver
+ * @param headers
+ */
+ void handleControlProgress(CommonTopicReceiver receiver, MessageHeaders headers);
+
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/ILiveStreamService.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/ILiveStreamService.java
new file mode 100644
index 0000000..5e887b8
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/ILiveStreamService.java
@@ -0,0 +1,73 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.manage.service.ILiveStreamService 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.manage.service;
+
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.manage.model.dto.CapacityDeviceDTO;
+import com.ard.sample.manage.model.dto.LiveTypeDTO;
+import com.ard.sample.manage.model.receiver.LiveCapacityReceiver;
+
+import java.util.List;
+
+/**
+ * @author sean.zhou
+ * @date 2021/11/19
+ * @version 0.1
+ */
+public interface ILiveStreamService {
+
+ /**
+ * Get all the drone data that can be broadcast live in this workspace.
+ * @return
+ */
+ List<CapacityDeviceDTO> getLiveCapacity();
+
+ /**
+ * Save live capability data.
+ * @param liveCapacityReceiver
+ * @param timestamp
+ */
+ void saveLiveCapacity(LiveCapacityReceiver liveCapacityReceiver, Long timestamp);
+
+ /**
+ * Initiate a live streaming by publishing mqtt message.
+ * @param liveParam Parameters needed for on-demand.
+ * @return
+ */
+ ResponseResult liveStart(LiveTypeDTO liveParam);
+
+ /**
+ * Stop the live streaming by publishing mqtt message.
+ * @param videoId
+ * @return
+ */
+ ResponseResult liveStop(String videoId);
+
+ /**
+ * Readjust the clarity of the live streaming by publishing mqtt messages.
+ * @param liveParam
+ * @return
+ */
+ ResponseResult liveSetQuality(LiveTypeDTO liveParam);
+
+ /**
+ * Switches the lens of the device during the live streaming.
+ * @param liveParam
+ * @return
+ */
+ ResponseResult liveLensChange(LiveTypeDTO liveParam);
+
+ /**
+ * 获取直播能力中摄像头模式(广角、变焦、红外)
+ * */
+ ResponseResult<List<String>> getLiveLens(String sn);
+
+ /**
+ * 直播相机切换
+ * @param liveParam
+ * @return
+ */
+ ResponseResult liveCameraChange(LiveTypeDTO liveParam);
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/IWaylineTrajectoryService.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/IWaylineTrajectoryService.java
new file mode 100644
index 0000000..770c316
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/IWaylineTrajectoryService.java
@@ -0,0 +1,28 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.manage.service.IWaylineTrajectoryService 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.manage.service;
+
+import com.ard.sample.manage.model.dto.WaylineTrajectoryDTO;
+import com.ard.sample.manage.model.entity.WaylineTrajectoryEntity;
+
+import java.util.List;
+
+public interface IWaylineTrajectoryService {
+
+ /**
+ * 增加list数据,type为:0时,表示轨迹点列表;type为:1时,表示任务状态数据。
+ * @param sn 设备序列号
+ * @param type 类型
+ * @param dataJson json数据
+ * */
+ void setList(String sn, String type, String dataJson);
+
+ /**
+ * 返回航线轨迹完整的数据,拼接的轨迹点列表和任务状态数据。
+ * @param jobId 任务ID
+ * @return 航线轨迹完整的数据
+ * */
+ WaylineTrajectoryDTO getWayLineTrajectorybyJobID(String jobId);
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1CommandServiceImpl.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1CommandServiceImpl.java
new file mode 100644
index 0000000..d75d1a3
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1CommandServiceImpl.java
@@ -0,0 +1,126 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.manage.service.impl.CommandServiceImpl 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.manage.service.impl;
+
+import com.ard.sample.common.error.CommonErrorEnum;
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.component.mqtt.model.*;
+import com.ard.sample.component.mqtt.service.IMessageSenderService;
+import com.ard.common.core.redis.RedisConst;
+import com.ard.common.core.redis.RedisOpsUtils;
+import com.ard.sample.manage.model.dto.DeviceDTO;
+import com.ard.sample.manage.model.param.CommandParam;
+import com.ard.sample.dock1.manage.service.ICommandService;
+import com.ard.sample.manage.service.IDeviceService;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.integration.mqtt.support.MqttHeaders;
+import org.springframework.messaging.MessageHeaders;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.UUID;
+import com.ard.sample.manage.service.impl.CommandServiceImpl;
+
+/**
+ * Description:
+ * Author: lsy
+ * Date: 2025年09月24日9:00
+ **/
+@Slf4j
+@Service
+public class Dock1CommandServiceImpl implements ICommandService {
+
+ @Autowired
+ private ObjectMapper mapper;
+ @Resource
+ private IDeviceService deviceService;
+ @Resource
+ private IMessageSenderService messageSenderService;
+ /**
+ * 负载控制-拍照
+ * @param sn
+ * @return
+ */
+ @Override
+ public ResponseResult cameraPhotoTake(String sn, CommandParam param) {
+
+ boolean isExist = deviceService.checkDeviceOnline(sn);
+ if (!isExist) {
+ return ResponseResult.error("The dock is offline.");
+ }
+ String topic = TopicConst.THING_MODEL_PRE + TopicConst.PRODUCT + sn + TopicConst.SERVICES_SUF;
+ String bid = UUID.randomUUID().toString();
+ ServiceReply serviceReplyOpt = messageSenderService.publishWithReply(
+ topic, CommonTopicResponse.builder()
+ .tid(UUID.randomUUID().toString())
+ .bid(bid)
+ .method("camera_photo_take")
+ .timestamp(System.currentTimeMillis())
+ .data(param)
+ .build());
+
+ ServiceReply<EventsOutputReceiver> serviceReply = mapper.convertValue(
+ serviceReplyOpt, new TypeReference<ServiceReply<EventsOutputReceiver>>() {});
+ if (ResponseResult.CODE_SUCCESS != serviceReply.getResult()) {
+ return ResponseResult.error(serviceReply.getResult(), CommonErrorEnum.getMsgByCode(serviceReply.getResult()));
+ }
+// if (controlMethodEnum.getProgress()) {
+// RedisOpsUtils.setWithExpire(serviceIdentifier + RedisConst.DELIMITER + bid, sn,
+// RedisConst.DEVICE_ALIVE_SECOND * RedisConst.DEVICE_ALIVE_SECOND);
+// }
+ return ResponseResult.success();
+ }
+
+ // 机场1:不复制原 ServiceActivator(inputChannel = ChannelName.INBOUND_EVENTS_CAMERA_PHOTO_TAKE_PROGRESS),消费职责由共享层原类承担
+ public void handleCameraPhotoTakeProgress(CommonTopicReceiver receiver, MessageHeaders headers) {
+ String key = receiver.getMethod() + RedisConst.DELIMITER + receiver.getBid();
+ if (RedisOpsUtils.getExpire(key) <= 0) {
+ return;
+ }
+ String sn = RedisOpsUtils.get(key).toString();
+
+ EventsReceiver<EventsOutputReceiver> eventsReceiver = mapper.convertValue(receiver.getData(),
+ new TypeReference<EventsReceiver<EventsOutputReceiver>>(){});
+ eventsReceiver.setBid(receiver.getBid());
+ eventsReceiver.setSn(sn);
+
+ log.info("SN: {}, {} ===> CameraPhotoTak progress: {}",
+ sn, receiver.getMethod(), eventsReceiver.getOutput().getProgress().toString());
+
+ if (eventsReceiver.getResult() != ResponseResult.CODE_SUCCESS) {
+ log.error("SN: {}, {} ===> Error code: {}", sn, receiver.getMethod(), eventsReceiver.getResult());
+ }
+
+ if (eventsReceiver.getOutput().getProgress().getPercent() == 100 ||
+ EventsResultStatusEnum.find(eventsReceiver.getOutput().getStatus()).getEnd()) {
+ RedisOpsUtils.del(key);
+ }
+
+ DeviceDTO device = (DeviceDTO) RedisOpsUtils.get(RedisConst.DEVICE_ONLINE_PREFIX + sn);
+// webSocketMessageService.sendBatch(
+// WebSocketManager.getValueWithWorkspaceAndUserType(
+// device.getWorkspaceId(), UserTypeEnum.WEB.getVal()),
+// CustomWebSocketMessage.builder()
+// .data(eventsReceiver)
+// .timestamp(System.currentTimeMillis())
+// .bizCode(receiver.getMethod())
+// .build());
+
+ if (receiver.getNeedReply() != null && receiver.getNeedReply() == 1) {
+ String topic = headers.get(MqttHeaders.RECEIVED_TOPIC) + TopicConst._REPLY_SUF;
+ messageSenderService.publish(topic,
+ CommonTopicResponse.builder()
+ .tid(receiver.getTid())
+ .bid(receiver.getBid())
+ .method(receiver.getMethod())
+ .timestamp(System.currentTimeMillis())
+ .data(RequestsReply.success())
+ .build());
+ }
+ }
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1ControlServiceImpl.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1ControlServiceImpl.java
new file mode 100644
index 0000000..fde35ab
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1ControlServiceImpl.java
@@ -0,0 +1,246 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.manage.service.impl.ControlServiceImpl 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.manage.service.impl;
+
+import com.ard.sample.common.error.CommonErrorEnum;
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.component.mqtt.model.*;
+import com.ard.sample.component.mqtt.service.IMessageSenderService;
+import com.ard.common.core.redis.RedisConst;
+import com.ard.common.core.redis.RedisOpsUtils;
+import com.ard.sample.component.websocket.enums.MessageType;
+import com.ard.sample.component.websocket.handler.DockWebSocketHandler;
+import com.ard.sample.component.websocket.handler.LiveStreamHandler;
+import com.ard.sample.component.websocket.service.ISendMessageService;
+import com.ard.sample.manage.model.dto.BasicDeviceProperty;
+import com.ard.sample.manage.model.dto.DeviceDTO;
+import com.ard.sample.manage.model.enums.DeviceDomainEnum;
+import com.ard.sample.manage.model.enums.RemoteControlMethodEnum;
+import com.ard.sample.manage.model.param.RemoteDebugParam;
+import com.ard.sample.dock1.manage.service.IControlService;
+import com.ard.sample.manage.service.IDeviceService;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.integration.mqtt.support.MqttHeaders;
+import org.springframework.messaging.MessageHeaders;
+import org.springframework.stereotype.Service;
+import java.util.Objects;
+import java.util.UUID;
+import com.ard.sample.manage.service.impl.ControlServiceImpl;
+
+/**
+ * @author sean
+ * @version 1.2
+ * @date 2022/7/29
+ */
+@Service
+@Slf4j
+public class Dock1ControlServiceImpl implements IControlService {
+
+ /**
+ * 机场1:Dock1 设备类型。
+ */
+ private static final int DOCK1_DEVICE_TYPE = 1;
+
+ @Autowired
+ private IMessageSenderService messageSenderService;
+
+ @Autowired
+ private DockWebSocketHandler _dockWebSocketHandler;
+
+ @Autowired
+ private ISendMessageService webSocketMessageService;
+
+ @Autowired
+ private IDeviceService _deviceService;
+
+ @Autowired
+ private LiveStreamHandler _liveStreamHandler;
+
+// @Autowired
+// private IWebSocketManageService webSocketManageService;
+
+ @Autowired
+ private IDeviceService deviceService;
+
+ @Autowired
+ private ObjectMapper mapper;
+
+ @Override
+ public ResponseResult controlDock(String sn, String serviceIdentifier, RemoteDebugParam param) {
+ RemoteControlMethodEnum controlMethodEnum = RemoteControlMethodEnum.find(serviceIdentifier);
+ if (RemoteControlMethodEnum.UNKNOWN == controlMethodEnum) {
+ return ResponseResult.error("The " + serviceIdentifier + " method does not exist.");
+ }
+
+
+ Object data = "";
+ // Add parameter validation.
+ if (Objects.nonNull(controlMethodEnum.getClazz())) {
+ if (Objects.isNull(param)) {
+ return ResponseResult.error(CommonErrorEnum.ILLEGAL_ARGUMENT);
+ }
+ BasicDeviceProperty basicDeviceProperty = null;
+ if(serviceIdentifier.equals("sdr_workmode_switch")){
+ basicDeviceProperty= mapper.convertValue(param.getLink_workmode(), controlMethodEnum.getClazz());
+ }
+ else
+ {
+ basicDeviceProperty = mapper.convertValue(param.getAction(), controlMethodEnum.getClazz());
+ }
+ if (!basicDeviceProperty.valid()) {
+ return ResponseResult.error(CommonErrorEnum.ILLEGAL_ARGUMENT);
+ }
+ data = basicDeviceProperty;
+ }
+
+ boolean isExist = deviceService.checkDeviceOnline(sn);
+ if (!isExist) {
+ return ResponseResult.error(CommonErrorEnum.DEVICE_OFFLINE.getErrorCode(), CommonErrorEnum.DEVICE_OFFLINE.getErrorMsg());
+ }
+ DeviceDTO device = (DeviceDTO) RedisOpsUtils.get(RedisConst.DEVICE_ONLINE_PREFIX + sn);
+ // 机场1:/dock1 控制接口只允许操作 Dock1,避免机场2/3 的 SN 误走 dock1 链路。
+ if (device == null || !Objects.equals(DOCK1_DEVICE_TYPE, device.getType())
+ || !Objects.equals(DeviceDomainEnum.DOCK.getVal(), device.getDomain())) {
+ return ResponseResult.error(314013004, "该设备不属于机场1,请使用对应的机场接口操作!");
+ }
+ // 重启机场时清redis视频直播地址,机场重启不掉线。但是流停了。
+ if(RemoteControlMethodEnum.DEVICE_REBOOT == controlMethodEnum) {
+ // 重启机场时,读取舱盖状态,开舱盖不让重启,关舱盖重启
+ Integer cover_state = (Integer)RedisOpsUtils.get(RedisConst.COVER_STATE_OSD_PREFIX + device.getDeviceSn());
+ if(cover_state != null && cover_state != 0) {
+ return ResponseResult.error(111111,"请关闭舱盖再执行重启操作!");
+ }
+ RedisOpsUtils.del(RedisConst.WEBSOCKET_VIDEO_ADDRESS_PREFIX + sn);
+ RedisOpsUtils.del(RedisConst.COVER_STATE_PREFIX + sn);
+ RedisOpsUtils.del(RedisConst.WEBSOCKET_VIDEO_ADDRESS_PREFIX + device.getChildDeviceSn());
+ }
+ if(RemoteControlMethodEnum.DRONE_CLOSE == controlMethodEnum) {
+ // 飞行器关机时drone_close,关闭飞行器视频流
+ _liveStreamHandler.StopLiveStreaming(device.getChildDeviceSn());
+ RedisOpsUtils.del(RedisConst.WEBSOCKET_VIDEO_ADDRESS_PREFIX + device.getChildDeviceSn());
+ log.info("SN: {}-飞行器关机,停止直播并删除redis直播信息!", device.getChildDeviceSn());
+ }
+ String topic = TopicConst.THING_MODEL_PRE + TopicConst.PRODUCT + sn + TopicConst.SERVICES_SUF;
+ String bid = UUID.randomUUID().toString();
+ ServiceReply serviceReplyOpt = messageSenderService.publishWithReply(
+ topic, CommonTopicResponse.builder()
+ .tid(UUID.randomUUID().toString())
+ .bid(bid)
+ .method(serviceIdentifier)
+ .timestamp(System.currentTimeMillis())
+ .data(data)
+ .build());
+
+ ServiceReply<EventsOutputReceiver> serviceReply = mapper.convertValue(
+ serviceReplyOpt, new TypeReference<ServiceReply<EventsOutputReceiver>>() {});
+ if (ResponseResult.CODE_SUCCESS != serviceReply.getResult()) {
+ String errorMsg = CommonErrorEnum.getMsgByCode(serviceReply.getResult());
+ return ResponseResult.error(serviceReply.getResult(),errorMsg);
+// return ResponseResult.error(serviceReply.getResult(),
+// Objects.nonNull(serviceReply.getOutput()) ? serviceReply.getOutput().getStatus() : "errorCode: "
+// + serviceReply.getResult());
+ }
+ if (controlMethodEnum.getProgress()) {
+ RedisOpsUtils.setWithExpire(serviceIdentifier + RedisConst.DELIMITER + bid, sn,
+ RedisConst.DEVICE_ALIVE_SECOND * RedisConst.DEVICE_ALIVE_SECOND);
+ }
+ // 执行操作成功后,更新摄像头内外镜头状态
+ // 机场1:Dock1 只有舱外单相机,camera_position 恒为 1,不存在舱内/舱外切换;
+ // 共享层在开盖时写 0 是 Dock2 双相机的语义,这里按 Dock1 修正。
+ if(RemoteControlMethodEnum.COVER_OPEN == controlMethodEnum) {
+ _deviceService.setRedisCameraPosition(sn,1);
+ }
+ if(RemoteControlMethodEnum.COVER_CLOSE == controlMethodEnum) {
+ _deviceService.setRedisCameraPosition(sn,1);
+ }
+
+ return ResponseResult.success();
+ }
+
+ @Override
+ // 机场1:不复制原 ServiceActivator(inputChannel = ChannelName.INBOUND_EVENTS_CONTROL_PROGRESS),消费职责由共享层原类承担
+ public void handleControlProgress(CommonTopicReceiver receiver, MessageHeaders headers) {
+ String key = receiver.getMethod() + RedisConst.DELIMITER + receiver.getBid();
+ if (RedisOpsUtils.getExpire(key) <= 0) {
+ return;
+ }
+ // 更新设备列表状态--关闭舱盖中...
+ String method = receiver.getMethod();
+ String deviceSn = receiver.getGateway();
+ if(method.equals("cover_close")){
+ _deviceService.updateWorkType(deviceSn, 2L);
+ RedisOpsUtils.set(RedisConst.OSD_STATUS_PREFIX + deviceSn, 2);
+ }
+ String sn = RedisOpsUtils.get(key).toString();
+ receiver.setType(MessageType.PROGRESS.getType());
+ EventsReceiver<EventsOutputReceiver> eventsReceiver = mapper.convertValue(receiver.getData(),
+ new TypeReference<EventsReceiver<EventsOutputReceiver>>(){});
+ eventsReceiver.setBid(receiver.getBid());
+ eventsReceiver.setGateway(sn);
+ eventsReceiver.setType(MessageType.PROGRESS.getType());
+ eventsReceiver.setMethod(receiver.getMethod());
+
+ log.info("SN: {}, {} ===> Control progress: {}",
+ sn, receiver.getMethod(), eventsReceiver.getOutput().getProgress().toString());
+
+ if (eventsReceiver.getResult() != ResponseResult.CODE_SUCCESS) {
+ log.error("SN: {}, {} ===> Error code: {}", sn, receiver.getMethod(), eventsReceiver.getResult());
+ }
+
+ if (eventsReceiver.getOutput().getProgress().getPercent() == 100 ||
+ EventsResultStatusEnum.find(eventsReceiver.getOutput().getStatus()).getEnd()) {
+ RedisOpsUtils.del(key);
+ }
+
+ DeviceDTO device = (DeviceDTO) RedisOpsUtils.get(RedisConst.DEVICE_ONLINE_PREFIX + sn);
+// webSocketMessageService.sendBatch(
+// WebSocketManager.getValueWithWorkspaceAndUserType(
+// device.getWorkspaceId(), UserTypeEnum.WEB.getVal()),
+// CustomWebSocketMessage.builder()
+// .data(eventsReceiver)
+// .timestamp(System.currentTimeMillis())
+// .bizCode(receiver.getMethod())
+// .build());
+ // 更新设备列表状态--飞行器开机中...
+ if(method.equals("drone_open")){
+ _deviceService.updateWorkType(device.getChildDeviceSn(), 7L);
+ RedisOpsUtils.set(RedisConst.OSD_STATUS_PREFIX + device.getChildDeviceSn(), 7);
+ }
+ // 机场重启状态--ok后删除redis状态
+ if(method.equals("device_reboot")){
+ if(eventsReceiver.getOutput().getProgress().getPercent() == 100){
+ RedisOpsUtils.del(RedisConst.WEBSOCKET_VIDEO_ADDRESS_PREFIX + sn);
+ RedisOpsUtils.del(RedisConst.COVER_STATE_PREFIX + sn);
+ RedisOpsUtils.del(RedisConst.WEBSOCKET_VIDEO_ADDRESS_PREFIX + device.getChildDeviceSn());
+ }
+ }
+
+
+ String dockProgress ="";
+ try {
+ dockProgress = mapper.writeValueAsString(eventsReceiver);
+ }
+ catch (Exception e) {
+ e.printStackTrace();
+ }
+ // 设备控制进度消息websocket发给前端
+ _dockWebSocketHandler.sendMessageToSubscribers(sn, MessageType.PROGRESS.getType(), dockProgress, "");
+
+ if (receiver.getNeedReply() != null && receiver.getNeedReply() == 1) {
+ String topic = headers.get(MqttHeaders.RECEIVED_TOPIC) + TopicConst._REPLY_SUF;
+ messageSenderService.publish(topic,
+ CommonTopicResponse.builder()
+ .tid(receiver.getTid())
+ .bid(receiver.getBid())
+ .method(receiver.getMethod())
+ .timestamp(System.currentTimeMillis())
+ .data(RequestsReply.success())
+ .build());
+ }
+ }
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1DeviceOSDServiceImpl.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1DeviceOSDServiceImpl.java
new file mode 100644
index 0000000..6b8d784
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1DeviceOSDServiceImpl.java
@@ -0,0 +1,142 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.manage.service.impl.DeviceOSDServiceImpl 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.manage.service.impl;
+
+import com.ard.common.core.redis.RedisConst;
+import com.ard.common.core.redis.RedisOpsUtils;
+import com.ard.sample.component.mqtt.model.CommonTopicReceiver;
+
+import com.ard.sample.component.websocket.config.ConcurrentWebSocketSession;
+import com.ard.sample.component.websocket.enums.MessageType;
+import com.ard.sample.component.websocket.handler.DockWebSocketHandler;
+import com.ard.sample.component.websocket.model.BizCodeEnum;
+import com.ard.sample.component.websocket.model.CustomWebSocketMessage;
+import com.ard.sample.manage.model.dto.DeviceDTO;
+import com.ard.sample.manage.model.dto.TelemetryDTO;
+import com.ard.sample.manage.model.dto.TelemetryDeviceDTO;
+import com.ard.sample.manage.model.enums.DeviceDomainEnum;
+import com.ard.sample.manage.model.receiver.OsdSubDeviceReceiver;
+// 机场1:使用带 M30 专属字段的子类解析飞行器 OSD(仍兼容共享层类型)。
+import com.ard.sample.dock1.manage.model.receiver.Dock1OsdSubDeviceReceiver;
+import com.ard.sample.manage.service.IDeviceService;
+// 机场1:轨迹缓存必须与共享任务进度(FlightTaskServiceImpl 的 type="1" 写入)使用同一个服务实例,
+// 否则 type="0" 的轨迹点写进 dock1 独立缓存、而 type="1" 的任务状态写在共享缓存,两处永远无法配对。
+// 这里复用共享层 IWaylineTrajectoryService,不修改共享层实现。
+import com.ard.sample.manage.service.IWaylineTrajectoryService;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+
+import java.util.Collection;
+import com.ard.sample.manage.service.impl.AbstractTSAService;
+import com.ard.sample.manage.service.impl.DeviceOSDServiceImpl;
+
+/**
+ * @author sean
+ * @version 0.3
+ * @date 2022/2/21
+ */
+@Service
+@Slf4j
+public class Dock1DeviceOSDServiceImpl extends AbstractTSAService {
+
+ @Autowired
+ private IDeviceService _deviceService;
+
+ @Autowired
+ private IWaylineTrajectoryService _waylineTrajectoryService;
+
+ @Autowired
+ private DockWebSocketHandler _dockWebSocketHandler;
+
+ protected Dock1DeviceOSDServiceImpl(@Autowired @Qualifier("dock1DockOSDServiceImpl") AbstractTSAService tsaService) {
+ super(tsaService);
+ }
+
+ @Override
+ public void pushTelemetryData(Collection<ConcurrentWebSocketSession> sessions,
+ CustomWebSocketMessage<TelemetryDTO> message, Object osdData) {
+ if (osdData instanceof OsdSubDeviceReceiver) {
+ OsdSubDeviceReceiver data = (OsdSubDeviceReceiver) osdData;
+ TelemetryDTO telemetry = message.getData();
+ telemetry.setHost(TelemetryDeviceDTO.builder()
+ .latitude(data.getLatitude())
+ .longitude(data.getLongitude())
+ .altitude(data.getElevation())
+ .attitudeHead(data.getAttitudeHead())
+ .elevation(data.getElevation())
+ .horizontalSpeed(data.getHorizontalSpeed())
+ .verticalSpeed(data.getVerticalSpeed())
+ .build());
+
+ this.sendMessageService.sendBatch(sessions, message);
+ }
+ }
+ @Override
+ public void handleOSD(CommonTopicReceiver receiver, DeviceDTO device,
+ Collection<ConcurrentWebSocketSession> webSessions,
+ CustomWebSocketMessage<TelemetryDTO> wsMessage) {
+ if (DeviceDomainEnum.SUB_DEVICE.getVal() == device.getDomain()) {
+ wsMessage.setBizCode(BizCodeEnum.DEVICE_OSD.getCode());
+
+ // 机场1:M30/M30T 的 OSD 比共享类多 9 个字段,用 dock1 子类承接。
+ // 机场1:Dock1 的飞行器 OSD 中 payloads 本身就是数组,convertValue 已经完成解析;
+ // 共享层按 device.payloadsList(扁平 key)二次覆盖的逻辑不适用于 dock1,
+ // 且该列表当前恒为 null,会在每条 OSD 上触发 NPE 并打印误导性告警,故 dock1 链直接跳过。
+ // 容错:单个字段类型异常(如固件把 rid_state 上报成布尔)不应丢弃整帧 OSD,
+ // 解析失败时降级为共享接收器,保证状态、遥测、轨迹链路继续工作。
+ OsdSubDeviceReceiver data;
+ try {
+ data = mapper.convertValue(receiver.getData(), Dock1OsdSubDeviceReceiver.class);
+ } catch (IllegalArgumentException e) {
+ log.warn("机场1 飞行器 OSD 解析失败,降级为共享接收器:{}", e.getMessage());
+ data = mapper.convertValue(receiver.getData(), OsdSubDeviceReceiver.class);
+ }
+ //data.setChildDeviceSn(device.getDeviceSn());
+ String dockOsd = "";
+ receiver.setType(MessageType.STATUS.getType()+"-aircraft");
+// receiver.setData(data);
+ try {
+ dockOsd=mapper.writeValueAsString(receiver);
+ JsonNode jsonNode = mapper.readTree(dockOsd);
+ //在JSON的data下插入childDeviceSn,飞行器的sn
+ if(jsonNode.get("data") instanceof ObjectNode){
+ ObjectNode objectNode = (ObjectNode) jsonNode.get("data");
+ objectNode.put("childDeviceSn", device.getDeviceSn());
+ }
+ if(jsonNode.has("data")){
+
+ if(jsonNode.get("data").has("mode_code")) {
+ Long code = jsonNode.get("data").get("mode_code").asLong();
+ if ("0,1,2,3,4,5,8,9,10,11,13,14".contains(code.toString())) {
+ _deviceService.updateWorkType(device.getDeviceSn(), code);
+ RedisOpsUtils.set(RedisConst.OSD_STATUS_PREFIX + device.getDeviceSn(), code);
+ }
+ }
+ }
+ dockOsd=mapper.writeValueAsString(jsonNode);
+ } catch (NullPointerException | JsonProcessingException e) {
+ log.warn("Please remount the payload, or restart the drone. Otherwise the data of the payload will not be received.");
+ }
+
+ RedisOpsUtils.setWithExpire(RedisConst.OSD_PREFIX + device.getDeviceSn(), data, RedisConst.DEVICE_ALIVE_SECOND);
+ wsMessage.getData().setHost(data);
+
+ // 发送给websocket客户端
+ _dockWebSocketHandler.sendMessageToSubscribers(device.getDeviceSn(), MessageType.STATUS.getType(), dockOsd, "");
+
+ // 将飞行器的OSD数据dockOsd入库;device.getDeviceSn()为飞行器sn,这里要机场的sn,试试receiver.getGateway();
+ _waylineTrajectoryService.setList(receiver.getGateway(),"0", dockOsd);
+
+ sendMessageService.sendBatch(webSessions, wsMessage);
+ this.pushTelemetryData(data, device.getDeviceSn());
+ }
+ tsaService.handleOSD(receiver, device, webSessions, wsMessage);
+ }
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1DockOSDServiceImpl.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1DockOSDServiceImpl.java
new file mode 100644
index 0000000..4180808
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1DockOSDServiceImpl.java
@@ -0,0 +1,125 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.manage.service.impl.DockOSDServiceImpl 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.manage.service.impl;
+
+import com.ard.common.core.redis.RedisConst;
+import com.ard.common.core.redis.RedisOpsUtils;
+import com.ard.sample.component.mqtt.model.CommonTopicReceiver;
+import com.ard.sample.component.websocket.config.ConcurrentWebSocketSession;
+import com.ard.sample.component.websocket.enums.MessageType;
+import com.ard.sample.component.websocket.handler.DockWebSocketHandler;
+import com.ard.sample.component.websocket.model.BizCodeEnum;
+import com.ard.sample.component.websocket.model.CustomWebSocketMessage;
+import com.ard.sample.manage.model.dto.DeviceDTO;
+import com.ard.sample.manage.model.dto.TelemetryDTO;
+import com.ard.sample.manage.model.enums.DeviceDomainEnum;
+import com.ard.sample.dock1.manage.model.receiver.Dock1OsdDockReceiver;
+import com.ard.sample.manage.service.IDeviceService;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Collection;
+import com.ard.sample.manage.service.impl.AbstractTSAService;
+import com.ard.sample.manage.service.impl.DockOSDServiceImpl;
+
+/**
+ * @author sean
+ * @version 1.0
+ * @date 2022/5/11
+ */
+@Service
+public class Dock1DockOSDServiceImpl extends AbstractTSAService {
+
+ @Autowired
+ private DockWebSocketHandler _dockWebSocketHandler;
+
+ @Autowired
+ private IDeviceService _deviceService;
+
+ public Dock1DockOSDServiceImpl() {
+ super(null);
+ }
+
+
+ @Override
+ public void pushTelemetryData(Collection<ConcurrentWebSocketSession> sessions,
+ CustomWebSocketMessage<TelemetryDTO> message, Object Object) {
+
+ }
+
+ @Override
+ public void handleOSD(CommonTopicReceiver receiver, DeviceDTO device,
+ Collection<ConcurrentWebSocketSession> webSessions,
+ CustomWebSocketMessage<TelemetryDTO> wsMessage) {
+
+ if (DeviceDomainEnum.DOCK.getVal() == device.getDomain()) {
+ wsMessage.setBizCode(BizCodeEnum.DOCK_OSD.getCode());
+ Dock1OsdDockReceiver data = mapper.convertValue(receiver.getData(), Dock1OsdDockReceiver.class);
+// receiver.setType(MessageType.STATUS.getType());
+ String dockOsd = "";
+ String dockOsdData = "";
+ try {
+ dockOsdData = mapper.writeValueAsString(receiver.getData());
+ if (dockOsdData.contains("flighttask_step_code")) {
+ JsonNode jsonNode = mapper.readTree(dockOsdData);
+ if (jsonNode.has("flighttask_step_code")) {
+ String deviceSn = device.getDeviceSn();
+ Long code = jsonNode.get("flighttask_step_code").asLong();
+ if (code != null) {
+ //只要状态 "0":"作业准备中","1":"飞行作业中","5":"任务空闲"
+ if (code == 0 || code == 1 || code == 5) {
+ _deviceService.updateWorkType(deviceSn, code);
+ RedisOpsUtils.set(RedisConst.OSD_STATUS_PREFIX + deviceSn, code);
+ }
+ }
+ }
+ receiver.setType(MessageType.STATUS.getType() + "-flighttask");
+ } else if (dockOsdData.contains("network_state")) {
+ JsonNode jsonNode = mapper.readTree(dockOsdData);
+ // 机场1:Dock1 刚上线时 network_state 可能不带 sub_device(飞行器尚未入舱),
+ // 原写法直接 jsonNode.get("sub_device").has(...) 会抛 NPE,导致整帧 OSD 丢失。
+ JsonNode subDeviceNode = jsonNode.get("sub_device");
+ JsonNode droneInDockNode = jsonNode.get("drone_in_dock");
+ if (subDeviceNode != null && !subDeviceNode.isNull()
+ && droneInDockNode != null && !droneInDockNode.isNull()
+ && subDeviceNode.has("device_online_status")
+ && subDeviceNode.has("device_sn")) {
+ String childDeviceSn = subDeviceNode.get("device_sn").asText();
+ Long inDock = droneInDockNode.asLong();
+ Long deviceOnlineStatus = subDeviceNode.get("device_online_status").asLong();
+ if (inDock.equals(1L) && deviceOnlineStatus.equals(0L)) {// 飞行器在舱内且关机状态为6
+ _deviceService.updateWorkType(childDeviceSn, 6L);
+ RedisOpsUtils.set(RedisConst.OSD_STATUS_PREFIX + device.getChildDeviceSn(), 6);
+ }
+ }
+ if (jsonNode.has("cover_state")) {
+ // 舱盖状态存redis
+ RedisOpsUtils.set(RedisConst.COVER_STATE_OSD_PREFIX + device.getDeviceSn(), jsonNode.get("cover_state").asInt());
+// //实时监控机场内外舱盖状态
+// Integer coverState = jsonNode.get("cover_state").asInt();
+// if(coverState != null && (coverState == 1 || coverState == 0)) {
+// // 执行0和1的互转逻辑,结果赋值给变量(保持Integer类型)
+// Integer newCoverState = coverState == 0 ? 1 : 0;
+// _deviceService.setRedisCameraPosition(device.getDeviceSn(), newCoverState);
+// }
+
+ }
+ receiver.setType(MessageType.STATUS.getType() + "-network");
+ } else if (dockOsdData.contains("job_number")) {
+ receiver.setType(MessageType.STATUS.getType() + "-job");
+ }
+ dockOsd = mapper.writeValueAsString(receiver);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ wsMessage.getData().setHost(data);
+ sendMessageService.sendBatch(webSessions, wsMessage);
+ // 发送给websocket客户端
+ _dockWebSocketHandler.sendMessageToSubscribers(device.getDeviceSn(), MessageType.STATUS.getType(), dockOsd, "");
+ }
+ }
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1FlightSetupExceptionHandler.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1FlightSetupExceptionHandler.java
new file mode 100644
index 0000000..c997f22
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1FlightSetupExceptionHandler.java
@@ -0,0 +1,68 @@
+/*
+ * 【机场1】航线准备异常通知消费者(flight_setup_exception_notify)。
+ * Dock1/M30 在 flighttask_prepare 失败时会发送该事件;共享层原枚举未注册此方法,
+ * 消息会被路由到 default 通道后丢弃。这里新增独立通道与 dock1 消费者,
+ * 仅处理 Dock1 设备,机场2/3 收到同名事件时保持原行为(只记录日志)。
+ */
+package com.ard.sample.dock1.manage.service.impl;
+
+import com.ard.sample.component.mqtt.model.ChannelName;
+import com.ard.sample.component.mqtt.model.CommonTopicReceiver;
+import com.ard.sample.component.websocket.enums.MessageType;
+import com.ard.sample.component.websocket.handler.DockWebSocketHandler;
+import com.ard.sample.manage.model.dto.DeviceDTO;
+import com.ard.sample.manage.service.IDeviceService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.integration.annotation.ServiceActivator;
+import org.springframework.messaging.MessageHeaders;
+import org.springframework.stereotype.Service;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * 机场1航线准备异常通知处理器。
+ *
+ * @author ard
+ */
+@Slf4j
+@Service
+public class Dock1FlightSetupExceptionHandler {
+
+ /**
+ * 机场1:Dock1 设备类型。
+ */
+ private static final int DOCK1_DEVICE_TYPE = 1;
+
+ @Autowired
+ private IDeviceService deviceService;
+
+ @Autowired
+ private DockWebSocketHandler dockWebSocketHandler;
+
+ @Autowired
+ private ObjectMapper mapper;
+
+ @ServiceActivator(inputChannel = ChannelName.INBOUND_EVENTS_FLIGHT_SETUP_EXCEPTION_NOTIFY)
+ public void handleFlightSetupException(CommonTopicReceiver receiver, MessageHeaders headers) {
+ String dockSn = receiver.getGateway();
+ Optional<DeviceDTO> deviceOpt = deviceService.getDeviceBySn(dockSn);
+ if (deviceOpt.isEmpty() || !Objects.equals(DOCK1_DEVICE_TYPE, deviceOpt.get().getType())) {
+ // 机场2/3 的事件仍按原逻辑只记录日志,不改变其行为。
+ log.info("非机场1设备的航线准备异常通知,忽略。sn={}, method={}, data={}",
+ dockSn, receiver.getMethod(), receiver.getData());
+ return;
+ }
+
+ log.warn("机场1航线准备异常:sn={}, bid={}, data={}", dockSn, receiver.getBid(), receiver.getData());
+ try {
+ // 原样推送给前端,便于机场1页面展示准备失败原因。
+ dockWebSocketHandler.sendMessageToSubscribers(dockSn, MessageType.STATUS.getType(),
+ mapper.writeValueAsString(receiver), "");
+ } catch (Exception e) {
+ log.error("机场1航线准备异常通知推送失败:sn={}", dockSn, e);
+ }
+ }
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1GatewayOSDServiceImpl.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1GatewayOSDServiceImpl.java
new file mode 100644
index 0000000..3780846
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1GatewayOSDServiceImpl.java
@@ -0,0 +1,70 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.manage.service.impl.GatewayOSDServiceImpl 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.manage.service.impl;
+
+import com.ard.sample.component.mqtt.model.CommonTopicReceiver;
+import com.ard.sample.component.websocket.config.ConcurrentWebSocketSession;
+import com.ard.sample.component.websocket.model.BizCodeEnum;
+import com.ard.sample.component.websocket.model.CustomWebSocketMessage;
+import com.ard.sample.manage.model.dto.DeviceDTO;
+import com.ard.sample.manage.model.dto.TelemetryDTO;
+import com.ard.sample.manage.model.dto.TelemetryDeviceDTO;
+import com.ard.sample.manage.model.enums.DeviceDomainEnum;
+import com.ard.sample.manage.model.receiver.OsdGatewayReceiver;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+
+import java.util.Collection;
+import com.ard.sample.manage.service.impl.AbstractTSAService;
+import com.ard.sample.manage.service.impl.GatewayOSDServiceImpl;
+
+/**
+ * @author sean
+ * @version 0.3
+ * @date 2022/2/21
+ */
+@Service
+public class Dock1GatewayOSDServiceImpl extends AbstractTSAService {
+
+ public Dock1GatewayOSDServiceImpl(@Autowired @Qualifier("dock1DeviceOSDServiceImpl") AbstractTSAService tsaService) {
+ super(tsaService);
+ }
+
+ @Override
+ public void pushTelemetryData(Collection<ConcurrentWebSocketSession> sessions,
+ CustomWebSocketMessage<TelemetryDTO> message, Object osdData) {
+ if (osdData instanceof OsdGatewayReceiver) {
+ OsdGatewayReceiver data = (OsdGatewayReceiver) osdData;
+ TelemetryDTO telemetry = message.getData();
+ telemetry.setHost(TelemetryDeviceDTO.builder()
+ .latitude(data.getLatitude())
+ .longitude(data.getLongitude())
+ .build());
+ this.sendMessageService.sendBatch(sessions, message);
+ return;
+ }
+ tsaService.pushTelemetryData(sessions, message, osdData);
+ }
+
+ @Override
+ public void handleOSD(CommonTopicReceiver receiver, DeviceDTO device,
+ Collection<ConcurrentWebSocketSession> webSessions,
+ CustomWebSocketMessage<TelemetryDTO> wsMessage) {
+ if (DeviceDomainEnum.GATEWAY.getVal() == device.getDomain()) {
+
+ wsMessage.setBizCode(BizCodeEnum.GATEWAY_OSD.getCode());
+ OsdGatewayReceiver data = mapper.convertValue(receiver.getData(), OsdGatewayReceiver.class);
+ wsMessage.getData().setHost(data);
+
+ this.sendMessageService.sendBatch(webSessions, wsMessage);
+
+ this.pushTelemetryData(data, device.getDeviceSn());
+ return;
+ }
+
+ tsaService.handleOSD(receiver, device, webSessions, wsMessage);
+ }
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1LiveStreamServiceImpl.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1LiveStreamServiceImpl.java
new file mode 100644
index 0000000..4f4d709
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1LiveStreamServiceImpl.java
@@ -0,0 +1,544 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.manage.service.impl.LiveStreamServiceImpl 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.manage.service.impl;
+
+import com.ard.sample.common.error.CommonErrorEnum;
+import com.ard.sample.common.error.LiveErrorEnum;
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.component.mqtt.model.CommonTopicResponse;
+import com.ard.sample.component.mqtt.model.ServiceReply;
+import com.ard.sample.component.mqtt.service.IMessageSenderService;
+import com.ard.common.core.redis.RedisConst;
+import com.ard.common.core.redis.RedisOpsUtils;
+import com.ard.sample.manage.model.dto.*;
+import com.ard.sample.manage.model.enums.DeviceDomainEnum;
+import com.ard.sample.manage.model.enums.LiveStreamMethodEnum;
+import com.ard.sample.manage.model.enums.LiveUrlTypeEnum;
+import com.ard.sample.manage.model.enums.LiveVideoQualityEnum;
+import com.ard.sample.manage.model.param.DeviceQueryParam;
+import com.ard.sample.manage.model.receiver.CapacityDeviceReceiver;
+import com.ard.sample.manage.model.receiver.LiveCapacityReceiver;
+import com.ard.sample.manage.service.ICapacityCameraService;
+import com.ard.sample.manage.service.IDeviceService;
+import com.ard.sample.dock1.manage.service.ILiveStreamService;
+import com.ard.vtdu.config.MediamtxConfig;
+import com.ard.vtdu.domain.ArdVtdu;
+import com.ard.vtdu.service.IArdVtduService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import javax.annotation.Resource;
+import java.lang.reflect.Field;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+
+import static com.ard.sample.component.mqtt.model.TopicConst.*;
+import com.ard.sample.manage.service.impl.LiveStreamServiceImpl;
+
+/**
+ * @author sean.zhou
+ * @version 0.1
+ * @date 2021/11/22
+ */
+@Slf4j
+@Service
+//@Transactional
+public class Dock1LiveStreamServiceImpl implements ILiveStreamService {
+
+ /**
+ * 机场1:Dock1 设备类型。
+ */
+ private static final int DOCK1_DOCK_TYPE = 1;
+
+ /**
+ * 机场1:M30 / M30T 飞行器设备类型。
+ */
+ private static final int DOCK1_AIRCRAFT_TYPE = 67;
+
+ @Autowired
+ private ICapacityCameraService capacityCameraService;
+
+ @Autowired
+ private IDeviceService deviceService;
+
+ @Autowired
+ private IMessageSenderService messageSender;
+
+ @Resource
+ private IArdVtduService ardVtduService;
+ @Resource
+ private MediamtxConfig mediamtxConfig;
+
+ @Override
+ public List<CapacityDeviceDTO> getLiveCapacity() {
+
+ // Query all devices in this workspace.
+ List<DeviceDTO> devicesList = deviceService.getDevicesByParams(
+ DeviceQueryParam.builder()
+ .domains(List.of(DeviceDomainEnum.SUB_DEVICE.getVal(), DeviceDomainEnum.DOCK.getVal()))
+ .build());
+
+ // 机场1:只返回 Dock1(type=1)和 M30/M30T(type=67)的直播能力,
+ // 避免 /dock1/live/capacity 混入机场2/3 的设备。
+ return devicesList.stream()
+ .filter(device -> Objects.equals(DOCK1_DOCK_TYPE, device.getType())
+ || Objects.equals(DOCK1_AIRCRAFT_TYPE, device.getType()))
+ .filter(device -> RedisOpsUtils.checkExist(RedisConst.DEVICE_ONLINE_PREFIX + device.getDeviceSn()))
+ .map(device -> CapacityDeviceDTO.builder()
+ .name(Objects.requireNonNullElse(device.getNickname(), device.getDeviceName()))
+ .sn(device.getDeviceSn())
+ .camerasList(capacityCameraService.getCapacityCameraByDeviceSn(device.getDeviceSn()))
+ .build())
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public void saveLiveCapacity(LiveCapacityReceiver liveCapacityReceiver, Long timestamp) {
+ // Solve timing problems
+ if (liveCapacityReceiver == null) {
+ //2025-10-28 zwy,这里有个空指针异常问题,如果liveCapacityReceiver为空,则不应该执行下面的代码。
+ //机场设备在刚开机时,发送的state状态时,直播能力前三次发送的是空,可能是由于刚开机,等待各设备开机,第四次为正常数据。
+ return;
+ }
+ for (CapacityDeviceReceiver capacityDeviceReceiver : liveCapacityReceiver.getDeviceList()) {
+ long last = (long) Objects.requireNonNullElse(
+ RedisOpsUtils.get(RedisConst.LIVE_CAPACITY + capacityDeviceReceiver.getSn()), 0L);
+ if (last > timestamp) {
+ return;
+ }
+ capacityCameraService.saveCapacityCameraReceiverList(
+ capacityDeviceReceiver.getCameraList(),
+ capacityDeviceReceiver.getSn(), timestamp);
+ }
+ }
+
+ @Override
+ public ResponseResult liveStart(LiveTypeDTO liveParam) {
+ String rtmpPublishUrl =
+ "rtmp://" + mediamtxConfig.getPushHost() + ":" + mediamtxConfig.getRtmpPort() + "/" + liveParam.getVideoId();
+ liveParam.setUrl(rtmpPublishUrl);
+ // Check if this lens is available live.
+ ResponseResult responseResult = this.checkBeforeLive(liveParam.getVideoId());
+ if (ResponseResult.CODE_SUCCESS != responseResult.getCode()) {
+ return responseResult;
+ }
+
+ // 将机场推流代码放到入库代码后面。
+ DeviceDTO data = (DeviceDTO) responseResult.getData();
+ // target topic
+ String respTopic = THING_MODEL_PRE + PRODUCT +
+ data.getDeviceSn() + SERVICES_SUF;
+ ServiceReply receiveReply=new ServiceReply();
+// ServiceReply receiveReply = this.publishLiveStart(respTopic, liveParam);
+
+// if (ResponseResult.CODE_SUCCESS != receiveReply.getResult()) {
+// log.info("机场直播开启失败,错误码:{},错误描述:{}", receiveReply.getResult(), LiveErrorEnum.find(receiveReply.getResult()).getErrorMsg());
+// return ResponseResult.error(receiveReply.getResult(), LiveErrorEnum.find(receiveReply.getResult()).getErrorMsg());
+// }
+
+ LiveUrlTypeEnum urlType = LiveUrlTypeEnum.find(liveParam.getUrlType());
+ LiveDTO live = new LiveDTO();
+
+ switch (urlType) {
+ case RTMP:
+ String name = liveParam.getVideoId();
+ // 拼接地址
+ String rtspUrl =
+ "rtsp://" + mediamtxConfig.getPlayHost() + ":" + mediamtxConfig.getRtspPort() + "/" + name; //
+ // 拼接 webrtcUrl
+ String rtmpUrl =
+ "rtmp://" + mediamtxConfig.getPlayHost() + ":" + mediamtxConfig.getRtmpPort() + "/" + name; //
+ // 拼接 webrtcUrl
+ String protocol = mediamtxConfig.isHttps() ? "https://" : "http://";
+ String webrtcUrl =
+ protocol + mediamtxConfig.getPlayHost() + ":" + mediamtxConfig.getWebrtcPort() + "/" + name; // 拼接
+ // webrtcUrl
+
+ //不管插入数据库成不成功,都返回地址
+ live.setRtmpUrl(rtmpUrl);
+ live.setRtspUrl(rtspUrl);
+ live.setWebrtcUrl(webrtcUrl);
+
+ // 如果没有,插入新的记录
+ ArdVtdu vtdu =
+ ArdVtdu.builder().name(name).rtspSource("publisher").rtspUrl(rtspUrl).rtmpUrl(rtmpUrl).webrtcUrl(webrtcUrl).build();
+ // 在停止直播时不删除Vtdu表记录,所以在开启直播时再插入会报错,
+ // 所以要先查询是否存在该记录,如果已经存在记录,直接返回直播地址
+ // 查询是否已经存在该记录
+ ArdVtdu ardVtdu = ardVtduService.selectArdVtduByName(vtdu.getName());
+ int i = 0;
+ log.info("机场视频插入前vtdu:{}",vtdu);
+ if(ardVtdu == null){
+ i = ardVtduService.insertArdVtdu(vtdu); // 保存到媒体表
+ log.info("机场视频插入后 i:{}",i);
+ if (i > 0) {
+ live.setRtmpUrl(rtmpUrl);
+ live.setRtspUrl(rtspUrl);
+ live.setWebrtcUrl(webrtcUrl);
+ }
+ }
+ else{
+ i = ardVtduService.updateArdVtdu(vtdu);
+ log.info("机场视频更新后 i:{}",i);
+ if (i > 0) {
+ live.setRtmpUrl(rtmpUrl);
+ live.setRtspUrl(rtspUrl);
+ live.setWebrtcUrl(webrtcUrl);
+ }
+ }
+ //int i = ardVtduService.insertArdVtdu(vtdu); // 保存到媒体表
+// if (i > 0) {
+// live.setRtmpUrl(rtmpUrl);
+// live.setRtspUrl(rtspUrl);
+// live.setWebrtcUrl(webrtcUrl);
+// }
+ receiveReply = this.publishLiveStart(respTopic, liveParam);
+
+ if (ResponseResult.CODE_SUCCESS != receiveReply.getResult()) {
+ log.info("机场直播开启失败,错误码:{},错误描述:{}", receiveReply.getResult(), LiveErrorEnum.find(receiveReply.getResult()).getErrorMsg());
+ return ResponseResult.error(receiveReply.getResult(), LiveErrorEnum.find(receiveReply.getResult()).getErrorMsg());
+ }
+ break;
+ case RTSP:
+ receiveReply = this.publishLiveStart(respTopic, liveParam);
+
+ if (ResponseResult.CODE_SUCCESS != receiveReply.getResult()) {
+ log.info("机场直播开启失败,错误码:{},错误描述:{}", receiveReply.getResult(), LiveErrorEnum.find(receiveReply.getResult()).getErrorMsg());
+ return ResponseResult.error(receiveReply.getResult(), LiveErrorEnum.find(receiveReply.getResult()).getErrorMsg());
+ }
+ Object url = Objects.requireNonNullElse(receiveReply.getOutput(), receiveReply.getInfo());
+ this.resolveUrlUser(String.valueOf(url), live);
+ break;
+ case UNKNOWN:
+ return ResponseResult.error(LiveErrorEnum.URL_TYPE_NOT_SUPPORTED);
+ }
+
+
+
+ // 开始直播时发送机场相机摄像头默认信息(舱外)到所有在线用户,只取机场sn,发机场的摄像头信息
+ if(data.getChildDeviceSn()!=null) {
+ String sn = data.getDeviceSn();
+ deviceService.setRedisCameraPosition(sn, 1);
+ }
+ return ResponseResult.success(live);
+ }
+
+ @Override
+ public ResponseResult liveStop(String videoId) {
+ ResponseResult<DeviceDTO> responseResult = this.checkBeforeLive(videoId);
+ if (responseResult.getCode() != 0) {
+ return responseResult;
+ }
+
+ String respTopic = THING_MODEL_PRE + PRODUCT + responseResult.getData().getDeviceSn() + SERVICES_SUF;
+
+ ServiceReply receiveReply = this.publishLiveStop(respTopic, videoId);
+ if (receiveReply.getResult() != 0) {
+ log.info("videoId:{},直播停止失败,错误码:{},错误描述:{}", videoId, receiveReply.getResult(), LiveErrorEnum.find(receiveReply.getResult()).getErrorMsg());
+ return ResponseResult.error(LiveErrorEnum.find(receiveReply.getResult()));
+ }
+ //注释掉删除流媒体管理库中的信息
+// ardVtduService.deleteArdVtduByName(videoId);
+ return ResponseResult.success();
+ }
+
+ @Override
+ public ResponseResult liveSetQuality(LiveTypeDTO liveParam) {
+ if (liveParam.getVideoQuality() == null ||
+ LiveVideoQualityEnum.UNKNOWN == LiveVideoQualityEnum.find(liveParam.getVideoQuality())) {
+ return ResponseResult.error(LiveErrorEnum.ERROR_PARAMETERS);
+ }
+
+ ResponseResult<DeviceDTO> responseResult = this.checkBeforeLive(liveParam.getVideoId());
+
+ if (responseResult.getCode() != 0) {
+ return responseResult;
+ }
+
+ String respTopic = THING_MODEL_PRE + PRODUCT + responseResult.getData().getDeviceSn() + SERVICES_SUF;
+
+ ServiceReply receiveReply = this.publishLiveSetQuality(respTopic, liveParam);
+ if (ResponseResult.CODE_SUCCESS != receiveReply.getResult()) {
+ return ResponseResult.error(LiveErrorEnum.find(receiveReply.getResult()));
+ }
+
+ return ResponseResult.success();
+ }
+
+ @Override
+ public ResponseResult liveLensChange(LiveTypeDTO liveParam) {
+ // 机场1:C3 修订(2026-10-19)——原「单舱外相机」只针对机场相机切换(live_camera_change,
+ // 见下方 liveCameraChange);飞行器相机镜头(广角/变焦/红外)由 M30/M30T 支持,
+ // live_lens_change 恢复为与共享层一致的下发逻辑。
+ log.info("切换镜头参数:{}", liveParam);
+ ResponseResult<DeviceDTO> responseResult = this.checkBeforeLive(liveParam.getVideoId());
+ if (ResponseResult.CODE_SUCCESS != responseResult.getCode()) {
+ return responseResult;
+ }
+ if (DeviceDomainEnum.GATEWAY.getVal() == responseResult.getData().getDomain()) {
+ return ResponseResult.error(LiveErrorEnum.FUNCTION_NOT_SUPPORT);
+ }
+
+ String respTopic = THING_MODEL_PRE + PRODUCT + responseResult.getData().getDeviceSn() + SERVICES_SUF;
+
+ ServiceReply receiveReply = this.publishLiveLensChange(respTopic, liveParam);
+
+ if (ResponseResult.CODE_SUCCESS != receiveReply.getResult()) {
+ Integer code = receiveReply.getResult();
+ if (code == 325003) {
+ // 指令响应失败,请重试。低配机型不支持红外时返回该错误码
+ return ResponseResult.error(325003000, LiveErrorEnum.COMMAND_FAILED.getErrorMsg() + "请确认无人机是否支持此功能!");
+ }
+ return ResponseResult.error(LiveErrorEnum.find(receiveReply.getResult()));
+ }
+
+ return ResponseResult.success();
+ }
+
+ @Override
+ public ResponseResult<List<String>> getLiveLens(String sn) {
+ List<String> videoTypes = new ArrayList<>();
+ // 检查设备是否在线
+ if (!deviceService.checkDeviceOnline(sn)) {
+ log.info("SN: {}-设备不在线,请检查网络连接!", sn);
+ // 抛弃出异常,给调用者处理
+ //throw new Exception("设备不在线,请检查网络连接!");
+ return ResponseResult.error(CommonErrorEnum.DEVICE_OFFLINE.getErrorCode(),CommonErrorEnum.DEVICE_OFFLINE.getErrorMsg());
+ }
+ // 读取直播能力拼接参数
+ List<CapacityDeviceDTO> devicesList = getLiveCapacity();
+ if (devicesList == null || devicesList.isEmpty()) {
+ log.info("SN: {}-未获得设备直播能力!", sn);
+ // 抛弃出异常,给调用者处理
+ //throw new Exception("未获得设备直播能力!");
+ return ResponseResult.error(22222200,"未获得设备直播能力!");
+ }
+ for (CapacityDeviceDTO device : devicesList) {
+ if (!device.getSn().equals(sn)) {
+ continue;
+ }
+ List<CapacityCameraDTO> camerasList = device.getCamerasList();
+ if (camerasList == null || camerasList.isEmpty()) {
+ continue;
+ }
+ for (CapacityCameraDTO camera : camerasList) {
+ List<CapacityVideoDTO> videosList = camera.getVideosList();
+ if (videosList == null || videosList.isEmpty()) {
+ continue;
+ }
+ // 机场1:M30 / M30T 相机(52-x-x / 53-x-x)优先返回 normal 码流的镜头类型,
+ // 避免循环取最后一条时默认落到红外;无 normal 码流时退回任意一条有效记录。
+ // 其他机型保持原逻辑。
+ boolean m30SeriesCamera = camera.getIndex() != null
+ && (camera.getIndex().startsWith("52-") || camera.getIndex().startsWith("53-"));
+ for (CapacityVideoDTO video : videosList) {
+ List<String> switchTypes = video.getSwitchVideoTypes();
+ if (switchTypes == null || switchTypes.isEmpty()) {
+ continue;
+ }
+ if (m30SeriesCamera) {
+ if (video.getIndex() != null && video.getIndex().startsWith("normal")) {
+ videoTypes = switchTypes;
+ break;
+ }
+ if (videoTypes.isEmpty()) {
+ videoTypes = switchTypes;
+ }
+ continue;
+ }
+ // 机场3中飞行器,没有name,需要其他条件确定主摄像头参数
+ if (switchTypes.size() == 1 && camera.getName() == null) {
+ continue;
+ }
+ videoTypes = switchTypes;
+ }
+ }
+ }
+
+ return ResponseResult.success(videoTypes);
+ }
+
+ @Override
+ public ResponseResult liveCameraChange(LiveTypeDTO liveParam) {
+ // 机场1:C3 dock1 为单舱外相机(camera_position 恒为 1),不支持切换相机,整个功能禁用。
+ // 如需恢复:参考共享层 com.ard.sample.manage.service.impl.LiveStreamServiceImpl#liveCameraChange
+ log.info("机场1:liveCameraChange 已禁用(单舱外相机),忽略参数:{}", liveParam);
+ return ResponseResult.error(LiveErrorEnum.FUNCTION_NOT_SUPPORT);
+ }
+
+
+ // 机场1:C3 修订——liveLensChange 已恢复(飞行器镜头切换),本方法为实际下发入口
+ private ServiceReply publishLiveLensChange(String respTopic, LiveTypeDTO liveParam) {
+ CommonTopicResponse<LiveTypeDTO> response = new CommonTopicResponse<>();
+ response.setTid(UUID.randomUUID().toString());
+ response.setBid(UUID.randomUUID().toString());
+ response.setMethod(LiveStreamMethodEnum.LIVE_LENS_CHANGE.getMethod());
+ response.setData(liveParam);
+
+ return messageSender.publishWithReply(respTopic, response);
+ }
+
+ // 机场1:C3 liveCameraChange 已禁用,本方法保留备用(如需恢复,见 liveCameraChange 方法头注释)
+ private ServiceReply publishLiveCameraChange(String respTopic, LiveTypeDTO liveParam) {
+ CommonTopicResponse<LiveTypeDTO> response = new CommonTopicResponse<>();
+ response.setTid(UUID.randomUUID().toString());
+ response.setBid(UUID.randomUUID().toString());
+ response.setMethod(LiveStreamMethodEnum.LIVE_CAMERA_CHANGE.getMethod());
+ response.setData(liveParam);
+
+ return messageSender.publishWithReply(respTopic, response);
+ }
+
+ /**
+ * Check if this lens is available live.
+ *
+ * @param videoId
+ * @return
+ */
+ private ResponseResult<DeviceDTO> checkBeforeLive(String videoId) {
+ if (!StringUtils.hasText(videoId)) {
+ return ResponseResult.error(LiveErrorEnum.ERROR_PARAMETERS);
+ }
+ String[] videoIdArr = videoId.split("/");
+ // drone sn / enumeration value of the location where the payload is mounted / payload lens
+ if (videoIdArr.length != 3) {
+ return ResponseResult.error(LiveErrorEnum.ERROR_PARAMETERS);
+ }
+
+ Optional<DeviceDTO> deviceOpt = deviceService.getDeviceBySn(videoIdArr[0]);
+ // Check if the gateway device connected to this drone exists
+ if (deviceOpt.isEmpty()) {
+ return ResponseResult.error(LiveErrorEnum.NO_AIRCRAFT);
+ }
+
+ if (DeviceDomainEnum.DOCK.getVal() == deviceOpt.get().getDomain()) {
+ if (!Objects.equals(DOCK1_DOCK_TYPE, deviceOpt.get().getType())) {
+ return ResponseResult.error(314013004, "该设备不属于机场1,请使用对应的机场接口操作!");
+ }
+ return ResponseResult.success(deviceOpt.get());
+ }
+ List<DeviceDTO> gatewayList = deviceService.getDevicesByParams(
+ DeviceQueryParam.builder()
+ .childSn(videoIdArr[0])
+ .build());
+ if (gatewayList.isEmpty()) {
+ return ResponseResult.error(LiveErrorEnum.NO_FLIGHT_CONTROL);
+ }
+ // 机场1:只允许 Dock1 下的飞行器走 /dock1 直播链路。
+ if (!Objects.equals(DOCK1_DOCK_TYPE, gatewayList.get(0).getType())) {
+ return ResponseResult.error(314013004, "该设备不属于机场1,请使用对应的机场接口操作!");
+ }
+
+ return ResponseResult.success(gatewayList.get(0));
+ }
+
+ /**
+ * When using rtsp live, the account and password are parsed from the information returned by the pilot.
+ *
+ * @param url
+ * @param live
+ */
+ private void resolveUrlUser(String url, LiveDTO live) {
+ if (!StringUtils.hasText(url)) {
+ return;
+ }
+ int start = url.indexOf("//");
+ int end = url.lastIndexOf("@");
+ String user = url.substring(start + 2, end);
+
+ url = url.replace(user + "@", "");
+ String[] userArr = user.split(":");
+// live.setUsername(userArr[0]);
+// live.setPassword(userArr[1]);
+// live.setUrl(url);
+ }
+
+ /**
+ * When using GB28181 live, url parameters are resolved into objects.
+ *
+ * @param url
+ * @return
+ */
+ private LiveUrlGB28181DTO urlToGB28181(String url) {
+ String[] arr = url.split("\\=|\\&");
+ LiveUrlGB28181DTO gb28181 = new LiveUrlGB28181DTO();
+ try {
+ Class<LiveUrlGB28181DTO> clazz = LiveUrlGB28181DTO.class;
+ for (int i = 0; i < arr.length - 1; i += 2) {
+ Field field = clazz.getDeclaredField(arr[i]);
+ field.setAccessible(true);
+
+ if (field.getType().equals(Integer.class)) {
+ field.set(gb28181, Integer.valueOf(arr[i + 1]));
+ continue;
+ }
+ field.set(gb28181, arr[i + 1]);
+ }
+ } catch (NoSuchFieldException | IllegalAccessException e) {
+ e.printStackTrace();
+ }
+ return gb28181;
+ }
+
+ /**
+ * Send a message to the pilot via mqtt to start the live streaming.
+ *
+ * @param topic
+ * @param liveParam
+ * @return
+ */
+ private ServiceReply publishLiveStart(String topic, LiveTypeDTO liveParam) {
+ CommonTopicResponse<LiveTypeDTO> response = new CommonTopicResponse<>();
+ response.setTid(UUID.randomUUID().toString());
+ response.setBid(UUID.randomUUID().toString());
+ response.setData(liveParam);
+ response.setMethod(LiveStreamMethodEnum.LIVE_START_PUSH.getMethod());
+
+ return messageSender.publishWithReply(topic, response);
+ }
+
+ /**
+ * Send a message to the pilot via mqtt to set quality.
+ *
+ * @param respTopic
+ * @param liveParam
+ * @return
+ */
+ private ServiceReply publishLiveSetQuality(String respTopic, LiveTypeDTO liveParam) {
+ Map<String, Object> data = new ConcurrentHashMap<>(Map.of(
+ "video_id", liveParam.getVideoId(),
+ "video_quality", liveParam.getVideoQuality()));
+ CommonTopicResponse<Map<String, Object>> response = new CommonTopicResponse<>();
+ response.setTid(UUID.randomUUID().toString());
+ response.setBid(UUID.randomUUID().toString());
+ response.setMethod(LiveStreamMethodEnum.LIVE_SET_QUALITY.getMethod());
+ response.setData(data);
+
+ return messageSender.publishWithReply(respTopic, response);
+ }
+
+ /**
+ * Send a message to the pilot via mqtt to stop the live streaming.
+ *
+ * @param topic
+ * @param videoId
+ * @return
+ */
+ private ServiceReply publishLiveStop(String topic, String videoId) {
+ Map<String, String> data = new ConcurrentHashMap<>(Map.of("video_id", videoId));
+ CommonTopicResponse<Map<String, String>> response = new CommonTopicResponse<>();
+ response.setTid(UUID.randomUUID().toString());
+ response.setBid(UUID.randomUUID().toString());
+ response.setData(data);
+ response.setMethod(LiveStreamMethodEnum.LIVE_STOP_PUSH.getMethod());
+
+ return messageSender.publishWithReply(topic, response);
+ }
+
+}
\ No newline at end of file
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1WaylineTrajectoryServiceImpl.java b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1WaylineTrajectoryServiceImpl.java
new file mode 100644
index 0000000..5909c35
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1WaylineTrajectoryServiceImpl.java
@@ -0,0 +1,274 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.manage.service.impl.WaylineTrajectoryServiceImpl 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.manage.service.impl;
+
+import cn.hutool.core.util.SerializeUtil;
+import com.ard.common.core.text.Convert;
+import com.ard.sample.component.mqtt.model.EventsReceiver;
+import com.ard.sample.manage.mapper.IWaylineTrajectoryMapper;
+import com.ard.sample.manage.model.dto.WaylineTrajectoryDTO;
+import com.ard.sample.manage.model.entity.WaylineTrajectoryEntity;
+import com.ard.sample.dock1.manage.service.IWaylineTrajectoryService;
+import com.ard.sample.wayline.model.dto.WaylineJobDTO;
+import com.ard.sample.wayline.model.dto.WaylineTaskProgressReceiver;
+import com.ard.sample.dock1.wayline.service.IWaylineJobService;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import com.ard.sample.manage.service.impl.WaylineTrajectoryServiceImpl;
+
+@Service
+@Slf4j
+public class Dock1WaylineTrajectoryServiceImpl implements IWaylineTrajectoryService {
+
+ @Resource
+ private IWaylineTrajectoryMapper _waylineTrajectoryMapper;
+
+ @Autowired
+ private IWaylineJobService _waylineJobService;
+
+ @Autowired
+ protected ObjectMapper mapper;
+ /**
+ * 轨迹点列表缓存
+ */
+ private final ConcurrentHashMap<String, List<String>> cacheTrajectoryMap = new ConcurrentHashMap<>();
+
+ /**
+ * 任务状态数据缓存
+ */
+ private final ConcurrentHashMap<String, List<String>> cacheTaskMap = new ConcurrentHashMap<>();
+
+ /**
+ * 给数据加个进度
+ */
+// private final ConcurrentHashMap<String, String> cacheProgress = new ConcurrentHashMap<>();
+ /**
+ * 分片保存阈值条数
+ */
+ private static final int CHUNK_SIZE = 300;
+
+ /**
+ * 增加list数据,type为:0时,表示轨迹点列表;type为:1时,表示任务状态数据。
+ * @param sn 设备序列号
+ * @param type 类型
+ * @param dataJson json数据
+ */
+ @Override
+ public void setList(String sn, String type, String dataJson) {
+ List<String> listTrajectory = cacheTrajectoryMap.computeIfAbsent(sn, k -> new ArrayList<>());
+ List<String> listTask = cacheTaskMap.computeIfAbsent(sn, k -> new ArrayList<>());
+// String progress = cacheProgress.computeIfAbsent(sn, k -> "0");
+ // 取进度值,就为了判断ok一个任务的结束
+// Integer pro = Convert.toInt(cacheProgress.get(sn));
+ // 取jobid、status字段值
+ String jobId = "";
+ String isOK = "";
+ if (type.equals("0")) {
+ // 飞行轨迹数据
+ if(!listTask.isEmpty()) {
+ listTrajectory.add(dataJson);
+ log.info("SN:{} 轨迹点数据添加到列表缓存,当前列表数量:{}", sn, listTrajectory.size());
+ }
+ else {
+ cacheTrajectoryMap.put(sn, new ArrayList<>());
+ log.info("SN:{} 任务状态数据为空,当前无任务,不保存轨迹点数据", sn);
+ }
+// log.info("SN:{} 轨迹点数据:{}", sn, dataJson);
+ }
+ if (type.equals("1")) {
+ // 任务状态数据
+ listTask.add(dataJson);
+ log.info("SN:{} 任务状态数据添加到列表缓存,当前列表数量:{}", sn, listTask.size());
+ }
+ // jobid、status字段赋值
+ if (!listTask.isEmpty()) {
+ String json = listTask.get(listTask.size() - 1);
+ log.info("SN:{} 当前任务记录最后一条数据:{}", sn, json);
+ try {
+ EventsReceiver<WaylineTaskProgressReceiver> eventsReceiver = mapper.readValue(json,
+ new TypeReference<EventsReceiver<WaylineTaskProgressReceiver>>() {
+ });
+ jobId = eventsReceiver.getOutput().getExt().getFlightId();
+ isOK = eventsReceiver.getOutput().getStatus();
+
+ log.info("SN:{} 当前任务jobId:{},任务执行进度isOK:{}", sn, jobId, isOK);
+
+ } catch (Exception e) {
+ log.error("SN:{} 解析任务状态JSON失败: {}", sn, e.getMessage());
+ log.error("SN:{} JSON内容: {}", sn, json);
+ }
+ }
+ else {
+ // 当前任务状态数据为空时,清空缓存
+ log.info("SN:{} 任务状态数据为空,当前无任务清空缓存", sn);
+ // 清空缓存
+ cacheTrajectoryMap.put(sn, new ArrayList<>());
+ cacheTaskMap.put(sn, new ArrayList<>());
+ return;
+ }
+// listTrajectory.size() >= CHUNK_SIZE ||
+ if (listTask.size() >= CHUNK_SIZE) {
+ // 分片保存
+ save(sn, jobId, "0");
+ log.info("SN:{} 当前任务保存成功,jobId:{}, listTrajectory.size():{},listTask.size():{}",
+ sn, jobId, listTrajectory.size(), listTask.size());
+// log.info("当前任务保存成功,jobId:{},进度:{}",jobId , progress);
+ } else {
+ // 判断任务状态json中的status字段,如果为OK,则保存数据,一条完整的航线数据结束。
+ try {
+ if (isOK.equals("ok")) {
+ String progress = "ok";
+ if (getWaylineTrajectoryByProgress(jobId, progress)) {
+ log.info("SN:{} 当前任务已完成,不再保存数据!jobId:{},progress:{}", sn, jobId, progress);
+ // 清空缓存
+ cacheTrajectoryMap.put(sn, new ArrayList<>());
+ cacheTaskMap.put(sn, new ArrayList<>());
+ return;
+ }
+ save(sn, jobId, progress);
+ log.info("SN:{} 当前任务保存成功,jobId:{},进度:{}", sn,jobId , progress);
+ }
+ } catch (Exception e) {
+ log.error("SN:{} json解析异常!{}", sn, e.getMessage());
+ }
+ }
+ }
+
+ @Async
+ public void save(String sn, String JobId, String progress) {
+// List<String> trajectoryList = cacheTrajectoryMap.get(sn);
+// List<String> taskList = cacheTaskMap.get(sn);
+// String progress = cacheProgress.get(sn);
+ // 关键修复:原子性remove获取缓存(拿到当前所有数据)+ 清空缓存,避免异步竞态
+ List<String> trajectoryList = cacheTrajectoryMap.remove(sn);
+ List<String> taskList = cacheTaskMap.remove(sn);
+ // 处理remove返回null的情况(缓存中无该sn)
+ trajectoryList = trajectoryList == null ? new ArrayList<>() : trajectoryList;
+ taskList = taskList == null ? new ArrayList<>() : taskList;
+ // 序列化前打印关键日志(确认拿到的数据)
+ log.info("SN:{} save方法原子性获取缓存 - 轨迹数据size:{},任务数据size:{}",
+ sn, trajectoryList.size(), taskList.size());
+
+ // 空数据直接返回
+ if (trajectoryList.isEmpty() && taskList.isEmpty()) {
+ log.warn("SN:{} 原子性获取的缓存数据为空,无需保存!jobId:{}", sn, JobId);
+ return;
+ }
+ try {
+ log.info("SN:{} 开始保存轨迹点列表数据!jobId:{},轨迹数量:{},任务状态数量:{}",
+ sn, JobId, trajectoryList.size(), taskList.size());
+ byte[] blobTrajectory = SerializeUtil.serialize(trajectoryList);
+ byte[] blobTask = SerializeUtil.serialize(taskList);
+ log.info("SN:{} 序列化结果 - 轨迹字节长度:{},任务字节长度:{}",
+ sn, blobTrajectory.length, blobTask.length);
+
+ Optional<WaylineJobDTO> waylineJobDTO = _waylineJobService.getJobByJobId(JobId);
+ WaylineTrajectoryEntity TrajectoryEntity = new WaylineTrajectoryEntity();
+ if(waylineJobDTO.isPresent()) {
+ TrajectoryEntity.setJobId(JobId);
+ TrajectoryEntity.setName(waylineJobDTO.get().getJobName());
+ TrajectoryEntity.setFileId(waylineJobDTO.get().getFileId());
+ TrajectoryEntity.setFileName(waylineJobDTO.get().getFileName());
+ TrajectoryEntity.setDockSn(sn);
+ TrajectoryEntity.setProgress(progress);
+ TrajectoryEntity.setCreateTime(System.currentTimeMillis());
+ TrajectoryEntity.setUpdateTime(System.currentTimeMillis());
+ TrajectoryEntity.setTrajectoryList(blobTrajectory);
+ TrajectoryEntity.setTaskList(blobTask);
+ // 保存轨迹点列表数据
+ int i = _waylineTrajectoryMapper.insert(TrajectoryEntity);
+ if (i > 0) {
+ log.info("保存轨迹点列表数据成功!jobId:{}", JobId);
+ // 清空缓存
+ cacheTrajectoryMap.put(sn, new ArrayList<>());
+ cacheTaskMap.put(sn, new ArrayList<>());
+// if (progress.equals("ok")) {
+// cacheProgress.put(sn, "0");
+// }
+ }
+ }
+ } catch (Exception e) {
+ log.error("SN:{} 保存数据异常!jobId:{}, 异常信息:{}", sn, JobId, e.getMessage(), e);
+ // 异常时把数据放回缓存(避免数据丢失)
+ if (!trajectoryList.isEmpty()) {
+ cacheTrajectoryMap.computeIfAbsent(sn, k -> new ArrayList<>()).addAll(trajectoryList);
+ }
+ if (!taskList.isEmpty()) {
+ cacheTaskMap.computeIfAbsent(sn, k -> new ArrayList<>()).addAll(taskList);
+ }
+ }
+ }
+
+ /**
+ * 返回航线轨迹完整的数据,拼接的轨迹点列表和任务状态数据。
+ *
+ * @param jobId 任务ID
+ * @return 航线轨迹完整的数据
+ */
+ @Override
+ public WaylineTrajectoryDTO getWayLineTrajectorybyJobID(String jobId) {
+
+ List<WaylineTrajectoryEntity> waylineTrajectoryEntityList = new ArrayList<>(_waylineTrajectoryMapper.selectList(new LambdaQueryWrapper<WaylineTrajectoryEntity>()
+ .eq(jobId != null, WaylineTrajectoryEntity::getJobId, jobId)
+ .orderByAsc(WaylineTrajectoryEntity::getId)));
+
+ return TrajectoryEntityConvertToDTO(waylineTrajectoryEntityList);
+ }
+
+ /**
+ * 根据作业ID和进度获取航线轨迹
+ *
+ * @param jobId 作业ID,可为null
+ * @param progress 进度值,可为null
+ * @return 当存在符合条件的航线轨迹时返回true,否则返回false
+ */
+ public Boolean getWaylineTrajectoryByProgress(String jobId, String progress) {
+ List<WaylineTrajectoryEntity> waylineTrajectoryEntityList = new ArrayList<>(_waylineTrajectoryMapper.selectList(new LambdaQueryWrapper<WaylineTrajectoryEntity>()
+ .eq(jobId != null, WaylineTrajectoryEntity::getJobId, jobId)
+ .eq(progress != null, WaylineTrajectoryEntity::getProgress, progress)
+ .orderByAsc(WaylineTrajectoryEntity::getId)));
+
+ return !waylineTrajectoryEntityList.isEmpty();
+ }
+
+
+ public WaylineTrajectoryDTO TrajectoryEntityConvertToDTO(List<WaylineTrajectoryEntity> entity) {
+ if (entity == null) {
+ return null;
+ }
+ WaylineTrajectoryDTO dto = new WaylineTrajectoryDTO();
+ try {
+ dto.setId(entity.get(0).getId());
+ dto.setJobId(entity.get(0).getJobId());
+ dto.setName(entity.get(0).getName());
+ dto.setFileId(entity.get(0).getFileId());
+ dto.setFileName(entity.get(0).getFileName());
+ dto.setDockSn(entity.get(0).getDockSn());
+ dto.setProgress(entity.get(0).getProgress());
+ dto.setCreateTime(entity.get(0).getCreateTime());
+ dto.setUpdateTime(entity.get(0).getUpdateTime());
+ for (WaylineTrajectoryEntity e : entity) {
+ dto.getTrajectoryList().addAll(SerializeUtil.deserialize(e.getTrajectoryList(), List.class));
+ dto.getTaskList().addAll(SerializeUtil.deserialize(e.getTaskList(), List.class));
+ }
+ }
+ catch (Exception e) {
+ log.error("获取任务id轨迹点列表数据失败!{}", e.getMessage());
+ }
+
+ return dto;
+ }
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/wayline/model/dto/Dock1WaylineTaskCreateDTO.java b/ard-sample/src/main/java/com/ard/sample/dock1/wayline/model/dto/Dock1WaylineTaskCreateDTO.java
new file mode 100644
index 0000000..f647506
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/wayline/model/dto/Dock1WaylineTaskCreateDTO.java
@@ -0,0 +1,79 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.wayline.model.dto.WaylineTaskCreateDTO 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.wayline.model.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import com.ard.sample.wayline.model.dto.WaylineSimulateMissionDTO;
+import com.ard.sample.wayline.model.dto.WaylineTaskCreateDTO;
+import com.ard.sample.wayline.model.dto.WaylineTaskFileDTO;
+
+/**
+ * @author sean
+ * @version 1.1
+ * @date 2022/6/1
+ */
+// 序列化时忽略所有 null 字段
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class Dock1WaylineTaskCreateDTO {
+
+ /**
+ * 计划 ID
+ * */
+ @JsonProperty("flight_id")
+ private String flightId;
+
+ /**
+ * 任务类型 "0":"立即任务","1":"定时任务","2":"条件任务"
+ * 立即任务和定时任务均由execute_time指定执行时间;
+ * 条件任务支持ready_conditions字段指定任务就绪条件,
+ * 设备可在指定时间段内满足就绪条件后即可执行;
+ * 立即任务媒体上传优先级最高,
+ * 定时任务和条件任务媒体上传优先级相同
+ * */
+ @JsonProperty("task_type")
+ private Integer taskType;
+
+ // 机场1:C4 dock1(M30)官方无 wayline_precision_type(waylineType)字段,已剔除不下发
+
+ /**
+ * 开始执行时间
+ * 任务开始执行时间毫秒时间戳。可选字段。当 task_type 为 0 或 1 时必填,为 2 时非必填。
+ * */
+ @JsonProperty("execute_time")
+ private Long executeTime;
+
+ /**
+ * 航线文件对象
+ * */
+ @JsonProperty("file")
+ private WaylineTaskFileDTO file;
+
+ /**
+ * 返航高度 "max":1500,"min":20,"unit_name":"米 / m"
+ * */
+ @JsonProperty("rth_altitude")
+ private Integer rthAltitude;
+
+ // 机场1:C4 dock1(M30)官方无 rth_mode(rthMode)字段,已剔除不下发
+
+ /**
+ * 遥控器失控动作 "0":"返航","1":"悬停","2":"降落"
+ * 失控动作,当前固定传的值是 0,即返航。注意该枚举值定义跟飞控跟机场定义的不一致,机场端会进行转换。
+ * */
+ @JsonProperty("out_of_control_action")
+ private Integer outOfControlAction;
+
+// @JsonProperty("simulate_mission")
+// private WaylineSimulateMissionDTO simulateMission;
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IFlightTaskService.java b/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IFlightTaskService.java
new file mode 100644
index 0000000..24956c9
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IFlightTaskService.java
@@ -0,0 +1,22 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.wayline.service.IFlightTaskService 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.wayline.service;
+
+import com.ard.sample.component.mqtt.model.CommonTopicReceiver;
+import org.springframework.messaging.MessageHeaders;
+
+/**
+ * @author sean
+ * @version 1.1
+ * @date 2022/6/9
+ */
+public interface IFlightTaskService {
+
+ /**
+ * Handle the progress messages of the flight tasks reported by the dock.
+ * @param receiver
+ */
+ void handleProgress(CommonTopicReceiver receiver, MessageHeaders headers);
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IWaylineFileService.java b/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IWaylineFileService.java
new file mode 100644
index 0000000..650499f
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IWaylineFileService.java
@@ -0,0 +1,80 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.wayline.service.IWaylineFileService 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.wayline.service;
+
+import com.ard.sample.common.model.PaginationData;
+import com.ard.sample.wayline.model.dto.WaylineFileDTO;
+import com.ard.sample.wayline.model.param.WaylineQueryParam;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.net.URL;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * @author sean
+ * @version 0.3
+ * @date 2021/12/22
+ */
+public interface IWaylineFileService {
+
+ /**
+ * Perform paging queries based on query parameters.
+ * @param param
+ * @return
+ */
+ PaginationData<WaylineFileDTO> getWaylinesByParam(WaylineQueryParam param);
+
+ /**
+ * Query the information of this wayline file according to the wayline file id.
+ * @param waylineId
+ * @return
+ */
+ Optional<WaylineFileDTO> getWaylineByWaylineId(String waylineId);
+
+ /**
+ * Get the download address of the file object.
+ * @param waylineId
+ * @return
+ */
+ URL getObjectUrl(String waylineId) throws SQLException;
+
+ /**
+ * Save the basic information of the wayline file.
+ * @param metadata
+ * @return
+ */
+ Integer saveWaylineFile(WaylineFileDTO metadata);
+
+ /**
+ * Updates whether the file is collected or not based on the passed parameters.
+ * @param ids wayline id
+ * @param isFavorite Whether the wayline file is favorited or not.
+ * @return
+ */
+ Boolean markFavorite(List<String> ids, Boolean isFavorite);
+
+ /**
+ * Batch query for duplicate file names in workspace.
+ * @param names
+ * @return
+ */
+ List<String> getDuplicateNames(List<String> names);
+
+ /**
+ * Delete the wayline file based on the wayline id.
+ * @param waylineId
+ */
+ Boolean deleteByWaylineId(String waylineId);
+
+ /**
+ * Import kmz wayline file.
+ * @param file
+ * @param creator
+ * @return
+ */
+ void importKmzFile(MultipartFile file, String creator);
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IWaylineJobService.java b/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IWaylineJobService.java
new file mode 100644
index 0000000..3bba79f
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IWaylineJobService.java
@@ -0,0 +1,129 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.wayline.service.IWaylineJobService 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.wayline.service;
+
+import com.ard.common.core.domain.model.LoginUser;
+import com.ard.sample.common.model.PaginationData;
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.component.mqtt.model.CommonTopicReceiver;
+import com.ard.sample.wayline.model.dto.WaylineJobDTO;
+import com.ard.sample.wayline.model.enums.WaylineJobStatusEnum;
+import com.ard.sample.wayline.model.param.CreateJobParam;
+import org.springframework.messaging.MessageHeaders;
+import java.sql.SQLException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * @author sean
+ * @version 1.1
+ * @date 2022/6/1
+ */
+public interface IWaylineJobService {
+
+ /**
+ * Create wayline job in the database.
+ * @param param
+ * @param username user info
+ * @param beginTime The time the job started.
+ * @param endTime The time the job ended.
+ * @return
+ */
+ Optional<WaylineJobDTO> createWaylineJob(CreateJobParam param, String username, Long beginTime, Long endTime);
+
+ /**
+ * Create a sub-task based on the information of the parent task.
+
+ * @param parentId
+ * @return
+ */
+ Optional<WaylineJobDTO> createWaylineJobByParent(String parentId);
+
+ /**
+ * Issue wayline mission to the dock.
+ * @param param
+ * @param loginUser user info
+ * @return
+ */
+ ResponseResult publishFlightTask(CreateJobParam param, LoginUser loginUser) throws SQLException;
+
+ ResponseResult publishFlightTaskPrepare(Optional<WaylineJobDTO> waylineJobOpt) throws SQLException;
+
+ /**
+ * Execute the task immediately.
+ * @param jobId
+ * @throws SQLException
+ * @return
+ */
+ Boolean executeFlightTask(String jobId);
+
+ /**
+ * Cancel the task Base on job Ids.
+ * @param jobId
+ * @throws SQLException
+ */
+ ResponseResult cancelFlightTask(Collection<String> jobId);
+// void cancelFlightTask(Collection<String> jobIds);
+
+ /**
+ * Cancel the dock tasks that have been issued but have not yet been executed.
+ * @param dockSn
+ * @param jobIds
+ */
+ ResponseResult publishCancelTask(String dockSn, List<String> jobIds);
+
+ /**
+ * 删除任务
+ * */
+ void deleteTask(String id);
+
+ /**
+ * Query wayline jobs based on conditions.
+ * @param jobIds
+ * @param status
+ * @return
+ */
+ List<WaylineJobDTO> getJobsByConditions( Collection<String> jobIds, WaylineJobStatusEnum status);
+
+ /**
+ * Query job information based on job id.
+ * @param jobId
+ * @return job information
+ */
+ Optional<WaylineJobDTO> getJobByJobId(String jobId);
+
+ /**
+ * Update job data.
+ * @param dto
+ * @return
+ */
+ Boolean updateJob(WaylineJobDTO dto);
+
+ /**
+ * Paginate through all jobs in this workspace.
+ * @param page
+ * @param pageSize
+ * @return
+ */
+ PaginationData<WaylineJobDTO> getJobsByWorkspaceId(long page, long pageSize);
+
+ PaginationData<WaylineJobDTO> getJobsList(long page, long pageSize, Integer status,
+ Long startTime, Long endTime, Integer taskType,
+ String name, Boolean isComplete);
+
+ /**
+ * Process to get interface data of flight mission resources.
+ * @param receiver
+ * @param headers
+ */
+ void flightTaskResourceGet(CommonTopicReceiver receiver, MessageHeaders headers);
+
+ /**
+ * Set the media files for this job to upload immediately.
+ * @param jobId
+ */
+ void uploadMediaHighestPriority(String jobId);
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1FlightTaskServiceImpl.java b/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1FlightTaskServiceImpl.java
new file mode 100644
index 0000000..30f5029
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1FlightTaskServiceImpl.java
@@ -0,0 +1,288 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.wayline.service.impl.FlightTaskServiceImpl 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.wayline.service.impl;
+
+import com.ard.common.utils.uuid.IdUtils;
+import com.ard.route.domain.route.ArdRouteInfo;
+import com.ard.route.service.IArdRouteInfoService;
+import com.ard.route.service.IArdRouteMissionService;
+import com.ard.sample.common.error.CommonErrorEnum;
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.component.mqtt.model.*;
+import com.ard.sample.component.mqtt.service.IMessageSenderService;
+import com.ard.common.core.redis.RedisConst;
+import com.ard.common.core.redis.RedisOpsUtils;
+import com.ard.sample.component.websocket.enums.MessageType;
+import com.ard.sample.component.websocket.handler.DockWebSocketHandler;
+import com.ard.sample.component.websocket.model.BizCodeEnum;
+import com.ard.sample.component.websocket.model.CustomWebSocketMessage;
+import com.ard.sample.component.websocket.service.ISendMessageService;
+import com.ard.sample.component.websocket.service.IWebSocketManageService;
+import com.ard.sample.manage.model.dto.DeviceDTO;
+import com.ard.sample.manage.model.enums.UserTypeEnum;
+// 机场1:任务状态(type="1")与轨迹点(type="0")必须写入同一个共享轨迹服务缓存。
+import com.ard.sample.manage.service.IWaylineTrajectoryService;
+import com.ard.sample.media.model.MediaFileCountDTO;
+import com.ard.sample.wayline.model.dto.WaylineJobDTO;
+import com.ard.sample.wayline.model.dto.WaylineTaskProgressExt;
+import com.ard.sample.wayline.model.dto.WaylineTaskProgressReceiver;
+import com.ard.sample.wayline.model.entity.WaylineJobHistoryEntity;
+import com.ard.sample.wayline.model.enums.WaylineJobStatusEnum;
+import com.ard.sample.dock1.wayline.service.IFlightTaskService;
+import com.ard.sample.wayline.service.IWaylineJobHistoryService;
+import com.ard.sample.dock1.wayline.service.IWaylineJobService;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.http.HttpStatus;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.integration.mqtt.support.MqttHeaders;
+import org.springframework.messaging.MessageHeaders;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import com.ard.sample.wayline.service.impl.FlightTaskServiceImpl;
+
+/**
+ * @author sean
+ * @version 1.1
+ * @date 2022/6/9
+ */
+@Service
+@Slf4j
+public class Dock1FlightTaskServiceImpl implements IFlightTaskService {
+
+ @Autowired
+ private IMessageSenderService messageSender;
+
+ @Autowired
+ private ObjectMapper mapper;
+
+ @Autowired
+ private DockWebSocketHandler _dockWebSocketHandler;
+
+ @Autowired
+ private ISendMessageService websocketMessageService;
+
+ @Autowired
+ private IWebSocketManageService webSocketManageService;
+
+ @Autowired
+ private IWaylineJobService waylineJobService;
+
+ @Autowired
+ private IWaylineTrajectoryService _waylineTrajectoryService;
+
+ @Autowired
+ private IWaylineJobHistoryService _waylineJobHistoryService;
+
+ @Autowired
+ private IArdRouteMissionService _ardRouteMissionService;
+
+ @Autowired
+ private IArdRouteInfoService _ardRouteInfoService;
+
+ @Override
+ // 机场1:不复制原 ServiceActivator(inputChannel = ChannelName.INBOUND_EVENTS_FLIGHT_TASK_PROGRESS),消费职责由共享层原类承担
+ public void handleProgress(CommonTopicReceiver receiver, MessageHeaders headers) {
+ String receivedTopic = String.valueOf(headers.get(MqttHeaders.RECEIVED_TOPIC));
+ String dockSn = receivedTopic.substring((TopicConst.THING_MODEL_PRE + TopicConst.PRODUCT).length(),
+ receivedTopic.indexOf(TopicConst.EVENTS_SUF));
+ EventsReceiver<WaylineTaskProgressReceiver> eventsReceiver = mapper.convertValue(receiver.getData(),
+ new TypeReference<EventsReceiver<WaylineTaskProgressReceiver>>() {
+ });
+ eventsReceiver.setBid(receiver.getBid());
+ eventsReceiver.setSn(receiver.getGateway());
+ eventsReceiver.setGateway(receiver.getGateway());
+ eventsReceiver.setType(MessageType.STATUS.getType() + "-flighttask_progress");
+ eventsReceiver.setMethod(EventsMethodEnum.FLIGHT_TASK_PROGRESS.getMethod());
+
+
+ WaylineTaskProgressReceiver output = eventsReceiver.getOutput();
+
+ log.info("Task progress: {}", output.getProgress().toString());
+
+ if (eventsReceiver.getResult() != ResponseResult.CODE_SUCCESS) {
+ String errorMsg = CommonErrorEnum.getMsgByCode(eventsReceiver.getResult());
+ log.error("Task progress ===> Error code: {},Error Msg: {}", eventsReceiver.getResult(), errorMsg);
+ }
+
+ EventsResultStatusEnum statusEnum = EventsResultStatusEnum.find(output.getStatus());
+ String key = RedisConst.WAYLINE_JOB_RUNNING_PREFIX + dockSn;
+ RedisOpsUtils.setWithExpire(key, eventsReceiver, RedisConst.DEVICE_ALIVE_SECOND * RedisConst.DEVICE_ALIVE_SECOND);
+
+ if (statusEnum.getEnd()) {
+ WaylineJobDTO job = WaylineJobDTO.builder()
+ .jobId(receiver.getBid())
+ .status(WaylineJobStatusEnum.SUCCESS.getVal())
+ .completedTime(LocalDateTime.now())
+ .mediaCount(output.getExt().getMediaCount())
+ .build();
+
+ // record the update of the media count.
+ if (Objects.nonNull(job.getMediaCount()) && job.getMediaCount() != 0) {
+ RedisOpsUtils.hashSet(RedisConst.MEDIA_FILE_PREFIX + receiver.getGateway(), job.getJobId(),
+ MediaFileCountDTO.builder().jobId(receiver.getBid()).mediaCount(job.getMediaCount()).uploadedCount(0).build());
+ }
+
+ if (EventsResultStatusEnum.OK != statusEnum) {
+ job.setCode(eventsReceiver.getResult());
+ job.setStatus(WaylineJobStatusEnum.FAILED.getVal());
+ }
+
+ waylineJobService.updateJob(job);
+ RedisOpsUtils.del(key);
+ RedisOpsUtils.del(RedisConst.WAYLINE_JOB_PAUSED_PREFIX + receiver.getBid());
+ }
+
+ DeviceDTO device = (DeviceDTO) RedisOpsUtils.get(RedisConst.DEVICE_ONLINE_PREFIX + receiver.getGateway());
+ websocketMessageService.sendBatch(
+ webSocketManageService.getValueAndUserType(UserTypeEnum.WEB.getVal()),
+ CustomWebSocketMessage.builder()
+ .data(eventsReceiver)
+ .timestamp(System.currentTimeMillis())
+ .bizCode(BizCodeEnum.FLIGHT_TASK_PROGRESS.getCode())
+ .build());
+
+ if (receiver.getNeedReply() != null && receiver.getNeedReply() == 1) {
+ messageSender.publish(receivedTopic + TopicConst._REPLY_SUF,
+ CommonTopicResponse.builder()
+ .tid(receiver.getTid())
+ .bid(receiver.getBid())
+ .method(EventsMethodEnum.FLIGHT_TASK_PROGRESS.getMethod())
+ .timestamp(System.currentTimeMillis())
+ .data(RequestsReply.success())
+ .build());
+ }
+ // 进度
+// if (eventsReceiver.getOutput().getProgress().getPercent() >= 95) {
+// return;
+// }
+
+
+
+ String json = "";
+ try {
+ json = mapper.writeValueAsString(eventsReceiver);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ _dockWebSocketHandler.sendMessageToSubscribers(dockSn, MessageType.STATUS.getType(), json, "");
+
+ _waylineTrajectoryService.setList(dockSn,"1",json);
+
+ // 计划任务成功后,保存计划任务到历史记录
+ String status = eventsReceiver.getOutput().getStatus();
+ String jobId = eventsReceiver.getOutput().getExt().getFlightId();
+ if(status.equals("ok") && jobId != null) {
+ Optional<WaylineJobDTO> job = waylineJobService.getJobByJobId(jobId);
+ if(job.isPresent()) {
+ if (_waylineJobHistoryService.getJobByJobId(jobId).isPresent()) {
+ return;
+ }
+ WaylineJobHistoryEntity waylineJobHistoryEntity = _waylineJobHistoryService.dto2Entity(job.get());
+ Optional<WaylineJobDTO> jobHistory = _waylineJobHistoryService.createWaylineJob(waylineJobHistoryEntity);
+ if(jobHistory.isPresent()) {
+ log.info("任务执行成功,保存到历史记录。 {}", jobHistory);
+ }
+ // 同步更新4.2的计划任务的状态;等待夏吉建的接口。1:已执行 0:未执行
+ // 在任务中查找航线文件id,取航线文件信息中的状态,是3的修改状态
+ ArdRouteInfo ardRouteInfo = _ardRouteInfoService.selectArdRouteInfoById(job.get().getFileId());
+ if(ardRouteInfo != null && ardRouteInfo.getType().equals("3")) {
+ _ardRouteMissionService.updateArdRouteMissionStatus(jobId, "1");
+ log.info("任务航线-执行成功,更新航线状态。 jobId:{}", jobId);
+ }
+ }
+
+ }
+ }
+
+ /**
+ * 定时检查并处理航线定时任务
+ * <p>
+ * 该方法每5秒执行一次,从Redis有序集合中获取最早需要执行的任务,检查其执行时间:
+ * 1. 若任务已过期(超过当前时间30秒),则标记为失败并删除
+ * 2. 若任务在允许的时间窗口内(当前时间±30秒),则执行任务并删除
+ * </p>
+ *
+ * <p>任务ID格式:{workspace_id}:{dock_sn}:{job_id}</p>
+ */
+ // 机场1:不复制原 Scheduled 定时任务,定时职责由共享层原类承担
+ private void checkScheduledJob() {
+ Object jobIdValue = RedisOpsUtils.zGetMin(RedisConst.WAYLINE_JOB_TIMED_EXECUTE);
+ if (Objects.isNull(jobIdValue)) {
+ return;
+ }
+ log.info("检查 wayline 的定时任务。 {}", jobIdValue);
+ // format: {workspace_id}:{dock_sn}:{job_id}
+ String[] jobArr = String.valueOf(jobIdValue).split(RedisConst.DELIMITER);
+ double time = RedisOpsUtils.zScore(RedisConst.WAYLINE_JOB_TIMED_EXECUTE, jobIdValue);
+ // region 取定时任务信息websocket发给前端
+ // 定时任务执行前,判断有没有任务正在执行,在执行则不发送websocket消息
+ String key = RedisConst.WAYLINE_JOB_RUNNING_PREFIX + jobArr[0];
+ if (!RedisOpsUtils.checkExist(key)) {
+ // 取到待执行的定时任务ID,获取任务信息websocket发给前端
+ // pending status状态待执行
+ EventsReceiver<WaylineTaskProgressReceiver> eventsReceiver = new EventsReceiver<>();
+ WaylineTaskProgressReceiver waylineTaskProgressReceiver = new WaylineTaskProgressReceiver();
+ WaylineTaskProgressExt waylineTaskProgressExt = new WaylineTaskProgressExt();
+ waylineTaskProgressExt.setFlightId(jobArr[1]);
+ waylineTaskProgressReceiver.setStatus("pending");
+ waylineTaskProgressReceiver.setExt(waylineTaskProgressExt);
+ eventsReceiver.setOutput(waylineTaskProgressReceiver);
+ eventsReceiver.setResult(ResponseResult.CODE_SUCCESS);
+ eventsReceiver.setBid(IdUtils.simpleUUID());
+ eventsReceiver.setSn(jobArr[0]);
+ eventsReceiver.setGateway(jobArr[0]);
+ eventsReceiver.setType(MessageType.STATUS.getType() + "-flighttask_progress");
+ eventsReceiver.setMethod(EventsMethodEnum.FLIGHT_TASK_PROGRESS.getMethod());
+ String json = "";
+ try {
+ json = mapper.writeValueAsString(eventsReceiver);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ _dockWebSocketHandler.sendMessageToSubscribers(jobArr[0], MessageType.STATUS.getType(), json, "");
+ log.info("定时任务自定义状态为待执行,json: {}", json);
+ }
+ // endregion
+ long now = System.currentTimeMillis();
+ int offset = 30_000;
+
+ // 过期任务会直接删除。
+ if (time < now - offset) {
+ log.info("定时任务超时,jobId: {}", jobArr[1]);
+ RedisOpsUtils.zRemove(RedisConst.WAYLINE_JOB_TIMED_EXECUTE, jobIdValue);
+ waylineJobService.updateJob(WaylineJobDTO.builder()
+ .jobId(jobArr[1])
+ .status(WaylineJobStatusEnum.FAILED.getVal())
+ .executeTime(LocalDateTime.now())
+ .completedTime(LocalDateTime.now())
+ .code(HttpStatus.SC_REQUEST_TIMEOUT).build());
+ return;
+ }
+
+ if (now <= time && time <= now + offset) {
+ try {
+ waylineJobService.executeFlightTask(jobArr[1]);
+ } catch (Exception e) {
+ log.info("计划中的任务交付失败了。jobId: {},异常信息:{}", jobArr[1], e.getMessage());
+ waylineJobService.updateJob(WaylineJobDTO.builder()
+ .jobId(jobArr[1])
+ .status(WaylineJobStatusEnum.FAILED.getVal())
+ .executeTime(LocalDateTime.now())
+ .completedTime(LocalDateTime.now())
+ .code(HttpStatus.SC_INTERNAL_SERVER_ERROR).build());
+ } finally {
+ RedisOpsUtils.zRemove(RedisConst.WAYLINE_JOB_TIMED_EXECUTE, jobIdValue);
+ }
+ }
+ }
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1WaylineFileServiceImpl.java b/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1WaylineFileServiceImpl.java
new file mode 100644
index 0000000..765c551
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1WaylineFileServiceImpl.java
@@ -0,0 +1,289 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.wayline.service.impl.WaylineFileServiceImpl 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.wayline.service.impl;
+
+import com.ard.common.config.OssConfiguration;
+import com.ard.oss.service.impl.OssServiceContext;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ard.common.core.page.PageDomain;
+import com.ard.common.core.page.TableSupport;
+import com.ard.sample.common.model.Pagination;
+import com.ard.sample.common.model.PaginationData;
+import com.ard.sample.manage.model.enums.DeviceDomainEnum;
+import com.ard.sample.wayline.mapper.IWaylineFileMapper;
+import com.ard.sample.wayline.model.dto.KmzFileProperties;
+import com.ard.sample.wayline.model.dto.WaylineFileDTO;
+import com.ard.sample.wayline.model.entity.WaylineFileEntity;
+import com.ard.sample.wayline.model.param.WaylineQueryParam;
+import com.ard.sample.dock1.wayline.service.IWaylineFileService;
+import org.dom4j.Document;
+import org.dom4j.DocumentException;
+import org.dom4j.Node;
+import org.dom4j.io.SAXReader;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.DigestUtils;
+import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.sql.SQLException;
+import java.util.*;
+import java.util.stream.Collectors;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import static com.ard.sample.wayline.model.dto.KmzFileProperties.WAYLINE_FILE_SUFFIX;
+import com.ard.sample.wayline.service.impl.WaylineFileServiceImpl;
+
+/**
+ * @author sean
+ * @version 0.3
+ * @date 2021/12/22
+ */
+@Service
+@Transactional
+public class Dock1WaylineFileServiceImpl implements IWaylineFileService {
+
+ @Autowired
+ private IWaylineFileMapper mapper;
+
+ @Autowired
+ private OssServiceContext ossService;
+
+ @Override
+ public PaginationData<WaylineFileDTO> getWaylinesByParam(WaylineQueryParam param) {
+ PageDomain pageDomain = TableSupport.buildPageRequest(); // 从请求获取分页参数
+ // Paging Query
+ Page<WaylineFileEntity> page = mapper.selectPage(
+ new Page<WaylineFileEntity>(pageDomain.getPageNum(), pageDomain.getPageSize()),
+ new LambdaQueryWrapper<WaylineFileEntity>()
+ .eq(param.isFavorited(), WaylineFileEntity::getFavorited, param.isFavorited())
+ .and(param.getTemplateType() != null, wrapper -> {
+ for (Integer type : param.getTemplateType()) {
+ wrapper.like(WaylineFileEntity::getTemplateTypes, type).or();
+ }
+ })
+ // There is a risk of SQL injection
+ .last(StringUtils.hasText(param.getOrderBy()), " order by " + param.getOrderBy())
+ );
+
+ // Wrap the results of a paging query into a custom paging object.
+ List<WaylineFileDTO> records = page.getRecords()
+ .stream()
+ .map(this::entityConvertToDTO)
+ .collect(Collectors.toList());
+
+ return new PaginationData<>(records, new Pagination(page));
+ }
+
+ @Override
+ public Optional<WaylineFileDTO> getWaylineByWaylineId(String waylineId) {
+ return Optional.ofNullable(
+ this.entityConvertToDTO(
+ mapper.selectOne(
+ new LambdaQueryWrapper<WaylineFileEntity>()
+ .eq(WaylineFileEntity::getWaylineId, waylineId))));
+ }
+
+ @Override
+ public URL getObjectUrl(String waylineId) throws SQLException {
+ Optional<WaylineFileDTO> waylineOpt = this.getWaylineByWaylineId(waylineId);
+ if (waylineOpt.isEmpty()) {
+ throw new SQLException(waylineId + " does not exist.");
+ }
+ return ossService.getObjectUrl(OssConfiguration.bucket, waylineOpt.get().getObjectKey());
+ }
+
+ @Override
+ public Integer saveWaylineFile(WaylineFileDTO metadata) {
+ WaylineFileEntity file = this.dtoConvertToEntity(metadata);
+ file.setWaylineId(UUID.randomUUID().toString());
+ if (!StringUtils.hasText(file.getSign())) {
+ try (InputStream object = ossService.getObject(OssConfiguration.bucket, metadata.getObjectKey())) {
+ if (object.available() == 0) {
+ throw new RuntimeException("The file " + metadata.getObjectKey() +
+ " does not exist in the bucket[" + OssConfiguration.bucket + "].");
+ }
+ file.setSign(DigestUtils.md5DigestAsHex(object));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ int insertId = mapper.insert(file);
+ return insertId > 0 ? file.getId() : insertId;
+ }
+
+ @Override
+ public Boolean markFavorite(List<String> waylineIds, Boolean isFavorite) {
+ if (waylineIds.isEmpty()) {
+ return false;
+ }
+ if (isFavorite == null) {
+ return true;
+ }
+ return mapper.update(null, new LambdaUpdateWrapper<WaylineFileEntity>()
+ .set(WaylineFileEntity::getFavorited, isFavorite)
+ .in(WaylineFileEntity::getWaylineId, waylineIds)) > 0;
+ }
+
+ @Override
+ public List<String> getDuplicateNames(List<String> names) {
+ return mapper.selectList(new LambdaQueryWrapper<WaylineFileEntity>()
+ .in(WaylineFileEntity::getName, names))
+ .stream()
+ .map(WaylineFileEntity::getName)
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public Boolean deleteByWaylineId(String waylineId) {
+ Optional<WaylineFileDTO> waylineOpt = this.getWaylineByWaylineId(waylineId);
+ if (waylineOpt.isEmpty()) {
+ return true;
+ }
+ WaylineFileDTO wayline = waylineOpt.get();
+ boolean isDel = mapper.delete(new LambdaUpdateWrapper<WaylineFileEntity>()
+ .eq(WaylineFileEntity::getWaylineId, waylineId))
+ > 0;
+ if (!isDel) {
+ return false;
+ }
+ return ossService.deleteObject(OssConfiguration.bucket, wayline.getObjectKey());
+ }
+
+ @Override
+ public void importKmzFile(MultipartFile file, String creator) {
+ Optional<WaylineFileDTO> waylineFileOpt = validKmzFile(file);
+ if (waylineFileOpt.isEmpty()) {
+ throw new RuntimeException("The file format is incorrect.");
+ }
+
+ try {
+ WaylineFileDTO waylineFile = waylineFileOpt.get();
+ waylineFile.setUsername(creator);
+ // 使用 try-with-resources 确保流关闭
+ try (InputStream input = file.getInputStream()) {
+ ossService.putObject(OssConfiguration.bucket, waylineFile.getObjectKey(), input);
+ }
+ this.saveWaylineFile(waylineFile);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private Optional<WaylineFileDTO> validKmzFile(MultipartFile file) {
+ String filename = file.getOriginalFilename();
+ if (Objects.nonNull(filename) && !filename.endsWith(WAYLINE_FILE_SUFFIX)) {
+ throw new RuntimeException("The file format is incorrect.");
+ }
+ try (ZipInputStream unzipFile = new ZipInputStream(file.getInputStream(), StandardCharsets.UTF_8)) {
+
+ ZipEntry nextEntry = unzipFile.getNextEntry();
+ while (Objects.nonNull(nextEntry)) {
+ boolean isWaylines = (KmzFileProperties.FILE_DIR_FIRST + "/" + KmzFileProperties.FILE_DIR_SECOND_WAYLINES).equals(nextEntry.getName());
+ if (!isWaylines) {
+ nextEntry = unzipFile.getNextEntry();
+ continue;
+ }
+ SAXReader reader = new SAXReader();
+ Document document = reader.read(unzipFile);
+ if (!StandardCharsets.UTF_8.name().equals(document.getXMLEncoding())) {
+ throw new RuntimeException("The file encoding format is incorrect.");
+ }
+
+ Node droneNode = document.selectSingleNode("//" + KmzFileProperties.TAG_WPML_PREFIX + KmzFileProperties.TAG_DRONE_INFO);
+ Node payloadNode = document.selectSingleNode("//" + KmzFileProperties.TAG_WPML_PREFIX + KmzFileProperties.TAG_PAYLOAD_INFO);
+ if (Objects.isNull(droneNode) || Objects.isNull(payloadNode)) {
+ throw new RuntimeException("The file format is incorrect.");
+ }
+
+ String type = droneNode.valueOf(KmzFileProperties.TAG_WPML_PREFIX + KmzFileProperties.TAG_DRONE_ENUM_VALUE);
+ String subType = droneNode.valueOf(KmzFileProperties.TAG_WPML_PREFIX + KmzFileProperties.TAG_DRONE_SUB_ENUM_VALUE);
+ String payloadType = payloadNode.valueOf(KmzFileProperties.TAG_WPML_PREFIX + KmzFileProperties.TAG_PAYLOAD_ENUM_VALUE);
+ String payloadSubType = payloadNode.valueOf(KmzFileProperties.TAG_WPML_PREFIX + KmzFileProperties.TAG_PAYLOAD_SUB_ENUM_VALUE);
+ String templateId = document.valueOf("//" + KmzFileProperties.TAG_WPML_PREFIX + KmzFileProperties.TAG_TEMPLATE_ID);
+
+ if (!StringUtils.hasText(type) || !StringUtils.hasText(subType) ||
+ !StringUtils.hasText(payloadSubType) || !StringUtils.hasText(payloadType) ||
+ !StringUtils.hasText(templateId)) {
+ throw new RuntimeException("The file format is incorrect.");
+ }
+
+ return Optional.of(WaylineFileDTO.builder()
+ .droneModelKey(String.format("%s-%s-%s", DeviceDomainEnum.SUB_DEVICE.getVal(), type, subType))
+ .payloadModelKeys(List.of(String.format("%s-%s-%s",DeviceDomainEnum.PAYLOAD.getVal(), payloadType, payloadSubType)))
+ .objectKey(OssConfiguration.objectDirPrefix + File.separator + filename)
+ .name(filename.substring(0, filename.lastIndexOf(WAYLINE_FILE_SUFFIX)))
+ .sign(DigestUtils.md5DigestAsHex(file.getInputStream()))
+ .templateTypes(List.of(Integer.parseInt(templateId)))
+ .build());
+ }
+
+ } catch (IOException | DocumentException e) {
+ e.printStackTrace();
+ }
+ return Optional.empty();
+ }
+ /**
+ * Convert database entity objects into wayline data transfer object.
+ * @param entity
+ * @return
+ */
+ private WaylineFileDTO entityConvertToDTO(WaylineFileEntity entity) {
+ if (entity == null) {
+ return null;
+ }
+ return WaylineFileDTO.builder()
+ .droneModelKey(entity.getDroneModelKey())
+ .favorited(entity.getFavorited())
+ .name(entity.getName())
+ .payloadModelKeys(entity.getPayloadModelKeys() != null ?
+ Arrays.asList(entity.getPayloadModelKeys().split(",")) : null)
+ .templateTypes(Arrays.stream(entity.getTemplateTypes().split(","))
+ .map(Integer::parseInt)
+ .collect(Collectors.toList()))
+ .username(entity.getUsername())
+ .objectKey(entity.getObjectKey())
+ .sign(entity.getSign())
+ .updateTime(entity.getUpdateTime())
+ .waylineId(entity.getWaylineId())
+ .build();
+
+ }
+
+ /**
+ * Convert the received wayline object into a database entity object.
+ * @param file
+ * @return
+ */
+ private WaylineFileEntity dtoConvertToEntity(WaylineFileDTO file) {
+ WaylineFileEntity.WaylineFileEntityBuilder builder = WaylineFileEntity.builder();
+
+ if (file != null) {
+ builder.droneModelKey(file.getDroneModelKey())
+ .name(file.getName())
+ .username(file.getUsername())
+ .objectKey(file.getObjectKey())
+ // Separate multiple payload data with ",".
+ .payloadModelKeys(String.join(",", file.getPayloadModelKeys()))
+ .templateTypes(file.getTemplateTypes().stream()
+ .map(String::valueOf)
+ .collect(Collectors.joining(",")))
+ .favorited(file.getFavorited())
+ .sign(file.getSign())
+ .build();
+ }
+
+ return builder.build();
+ }
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1WaylineJobServiceImpl.java b/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1WaylineJobServiceImpl.java
new file mode 100644
index 0000000..ab50b7b
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1WaylineJobServiceImpl.java
@@ -0,0 +1,1160 @@
+/*
+ * 【机场1】从 dock2 原类 com.ard.sample.wayline.service.impl.WaylineJobServiceImpl 复制/独立适配(DJI Dock 1 / M30)。
+ * 与 dock2 原类同服务共存;本文件内的适配差异以「// 机场1:」内联注释标出。
+ */
+package com.ard.sample.dock1.wayline.service.impl;
+
+import com.ard.common.config.OssConfiguration;
+import com.ard.oss.service.impl.OssServiceContext;
+import com.ard.route.domain.route.ArdRouteInfo;
+import com.ard.route.service.IArdRouteInfoService;
+import com.ard.sample.config.DockConfiguration;
+import com.ard.sample.utils.TimestampUtils;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ard.common.core.domain.model.LoginUser;
+import com.ard.sample.common.error.CommonErrorEnum;
+import com.ard.sample.common.model.Pagination;
+import com.ard.sample.common.model.PaginationData;
+import com.ard.sample.common.model.ResponseResult;
+import com.ard.sample.component.mqtt.model.*;
+import com.ard.sample.component.mqtt.service.IMessageSenderService;
+import com.ard.common.core.redis.RedisConst;
+import com.ard.common.core.redis.RedisOpsUtils;
+import com.ard.sample.manage.model.dto.DeviceDTO;
+import com.ard.sample.manage.model.enums.DeviceDomainEnum;
+import com.ard.sample.manage.model.param.DeviceQueryParam;
+import com.ard.sample.manage.service.IDeviceService;
+import com.ard.sample.media.model.MediaFileCountDTO;
+import com.ard.sample.media.model.MediaMethodEnum;
+import com.ard.sample.media.service.IFileService;
+import com.ard.sample.wayline.mapper.IWaylineJobMapper;
+import com.ard.sample.wayline.model.dto.*;
+import com.ard.sample.wayline.model.entity.WaylineJobEntity;
+import com.ard.sample.wayline.model.enums.WaylineJobStatusEnum;
+import com.ard.sample.wayline.model.enums.WaylineMethodEnum;
+import com.ard.sample.wayline.model.enums.WaylineTaskTypeEnum;
+import com.ard.sample.wayline.model.param.CreateJobParam;
+import com.ard.sample.dock1.wayline.service.IWaylineJobService;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.ibatis.annotations.Param;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.integration.mqtt.support.MqttHeaders;
+import org.springframework.messaging.MessageHeaders;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Isolation;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import javax.annotation.Resource;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.sql.SQLException;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static com.ard.route.util.ToolUtils.kmzPath;
+import static com.ard.route.util.ToolUtils.subStringMinioUrlName;
+import com.ard.sample.dock1.wayline.model.dto.Dock1WaylineTaskCreateDTO;
+import com.ard.sample.wayline.service.impl.WaylineJobServiceImpl;
+
+/**
+ * @author sean
+ * @version 1.1
+ * @date 2022/6/1
+ */
+@Service
+@Transactional
+@Slf4j
+@SuppressWarnings("all")
+public class Dock1WaylineJobServiceImpl implements IWaylineJobService {
+
+ /**
+ * 机场1 设备类型(DJI Dock 1)。
+ */
+ private static final int DOCK1_DEVICE_TYPE = 1;
+
+ @Autowired
+ private IWaylineJobMapper mapper;
+
+ // @Autowired
+// private IWaylineFileService waylineFileService;
+ @Autowired
+ private TimestampUtils _timestampUtils;
+
+ @Autowired
+ private IArdRouteInfoService _ardRouteInfoService;
+
+ @Resource
+ private OssServiceContext _ossService;
+
+ @Autowired
+ private IDeviceService deviceService;
+
+ @Autowired
+ private IMessageSenderService messageSender;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private IFileService fileService;
+
+ /**
+ * 插入航路作业
+ *
+ * @param jobEntity 航路作业实体
+ * @return 航路作业DTO的Optional包装对象,如果插入失败则返回Optional.empty()
+ */
+ private Optional<WaylineJobDTO> insertWaylineJob(WaylineJobEntity jobEntity) {
+ int id = mapper.insert(jobEntity);
+ if (id <= 0) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(this.entity2Dto(jobEntity));
+ }
+
+ /**
+ * 创建航线任务
+ *
+ * @param param 任务参数
+ * @param username 用户名
+ * @param beginTime 开始时间
+ * @param endTime 结束时间
+ * @return Optional<WaylineJobDTO> 返回创建的航线任务对象,若创建失败则返回Optional.empty()
+ */
+ @Override
+ public Optional<WaylineJobDTO> createWaylineJob(CreateJobParam param, String username, Long beginTime, Long endTime) {
+ if (Objects.isNull(param)) {
+ return Optional.empty();
+ }
+ // Immediate tasks, allocating time on the backend.
+ WaylineJobEntity jobEntity = WaylineJobEntity.builder()
+ .name(param.getName())
+ .dockSn(param.getDockSn())
+ .fileId(param.getFileId())
+ .username(username)
+ .jobId(UUID.randomUUID().toString())
+ .beginTime(beginTime)
+ .endTime(endTime)
+ .status(WaylineJobStatusEnum.PENDING.getVal())
+ .taskType(param.getTaskType())
+ .waylineType(param.getWaylineType())
+ .outOfControlAction(param.getOutOfControlAction())
+ .rthAltitude(param.getRthAltitude())
+ .rthMode(param.getRth_mode())
+ .mediaCount(0)
+ .parentId(param.getParentId())
+ .loopSum(param.getLoopSum())
+ .build();
+
+ return insertWaylineJob(jobEntity);
+ }
+
+ /**
+ * 根据父作业ID创建新的作业
+ *
+ * @param parentId 父作业ID
+ * @return 新创建的作业的DTO对象,如果父作业不存在,则返回Optional.empty()
+ */
+ @Override
+ public Optional<WaylineJobDTO> createWaylineJobByParent(String parentId) {
+ Optional<WaylineJobDTO> parentJobOpt = this.getJobByJobId(parentId);
+ if (parentJobOpt.isEmpty()) {
+ return Optional.empty();
+ }
+ WaylineJobEntity jobEntity = this.dto2Entity(parentJobOpt.get());
+ jobEntity.setJobId(UUID.randomUUID().toString());
+ jobEntity.setErrorCode(null);
+ jobEntity.setCompletedTime(null);
+ jobEntity.setExecuteTime(null);
+ jobEntity.setStatus(WaylineJobStatusEnum.PENDING.getVal());
+ jobEntity.setParentId(parentId);
+
+ return this.insertWaylineJob(jobEntity);
+ }
+
+ /**
+ * 发布飞行任务
+ *
+ * @param param 任务参数
+ * @param loginUser 登录用户
+ * @return ResponseResult 返回结果
+ * @throws SQLException SQL异常
+ */
+ @Override
+ public ResponseResult publishFlightTask(CreateJobParam param, LoginUser loginUser) throws SQLException {
+ if (WaylineTaskTypeEnum.IMMEDIATE.getVal() == param.getTaskType()) {
+ param.setExecuteTime(System.currentTimeMillis());
+ }
+ if (param.getExecuteTime() < _timestampUtils.plusMinutesByMillis(System.currentTimeMillis(), 10) && param.getTaskType() == WaylineTaskTypeEnum.TIMED.getVal()) {
+ return ResponseResult.error(1234,"定时任务执行时间不能小于当前时间的后10分钟!");
+ }
+ // 机场1:共享 WaylineLoopTaskServiceImpl 在生成循环子任务时会固定下发 rth_mode=1,
+ // 该字段 Dock1(M30)的 flighttask_prepare 不支持。在共享调度器按机型分流之前,
+ // 机场1 不创建循环任务,避免向设备下发非法参数。
+ if (param.getTaskType() == WaylineTaskTypeEnum.LOOP.getVal()) {
+ return ResponseResult.error(314013003, "机场1暂不支持循环任务,请选择立即执行或定时任务!");
+ }
+ // 机场1:/dock1 接口只允许给 Dock1(domain=3, type=1)创建任务,
+ // 避免误把机场2/3 的 SN 传进来,导致任务落到错误链路。
+ if (!isDock1Device(param.getDockSn())) {
+ return ResponseResult.error(314013004, "该设备不属于机场1,请使用对应的机场接口操作!");
+ }
+ param.setRth_mode(1);
+ Optional<WaylineJobDTO> waylineJobOpt = this.createWaylineJob(param, loginUser.getUsername(),
+ param.getExecuteTime(), param.getExecuteTime());
+
+ if (waylineJobOpt.isEmpty()) {
+ return ResponseResult.error(314013000, "创建机场任务失败!");
+ }
+
+ WaylineJobDTO waylineJob = waylineJobOpt.get();
+
+ boolean isOnline = deviceService.checkDeviceOnline(waylineJob.getDockSn());
+ if (!isOnline) {
+ return ResponseResult.error(CommonErrorEnum.DEVICE_OFFLINE.getErrorMsg());
+ }
+
+ // get wayline file
+// Optional<WaylineFileDTO> waylineFile = waylineFileService.getWaylineByWaylineId(waylineJob.getFileId());
+// if (waylineFile.isEmpty()) {
+// return ResponseResult.error("机场任务航线不存在!");
+// }
+ // 换航线表
+ ArdRouteInfo ardRouteOpt = _ardRouteInfoService.selectArdRouteInfoById(waylineJob.getFileId());
+ if (ardRouteOpt == null || ardRouteOpt.getPath() == null || ardRouteOpt.getPath().isEmpty()) {
+ return ResponseResult.error(314013001, "机场任务航线不存在!");
+ }
+
+ // get file url
+// URL url = waylineFileService.getObjectUrl(waylineFile.get().getWaylineId());
+ URL url = null;
+ String minioUrl = subStringMinioUrlName(ardRouteOpt.getPath());
+ String md5 = "";
+ String httpUrl = "";
+ try (InputStream inputStream = _ossService.getObject(kmzPath, minioUrl)) {
+ md5 = DigestUtils.md5Hex(inputStream);
+ url = new URL(ardRouteOpt.getPath());
+ log.info("机场任务航线文件url地址转换前:{}", url);
+ httpUrl = url.toString().replace(OssConfiguration.endpoint, DockConfiguration.endpoint);
+ log.info("机场任务航线文件url地址转换后:{}", httpUrl);
+ log.info("机场任务航线文件md5值:{}", md5);
+ } catch (Exception e) {
+ log.error("机场任务航线文件地址错误!{}", e.getMessage());
+ return ResponseResult.error(314013002, "机场任务航线文件地址错误!" + e.getMessage());
+ }
+
+ log.info("机场任务-rth_mode的值:{}", waylineJob.getRthMode());
+
+ Dock1WaylineTaskCreateDTO flightTask = Dock1WaylineTaskCreateDTO.builder()
+ .flightId(waylineJob.getJobId())
+ .executeTime(waylineJob.getBeginTime().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())
+ .taskType(waylineJob.getTaskType())
+ // 机场1:C4 dock1(M30)无 wayline_precision_type,不下发
+ .rthAltitude(waylineJob.getRthAltitude())
+ .outOfControlAction(waylineJob.getOutOfControlAction())
+ // 机场1:C4 dock1(M30)无 rth_mode,不下发
+ .file(WaylineTaskFileDTO.builder()
+// .url(url.toString().replace("https://192.168.10.103:9001", "http://192.168.10.103:9011"))
+// .fingerprint(waylineFile.get().getSign())
+// .replace("https://", "http://")
+// .replace("9001", "9011")
+ .url(httpUrl)
+ .fingerprint(md5)
+ .build())
+// .simulateMission(WaylineSimulateMissionDTO.builder()
+// .isEnable(1)
+// .latitude(46.55819013988211)
+// .longitude(125.14652370145113)
+// .build())
+ .build();
+
+ String topic = TopicConst.THING_MODEL_PRE + TopicConst.PRODUCT +
+ waylineJob.getDockSn() + TopicConst.SERVICES_SUF;
+ CommonTopicResponse<Object> response = CommonTopicResponse.builder()
+ .tid(UUID.randomUUID().toString())
+ .bid(waylineJob.getJobId())
+ .timestamp(System.currentTimeMillis())
+ .data(flightTask)
+ .method(WaylineMethodEnum.FLIGHT_TASK_PREPARE.getMethod())
+ .build();
+
+ // 下发参数打印
+ try {
+ String json = objectMapper.writeValueAsString(response);
+ log.info("机场下发航线任务的参数response:{}", json);
+ } catch (Exception e) {
+ log.error("机场下发航线任务的参数response异常:{}", e.getMessage());
+ }
+
+ ServiceReply serviceReply = messageSender.publishWithReply(topic, response);
+
+ log.info("下发任务结果: {}", serviceReply.getResult());
+
+ if (ResponseResult.CODE_SUCCESS != serviceReply.getResult()) {
+ String errorMsg = CommonErrorEnum.getMsgByCode(serviceReply.getResult());
+ log.info("Prepare task ====> Error code: {},Error Msg:{}", serviceReply.getResult(), errorMsg);
+ this.updateJob(WaylineJobDTO.builder()
+ .jobId(waylineJob.getJobId())
+ .executeTime(LocalDateTime.now())
+ .status(WaylineJobStatusEnum.FAILED.getVal())
+ .completedTime(LocalDateTime.now())
+ .code(serviceReply.getResult()).build());
+ //return ResponseResult.error("Prepare task ====> Error code: " + serviceReply.getResult());
+ return ResponseResult.error(serviceReply.getResult(), errorMsg);
+ }
+
+ // 飞行命令先注释掉
+ // Issue an immediate task execution command.
+ if (WaylineTaskTypeEnum.IMMEDIATE.getVal() == waylineJob.getTaskType()) {
+ if (!executeFlightTask(waylineJob.getJobId())) {
+ return ResponseResult.error("无法执行作业。");
+ }
+ }
+
+ if (WaylineTaskTypeEnum.TIMED.getVal() == waylineJob.getTaskType()) {
+ boolean isAdd = RedisOpsUtils.zAdd(RedisConst.WAYLINE_JOB_TIMED_EXECUTE,
+ waylineJob.getDockSn() + RedisConst.DELIMITER + waylineJob.getJobId(),
+ waylineJob.getBeginTime().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
+ if (!isAdd) {
+ return ResponseResult.error("无法创建计划作业。");
+ }
+ }
+
+ return ResponseResult.success();
+ }
+
+ public ResponseResult publishFlightTaskPrepare(Optional<WaylineJobDTO> waylineJobOpt) throws SQLException {
+
+ if (waylineJobOpt.isEmpty()) {
+ return ResponseResult.error(314013000, "创建机场任务失败!");
+ }
+ WaylineJobDTO waylineJob = waylineJobOpt.get();
+ boolean isOnline = deviceService.checkDeviceOnline(waylineJob.getDockSn());
+ if (!isOnline) {
+ return ResponseResult.error("机场不在线!");
+ }
+ // 换航线表
+ ArdRouteInfo ardRouteOpt = _ardRouteInfoService.selectArdRouteInfoById(waylineJob.getFileId());
+ if (ardRouteOpt == null || ardRouteOpt.getPath() == null || ardRouteOpt.getPath().isEmpty()) {
+ return ResponseResult.error(314013001, "机场任务航线不存在!");
+ }
+ // get file url
+ URL url = null;
+ String minioUrl = subStringMinioUrlName(ardRouteOpt.getPath());
+ String md5 = "";
+ String httpUrl = "";
+ try (InputStream inputStream = _ossService.getObject(kmzPath, minioUrl)) {
+ md5 = DigestUtils.md5Hex(inputStream);
+ url = new URL(ardRouteOpt.getPath());
+ log.info("机场任务航线文件url地址转换前:{}", url);
+ httpUrl = url.toString().replace(OssConfiguration.endpoint, DockConfiguration.endpoint);
+ log.info("机场任务航线文件url地址转换后:{}", httpUrl);
+ log.info("机场任务航线文件md5值:{}", md5);
+ } catch (Exception e) {
+ log.error("机场任务航线文件地址错误!{}", e.getMessage());
+ return ResponseResult.error(314013002, "机场任务航线文件地址错误!" + e.getMessage());
+ }
+
+ Dock1WaylineTaskCreateDTO flightTask = Dock1WaylineTaskCreateDTO.builder()
+ .flightId(waylineJob.getJobId())
+ .executeTime(waylineJob.getBeginTime().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())
+ .taskType(waylineJob.getTaskType())
+ // 机场1:C4 dock1(M30)无 wayline_precision_type,不下发
+ .rthAltitude(waylineJob.getRthAltitude())
+ .outOfControlAction(waylineJob.getOutOfControlAction())
+ // 机场1:C4 dock1(M30)无 rth_mode,不下发
+ .file(WaylineTaskFileDTO.builder()
+ .url(httpUrl)
+// .fingerprint(waylineFile.get().getSign())
+ .fingerprint(md5)
+ .build())
+// .simulateMission(WaylineSimulateMissionDTO.builder()
+// .isEnable(1)
+// .latitude(46.558831038)
+// .longitude(125.148072319)
+// .build())
+ .build();
+
+ // 下发参数打印
+ try {
+ String json = objectMapper.writeValueAsString(flightTask);
+ log.info("机场下发循环任务子任务-定时任务的航线任务的参数flightTask:{}", json);
+ } catch (Exception e) {
+ log.error("机场下发循环任务子任务-定时任务的航线任务的参数flightTask异常:{}", e.getMessage());
+ }
+
+ String topic = TopicConst.THING_MODEL_PRE + TopicConst.PRODUCT +
+ waylineJob.getDockSn() + TopicConst.SERVICES_SUF;
+ CommonTopicResponse<Object> response = CommonTopicResponse.builder()
+ .tid(UUID.randomUUID().toString())
+ .bid(waylineJob.getJobId())
+ .timestamp(System.currentTimeMillis())
+ .data(flightTask)
+ .method(WaylineMethodEnum.FLIGHT_TASK_PREPARE.getMethod())
+ .build();
+
+ ServiceReply serviceReply = messageSender.publishWithReply(topic, response);
+
+ log.info("下发循环任务子任务-定时任务的结果: {}", serviceReply.getResult());
+ log.info("机场循环任务子任务-定时任务的航线文件url地址:{}", httpUrl);
+ log.info("机场循环任务子任务-定时任务的航线文件md5值:{}", md5);
+
+ if (ResponseResult.CODE_SUCCESS != serviceReply.getResult()) {
+ String errorMsg = CommonErrorEnum.getMsgByCode(serviceReply.getResult());
+ log.info("Prepare task ====> Error code: {},Error Msg: {}", serviceReply.getResult(), errorMsg);
+ this.updateJob(WaylineJobDTO.builder()
+ .jobId(waylineJob.getJobId())
+ .executeTime(LocalDateTime.now())
+ .status(WaylineJobStatusEnum.FAILED.getVal())
+ .completedTime(LocalDateTime.now())
+ .code(serviceReply.getResult()).build());
+// return ResponseResult.error("Prepare task ====> Error code: " + serviceReply.getResult());
+ return ResponseResult.error(serviceReply.getResult(), errorMsg);
+ }
+
+ // 飞行命令先注释掉
+ // Issue an immediate task execution command.
+// if (WaylineTaskTypeEnum.IMMEDIATE.getVal() == waylineJob.getTaskType()) {
+// if (!executeFlightTask(waylineJob.getJobId())) {
+// return ResponseResult.error("无法执行作业。");
+// }
+// }
+
+ if (WaylineTaskTypeEnum.TIMED.getVal() == waylineJob.getTaskType()) {
+ boolean isAdd = RedisOpsUtils.zAdd(RedisConst.WAYLINE_JOB_TIMED_EXECUTE,
+ waylineJob.getDockSn() + RedisConst.DELIMITER + waylineJob.getJobId(),
+ waylineJob.getBeginTime().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
+ if (!isAdd) {
+ return ResponseResult.error("无法创建计划作业。");
+ }
+ }
+
+ return ResponseResult.success();
+ }
+
+ /**
+ * 执行飞行任务
+ *
+ * @param jobId 任务ID
+ * @return 如果任务执行成功返回true,否则返回false
+ * @throws IllegalArgumentException 如果任务不存在
+ * @throws RuntimeException 如果设备处于离线状态
+ */
+ @Override
+ public Boolean executeFlightTask(String jobId) {
+ // get job
+ Optional<WaylineJobDTO> waylineJob = this.getJobByJobId(jobId);
+ if (waylineJob.isEmpty()) {
+ throw new IllegalArgumentException("计划任务不存在。");
+ }
+
+ boolean isOnline = deviceService.checkDeviceOnline(waylineJob.get().getDockSn());
+ if (!isOnline) {
+// throw new RuntimeException("Dock is offline.");
+ throw new RuntimeException(CommonErrorEnum.DEVICE_OFFLINE.getErrorMsg());
+ }
+
+ WaylineJobDTO job = waylineJob.get();
+ Dock1WaylineTaskCreateDTO flightTask = Dock1WaylineTaskCreateDTO.builder().flightId(jobId).build();
+ // flightTask中,只有任务id是有值的,其他字段不返回
+ String topic = TopicConst.THING_MODEL_PRE + TopicConst.PRODUCT +
+ job.getDockSn() + TopicConst.SERVICES_SUF;
+ CommonTopicResponse<Object> response = CommonTopicResponse.builder()
+ .tid(UUID.randomUUID().toString())
+ .bid(jobId)
+ .timestamp(System.currentTimeMillis())
+ .data(flightTask)
+ .method(WaylineMethodEnum.FLIGHT_TASK_EXECUTE.getMethod())
+ .build();
+
+ // 下发参数打印
+ try {
+ String json = objectMapper.writeValueAsString(response);
+ log.info("机场下发-执行飞行任务-的参数response:{}", json);
+ } catch (Exception e) {
+ log.error("机场下发-执行飞行任务-的参数response异常:{}", e.getMessage());
+ }
+
+ ServiceReply serviceReply = messageSender.publishWithReply(topic, response);
+
+ log.info("下发-执行飞行任务-结果: {}", serviceReply.getResult());
+
+ if (ResponseResult.CODE_SUCCESS != serviceReply.getResult()) {
+ String errorMsg = CommonErrorEnum.getMsgByCode(serviceReply.getResult());
+ log.info("Execute job ====> Error code: {},Error Msg:{}", serviceReply.getResult(), errorMsg);
+ this.updateJob(WaylineJobDTO.builder()
+ .jobId(jobId)
+ .executeTime(LocalDateTime.now())
+ .status(WaylineJobStatusEnum.FAILED.getVal())
+ .completedTime(LocalDateTime.now())
+ .code(serviceReply.getResult()).build());
+ return false;
+ }
+
+ this.updateJob(WaylineJobDTO.builder()
+ .jobId(jobId)
+ .executeTime(LocalDateTime.now())
+ .status(WaylineJobStatusEnum.IN_PROGRESS.getVal())
+ .build());
+ RedisOpsUtils.setWithExpire(RedisConst.WAYLINE_JOB_RUNNING_PREFIX + job.getDockSn(),
+ EventsReceiver.<WaylineTaskProgressReceiver>builder().bid(jobId).sn(job.getDockSn()).build(),
+ RedisConst.DEVICE_ALIVE_SECOND * RedisConst.DEVICE_ALIVE_SECOND);
+ return true;
+ }
+
+ /**
+ * 取消飞行任务
+ *
+ * @param jobIds 需要取消的任务ID集合
+ * @throws IllegalArgumentException 如果任务状态不正确,抛出非法参数异常
+ */
+ @Override
+ public ResponseResult cancelFlightTask(Collection<String> jobIds) {
+ if (CollectionUtils.isEmpty(jobIds)) {
+ return ResponseResult.error(98, "任务ID不能为空");
+ }
+ // 机场1:按机场分组,逐个下发取消,不能在第一组处理完后直接 return,
+ // 否则同一请求中包含多个机场(或多个 SN)时,后面的任务不会被取消。
+ Map<String, Set<String>> dockJobs = new LinkedHashMap<>();
+ for (String jobId : jobIds) {
+ Optional<WaylineJobDTO> jobOpt = this.getJobByJobId(jobId);
+ if (jobOpt.isEmpty()) {
+ return ResponseResult.error("任务不存在,jobId: " + jobId);
+ }
+ WaylineJobDTO job = jobOpt.get();
+ // 机场1:/dock1 接口只允许取消机场1(Dock1)的任务,避免通过 jobId 误取消机场2/3 的任务。
+ if (!isDock1Device(job.getDockSn())) {
+ return ResponseResult.error(314013004, "该任务不属于机场1,请使用对应的机场接口操作!");
+ }
+ // 循环主任务只是模板,真正的执行体是子任务:主任务本地置为已取消,执行中的子任务下发取消。
+ if (job.getTaskType() != null && job.getTaskType() == WaylineTaskTypeEnum.LOOP.getVal()) {
+ for (WaylineJobDTO child : this.getJobByJobParentId(jobId)) {
+ if (isJobEnded(child.getStatus())) {
+ continue;
+ }
+ if (Objects.equals(child.getStatus(), WaylineJobStatusEnum.IN_PROGRESS.getVal())) {
+ dockJobs.computeIfAbsent(child.getDockSn(), k -> new LinkedHashSet<>()).add(child.getJobId());
+ } else {
+ this.cancelJobLocally(child.getJobId(), child.getDockSn());
+ }
+ }
+ this.cancelJobLocally(jobId, job.getDockSn());
+ continue;
+ }
+ if (isJobEnded(job.getStatus())) {
+ continue;
+ }
+ dockJobs.computeIfAbsent(job.getDockSn(), k -> new LinkedHashSet<>()).add(jobId);
+ }
+
+ ResponseResult errorResult = null;
+ for (Map.Entry<String, Set<String>> entry : dockJobs.entrySet()) {
+ ResponseResult result = this.publishCancelTask(entry.getKey(), new ArrayList<>(entry.getValue()));
+ if (ResponseResult.CODE_SUCCESS != result.getCode() && errorResult == null) {
+ errorResult = result;
+ }
+ }
+ return errorResult == null ? ResponseResult.success() : errorResult;
+ }
+
+ /**
+ * 机场1:判断 SN 是否为 Dock1 机场设备(domain=3, type=1)。
+ *
+ * @param sn 设备序列号
+ * @return true 表示属于机场1
+ */
+ private boolean isDock1Device(String sn) {
+ if (!StringUtils.hasText(sn)) {
+ return false;
+ }
+ Optional<DeviceDTO> deviceOpt = deviceService.getDeviceBySn(sn);
+ return deviceOpt.isPresent()
+ && Objects.equals(DeviceDomainEnum.DOCK.getVal(), deviceOpt.get().getDomain())
+ && Objects.equals(DOCK1_DEVICE_TYPE, deviceOpt.get().getType());
+ }
+
+ /**
+ * 机场1:任务是否已经处于终态(成功/取消/失败/未知)。
+ */
+ private boolean isJobEnded(Integer status) {
+ if (status == null) {
+ return false;
+ }
+ return Boolean.TRUE.equals(WaylineJobStatusEnum.find(status).getEnd());
+ }
+
+ /**
+ * 机场1:只在本地把任务标记为已取消,并清理定时执行队列,不删除历史记录。
+ */
+ private void cancelJobLocally(String jobId, String dockSn) {
+ this.updateJob(WaylineJobDTO.builder()
+ .jobId(jobId)
+ .status(WaylineJobStatusEnum.CANCEL.getVal())
+ .completedTime(LocalDateTime.now())
+ .build());
+ if (dockSn != null) {
+ RedisOpsUtils.zRemove(RedisConst.WAYLINE_JOB_TIMED_EXECUTE,
+ dockSn + RedisConst.DELIMITER + jobId);
+ }
+ }
+
+// region 代码备份
+// @Override
+// public void cancelFlightTask(Collection<String> jobIds) {
+// List<WaylineJobDTO> waylineJobs = getJobsByConditions(jobIds, WaylineJobStatusEnum.PENDING);
+//
+//// Set<String> waylineJobIds = waylineJobs.stream().map(WaylineJobDTO::getJobId).collect(Collectors.toSet());
+//// // Check if the task status is correct.
+//// boolean isErr = !jobIds.removeAll(waylineJobIds) || !jobIds.isEmpty();
+//// if (isErr) {
+//// throw new IllegalArgumentException("任务开始后,无法取消。" + Arrays.toString(jobIds.toArray()));
+////// throw new IllegalArgumentException("These tasks have an incorrect status and cannot be canceled. "
+////// + Arrays.toString(jobIds.toArray()));
+//// }
+//
+// // Group job id by dock sn.
+// Map<String, List<String>> dockJobs = waylineJobs.stream()
+// .collect(Collectors.groupingBy(WaylineJobDTO::getDockSn,
+// Collectors.mapping(WaylineJobDTO::getJobId, Collectors.toList())));
+// dockJobs.forEach((dockSn, idList) -> this.publishCancelTask(dockSn, idList));
+//
+// }
+
+//@Override
+//public void publishCancelTask(String dockSn, List<String> jobIds) {
+//
+// boolean isOnline = deviceService.checkDeviceOnline(dockSn);
+// if (!isOnline) {
+// Integer errorCode = CommonErrorEnum.DEVICE_OFFLINE.getErrorCode();
+//
+// //return ResponseResult.error(errorCode, CommonErrorEnum.getMsgByCode(errorCode)));
+// throw new RuntimeException(CommonErrorEnum.DEVICE_OFFLINE.getErrorMsg());
+// }
+// String topic = TopicConst.THING_MODEL_PRE + TopicConst.PRODUCT + dockSn + TopicConst.SERVICES_SUF;
+//
+// CommonTopicResponse<Object> response = CommonTopicResponse.builder()
+// .tid(UUID.randomUUID().toString())
+// .bid(UUID.randomUUID().toString())
+// .timestamp(System.currentTimeMillis())
+// .data(Map.of(MapKeyConst.FLIGHT_IDS, jobIds))
+// .method(WaylineMethodEnum.FLIGHT_TASK_CANCEL.getMethod())
+// .build();
+//
+// ServiceReply serviceReply = messageSender.publishWithReply(topic, response);
+// if (ResponseResult.CODE_SUCCESS != serviceReply.getResult()) {
+// String errorMsg = CommonErrorEnum.getMsgByCode(serviceReply.getResult());
+// log.info("取消飞行任务失败 ====> 设备sn: {}, 错误码: {}, {}", dockSn, serviceReply.getResult(), errorMsg);
+// //throw new RuntimeException("Failed to cancel the wayline job of " + dockSn);
+// throw new RuntimeException("设备:" + dockSn + "取消飞行任务失败,错误码:" + serviceReply.getResult() + "," + errorMsg);
+// }
+//
+// for (String jobId : jobIds) {
+// this.updateJob(WaylineJobDTO.builder()
+// .jobId(jobId)
+// .status(WaylineJobStatusEnum.CANCEL.getVal())
+// .completedTime(LocalDateTime.now())
+// .build());
+// RedisOpsUtils.zRemove(RedisConst.WAYLINE_JOB_TIMED_EXECUTE, RedisConst.DELIMITER + dockSn + RedisConst.DELIMITER + jobId);
+// // 原流程是修改表中数据的status状态值,现在的精力是直接删除
+// this.deleteTask(jobId);
+// }
+//
+//}
+// endregion
+
+ /**
+ * 发布取消任务
+ *
+ * @param dockSn 设备序列号
+ * @param jobIds 任务ID列表
+ * @throws RuntimeException 如果设备离线或取消任务失败,则抛出运行时异常
+ */
+ @Override
+ public ResponseResult publishCancelTask(String dockSn, List<String> jobIds) {
+ if (CollectionUtils.isEmpty(jobIds)) {
+ return ResponseResult.success();
+ }
+ for (String jobId : jobIds) {
+ Optional<WaylineJobDTO> waylineJobDTO = this.getJobByJobId(jobId);
+ if (waylineJobDTO.isEmpty()) {
+ return ResponseResult.error("任务不存在,jobId: " + jobId);
+ }
+ }
+ boolean isOnline = deviceService.checkDeviceOnline(dockSn);
+ if (!isOnline) {
+ Integer errorCode = CommonErrorEnum.DEVICE_OFFLINE.getErrorCode();
+ return ResponseResult.error(errorCode, CommonErrorEnum.getMsgByCode(errorCode));
+ }
+ String topic = TopicConst.THING_MODEL_PRE + TopicConst.PRODUCT + dockSn + TopicConst.SERVICES_SUF;
+
+ CommonTopicResponse<Object> response = CommonTopicResponse.builder()
+ .tid(UUID.randomUUID().toString())
+ .bid(UUID.randomUUID().toString())
+ .timestamp(System.currentTimeMillis())
+ .data(Map.of(MapKeyConst.FLIGHT_IDS, jobIds))
+ .method(WaylineMethodEnum.FLIGHT_TASK_CANCEL.getMethod())
+ .build();
+
+ ServiceReply serviceReply = messageSender.publishWithReply(topic, response);
+ if (ResponseResult.CODE_SUCCESS != serviceReply.getResult()) {
+ String errorMsg = CommonErrorEnum.getMsgByCode(serviceReply.getResult());
+ log.info("取消飞行任务失败 ====> 设备sn: {}, 错误码: {}, {}", dockSn, serviceReply.getResult(), errorMsg);
+ return ResponseResult.error(serviceReply.getResult(), errorMsg);
+ }
+
+ // 机场1:取消成功后只更新状态并清理定时队列,保留历史记录,避免“取消”等同于“删除”。
+ for (String jobId : jobIds) {
+ this.cancelJobLocally(jobId, dockSn);
+ }
+ return ResponseResult.success();
+ }
+
+ @Override
+ public void deleteTask(String jobid) {
+ mapper.delete(new LambdaQueryWrapper<WaylineJobEntity>()
+ .eq(WaylineJobEntity::getJobId, jobid));
+ }
+
+ /**
+ * 根据条件查询作业列表
+ *
+ * @param jobIds 作业ID集合
+ * @param status 作业状态枚举
+ * @return 满足条件的作业DTO列表
+ */
+ @Override
+ public List<WaylineJobDTO> getJobsByConditions(Collection<String> jobIds, WaylineJobStatusEnum status) {
+ return mapper.selectList(
+ new LambdaQueryWrapper<WaylineJobEntity>()
+ // 机场1:原实现误用 ne(取的是“非该状态”的任务),修正为 eq。
+ .eq(Objects.nonNull(status), WaylineJobEntity::getStatus, status.getVal())
+ .and(!CollectionUtils.isEmpty(jobIds),
+ wrapper -> jobIds.forEach(id -> wrapper.eq(WaylineJobEntity::getJobId, id).or())))
+ .stream()
+ .map(this::entity2Dto)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * 根据任务ID获取任务详情
+ *
+ * @param jobId 任务ID
+ * @return Optional<WaylineJobDTO> 包含任务详情的Optional对象,如果不存在则返回空的Optional
+ */
+ @Override
+ public Optional<WaylineJobDTO> getJobByJobId(String jobId) {
+ WaylineJobEntity jobEntity = mapper.selectOne(
+ new LambdaQueryWrapper<WaylineJobEntity>()
+ .eq(WaylineJobEntity::getJobId, jobId));
+ return Optional.ofNullable(entity2Dto(jobEntity));
+ }
+
+ public List<WaylineJobDTO> getJobByJobParentId(String parentId) {
+ List<WaylineJobEntity> jobEntities = mapper.selectList(
+ new LambdaQueryWrapper<WaylineJobEntity>()
+ .eq(WaylineJobEntity::getParentId, parentId));
+ if (CollectionUtils.isEmpty(jobEntities)) {
+ return Collections.emptyList(); // 返回不可变空列表,更安全
+ }
+ return jobEntities.stream()
+ .map(this::entity2Dto) // 调用原有的单个转换方法
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * 更新作业信息
+ *
+ * @param dto 作业信息传输对象
+ * @return 更新成功返回true,否则返回false
+ */
+ @Override
+ public Boolean updateJob(WaylineJobDTO dto) {
+ return mapper.update(this.dto2Entity(dto),
+ new LambdaUpdateWrapper<WaylineJobEntity>()
+ .eq(WaylineJobEntity::getJobId, dto.getJobId())) > 0;
+ }
+
+ /**
+ * 根据工作区ID获取工作线任务分页数据
+ *
+ * @param page 页码,从1开始
+ * @param pageSize 每页显示条数
+ * @return PaginationData 包含工作线任务分页数据的对象
+ */
+ @Override
+ public PaginationData<WaylineJobDTO> getJobsByWorkspaceId(long page, long pageSize) {
+ Page<WaylineJobEntity> pageData = mapper.selectPage(
+ new Page<WaylineJobEntity>(page, pageSize),
+ new LambdaQueryWrapper<WaylineJobEntity>()
+ .orderByDesc(WaylineJobEntity::getId));
+ List<WaylineJobDTO> records = pageData.getRecords()
+ .stream()
+ .map(this::entity2Dto)
+ .collect(Collectors.toList());
+
+ return new PaginationData<WaylineJobDTO>(records, new Pagination(pageData));
+ }
+
+ /**
+ * 根据条件筛选获取任务分页数据
+ *
+ * @param page 页码,从1开始
+ * @param pageSize 每页显示条数
+ * @return PaginationData 包含工作线任务分页数据的对象
+ */
+ @Override
+ public PaginationData<WaylineJobDTO> getJobsList(long page, long pageSize, Integer status,
+ Long startTime, Long endTime, Integer taskType, String name, Boolean isComplete) {
+ // 机场1:只查询机场1(Dock1)设备下的任务,避免 /dock1 接口返回机场2/3 的任务。
+ List<String> dock1SnList = deviceService.getDevicesByParams(DeviceQueryParam.builder()
+ .deviceType(DOCK1_DEVICE_TYPE)
+ .domains(Collections.singletonList(DeviceDomainEnum.DOCK.getVal()))
+ .build())
+ .stream()
+ .map(DeviceDTO::getDeviceSn)
+ .collect(Collectors.toList());
+ if (CollectionUtils.isEmpty(dock1SnList)) {
+ Page<WaylineJobEntity> emptyPage = new Page<>(page, pageSize);
+ return new PaginationData<>(Collections.emptyList(), new Pagination(emptyPage));
+ }
+ Page<WaylineJobEntity> pageData = mapper.selectPage(
+ new Page<WaylineJobEntity>(page, pageSize),
+ new LambdaQueryWrapper<WaylineJobEntity>()
+ .in(WaylineJobEntity::getDockSn, dock1SnList)
+ .isNull(WaylineJobEntity::getParentId)
+ // 实际开始时间筛选,允许为空,为空则不进行筛选
+ .ge(startTime != null, WaylineJobEntity::getBeginTime, startTime)
+ .le(endTime != null, WaylineJobEntity::getBeginTime, endTime)
+ // 任务类型筛选,允许为空,为空则不进行筛选
+ .eq(taskType != null, WaylineJobEntity::getTaskType, taskType)
+ // 任务名称筛选,允许为空,为空则不进行筛选;....模糊查询
+ .like(name != null, WaylineJobEntity::getName, "%" + name + "%")
+ // 根据是否完成筛选,允许为空,为空则不进行筛选
+ .eq(isComplete != null && isComplete, WaylineJobEntity::getStatus, 3)
+ .ne(isComplete != null && !isComplete, WaylineJobEntity::getStatus, 3)
+ // 任务状态筛选,允许为空,为空则不进行筛选
+ .eq(status != null, WaylineJobEntity::getStatus, status)
+ .orderByDesc(WaylineJobEntity::getBeginTime));
+ List<WaylineJobDTO> records = pageData.getRecords()
+ .stream()
+ .map(this::entity2Dto)
+ .peek(job -> {
+ List<WaylineJobDTO> childOpt = this.getJobByJobParentId(job.getJobId());
+ job.setChildren(childOpt);
+ })
+ .collect(Collectors.toList());
+
+ return new PaginationData<WaylineJobDTO>(records, new Pagination(pageData));
+ }
+
+ public List<WaylineJobEntity> selectList(Long taskType, Long executeTime, String parentId) {
+ return mapper.selectList(new LambdaQueryWrapper<WaylineJobEntity>()
+ .eq(taskType != null, WaylineJobEntity::getTaskType, taskType)
+ .ne(WaylineJobEntity::getStatus, WaylineJobStatusEnum.SUCCESS.getVal())
+ .eq(executeTime != null, WaylineJobEntity::getBeginTime, executeTime)
+ .eq(parentId != null, WaylineJobEntity::getParentId, parentId)
+ .ne(WaylineJobEntity::getStatus, 3)
+ .orderByDesc(WaylineJobEntity::getId));
+ }
+
+ /**
+ * 获取所有待定状态的定时任务,未执行即将要执行的。
+ */
+ public List<WaylineJobEntity> selectScheduledTaskList() {
+ // "1":"定时任务"
+ return mapper.selectList(new LambdaQueryWrapper<WaylineJobEntity>()
+ .eq(WaylineJobEntity::getTaskType, 1)
+ .eq(WaylineJobEntity::getStatus, WaylineJobStatusEnum.PENDING.getVal())
+ .gt(WaylineJobEntity::getBeginTime, System.currentTimeMillis())
+ .orderByDesc(WaylineJobEntity::getBeginTime));
+ }
+
+
+ /**
+ * 根据航班任务ID获取航班任务资源
+ *
+ * @param receiver 接收到的消息对象
+ * @param headers 消息头信息
+ * @Override 覆盖父类方法
+ * @ServiceActivator(inputChannel = ChannelName.INBOUND_REQUESTS_FLIGHT_TASK_RESOURCE_GET, outputChannel = ChannelName.OUTBOUND)
+ * 指定输入通道和输出通道
+ * @Transactional(isolation = Isolation.READ_UNCOMMITTED)
+ * 设置事务隔离级别为未提交读
+ */
+ @Override
+ // 机场1:不复制原 ServiceActivator(inputChannel = ChannelName.INBOUND_REQUESTS_FLIGHT_TASK_RESOURCE_GET),消费职责由共享层原类承担
+ @Transactional(isolation = Isolation.READ_UNCOMMITTED)
+ public void flightTaskResourceGet(CommonTopicReceiver receiver, MessageHeaders headers) {
+ Map<String, String> jobIdMap = objectMapper.convertValue(receiver.getData(), new TypeReference<Map<String, String>>() {
+ });
+ String jobId = jobIdMap.get(MapKeyConst.FLIGHT_ID);
+
+ log.info("机场通过flightTaskResourceGet获取航线文件地址,jobId: " + jobId);
+
+ CommonTopicResponse.CommonTopicResponseBuilder<RequestsReply> builder = CommonTopicResponse.<RequestsReply>builder()
+ .tid(receiver.getTid())
+ .bid(receiver.getBid())
+ .method(RequestsMethodEnum.FLIGHT_TASK_RESOURCE_GET.getMethod())
+ .timestamp(System.currentTimeMillis());
+
+ String topic = headers.get(MqttHeaders.RECEIVED_TOPIC).toString() + TopicConst._REPLY_SUF;
+
+ DeviceDTO device = (DeviceDTO) RedisOpsUtils.get(RedisConst.DEVICE_ONLINE_PREFIX + receiver.getGateway());
+ Optional<WaylineJobDTO> waylineJobOpt = this.getJobByJobId(jobId);
+ if (waylineJobOpt.isEmpty()) {
+ builder.data(RequestsReply.error(CommonErrorEnum.ILLEGAL_ARGUMENT));
+ messageSender.publish(topic, builder.build());
+ return;
+ }
+
+ WaylineJobDTO waylineJob = waylineJobOpt.get();
+
+ // get wayline file
+// Optional<WaylineFileDTO> waylineFile = waylineFileService.getWaylineByWaylineId(waylineJob.getFileId());
+// if (waylineFile.isEmpty()) {
+// builder.data(RequestsReply.error(CommonErrorEnum.ILLEGAL_ARGUMENT));
+// messageSender.publish(topic, builder.build());
+// return;
+// }
+ // 修改航线文件名称,从ard_route_info航线表中取;
+ // 换航线表
+ ArdRouteInfo ardRouteOpt = _ardRouteInfoService.selectArdRouteInfoById(waylineJob.getFileId());
+ if (ardRouteOpt == null) {
+ builder.data(RequestsReply.error(CommonErrorEnum.ILLEGAL_ARGUMENT));
+ messageSender.publish(topic, builder.build());
+ return;
+ }
+
+ // get file url
+ URL url = null;
+ String minioUrl = subStringMinioUrlName(ardRouteOpt.getPath());
+ String md5 = "";
+ String httpUrl = "";
+ try (InputStream inputStream = _ossService.getObject(kmzPath, minioUrl)) {
+ md5 = DigestUtils.md5Hex(inputStream);
+ url = new URL(ardRouteOpt.getPath());
+
+ log.info("机场通过flightTaskResourceGet获取航线文件地址转换前:{}", url);
+ httpUrl = url.toString().replace(OssConfiguration.endpoint, DockConfiguration.endpoint);
+ log.info("机场通过flightTaskResourceGet获取航线文件地址转换后:{}", httpUrl);
+
+ log.info("机场通过flightTaskResourceGet获取航线文件地址,jobId: " + jobId + " 返回信息为url:" + httpUrl + " md5:" + md5);
+// }
+// try {
+// url = waylineFileService.getObjectUrl(waylineFile.get().getWaylineId());
+// url = new URL(ardRouteOpt.getUpPath());
+
+// Map<String, Object> jsonResourceGet = new HashMap<>();
+// jsonResourceGet.put("url", url.toString().replace("https//192.168.10.103:9001", "http//192.168.10.103:9002"));
+// jsonResourceGet.put("fingerprint", md5);
+// .replace("https://", "http://")
+// .replace("9001", "9011")
+
+ builder.data(RequestsReply.success(Dock1WaylineTaskCreateDTO.builder()
+ .file(WaylineTaskFileDTO.builder()
+ .url(httpUrl)
+ .fingerprint(md5)
+ .build())
+ .build()));
+
+ } catch (NullPointerException | IOException e) {
+ e.printStackTrace();
+ builder.data(RequestsReply.error(CommonErrorEnum.ILLEGAL_ARGUMENT));
+ messageSender.publish(topic, builder.build());
+ return;
+ }
+
+ // 下发参数打印
+ try {
+ String json = objectMapper.writeValueAsString(builder.build());
+ log.info("机场通过flighttask_resource_get获取航线文件的参数flightTask:{}", json);
+ } catch (Exception e) {
+ log.error("机场通过flighttask_resource_get获取航线文件的参数flightTask异常:{}", e.getMessage());
+ }
+
+ messageSender.publish(topic, builder.build());
+
+ }
+
+ /**
+ * 上传媒体文件的最高优先级
+ *
+ * @param jobId 作业ID
+ * @throws RuntimeException 如果作业ID不合法或设置优先级失败
+ */
+ @Override
+ public void uploadMediaHighestPriority(String jobId) {
+ Optional<WaylineJobDTO> jobOpt = getJobByJobId(jobId);
+ if (jobOpt.isEmpty()) {
+ throw new RuntimeException(CommonErrorEnum.ILLEGAL_ARGUMENT.getErrorMsg());
+ }
+ // 机场1:只允许对 Dock1 的任务设置媒体上传优先级。
+ if (!isDock1Device(jobOpt.get().getDockSn())) {
+ throw new RuntimeException("该任务不属于机场1,请使用对应的机场接口操作!");
+ }
+
+ String dockSn = jobOpt.get().getDockSn();
+ String key = RedisConst.MEDIA_HIGHEST_PRIORITY_PREFIX + dockSn;
+ if (RedisOpsUtils.checkExist(key) &&
+ jobId.equals(((MediaFileCountDTO) RedisOpsUtils.get(key)).getJobId())) {
+ return;
+ }
+
+ ServiceReply reply = messageSender.publishWithReply(TopicConst.THING_MODEL_PRE + TopicConst.PRODUCT + dockSn + TopicConst.SERVICES_SUF,
+ CommonTopicResponse.builder()
+ .tid(UUID.randomUUID().toString())
+ .bid(UUID.randomUUID().toString())
+ .timestamp(System.currentTimeMillis())
+ .method(MediaMethodEnum.UPLOAD_FLIGHT_TASK_MEDIA_PRIORITIZE.getMethod())
+ .data(Map.of(MapKeyConst.FLIGHT_ID, jobId))
+ .build());
+ if (ResponseResult.CODE_SUCCESS != reply.getResult()) {
+ String errorMsg = CommonErrorEnum.getMsgByCode(reply.getResult());
+ throw new RuntimeException("未能设置媒体作业上传优先级. Error Code: " + reply.getResult() + ", Error Msg: " + errorMsg);
+ }
+ }
+
+ /**
+ * 将WaylineJobDTO对象转换为WaylineJobEntity对象
+ *
+ * @param dto WaylineJobDTO对象
+ * @return WaylineJobEntity对象
+ */
+ private WaylineJobEntity dto2Entity(WaylineJobDTO dto) {
+ WaylineJobEntity.WaylineJobEntityBuilder builder = WaylineJobEntity.builder();
+ if (dto == null) {
+ return builder.build();
+ }
+ if (Objects.nonNull(dto.getBeginTime())) {
+ builder.beginTime(dto.getBeginTime().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
+ }
+ if (Objects.nonNull(dto.getEndTime())) {
+ builder.endTime(dto.getEndTime().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
+ }
+ if (Objects.nonNull(dto.getExecuteTime())) {
+ builder.executeTime(dto.getExecuteTime().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
+ }
+ if (Objects.nonNull(dto.getCompletedTime())) {
+ builder.completedTime(dto.getCompletedTime().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
+ }
+ return builder.status(dto.getStatus())
+ .mediaCount(dto.getMediaCount())
+ .name(dto.getJobName())
+ .errorCode(dto.getCode())
+ .jobId(dto.getJobId())
+ .fileId(dto.getFileId())
+ .dockSn(dto.getDockSn())
+ .taskType(dto.getTaskType())
+ .waylineType(dto.getWaylineType())
+ .username(dto.getUsername())
+ .rthAltitude(dto.getRthAltitude())
+ .outOfControlAction(dto.getOutOfControlAction())
+ .parentId(dto.getParentId())
+ .loopSum(dto.getLoopSum())
+ .build();
+ }
+
+ /**
+ * 将实体对象转换为数据传输对象
+ *
+ * @param entity WaylineJobEntity 实体对象
+ * @return WaylineJobDTO 数据传输对象
+ */
+ private WaylineJobDTO entity2Dto(WaylineJobEntity entity) {
+ if (entity == null) {
+ return null;
+ }
+ // 修改航线文件名称,从ard_route_info航线表中取;
+ String fileId = entity.getFileId();
+ String fileName = "";
+ try {
+ if (fileId != null) {
+ ArdRouteInfo ardRouteInfo = _ardRouteInfoService.selectArdRouteInfoById(fileId);
+ if (ardRouteInfo != null) {
+ fileName = Optional.ofNullable(ardRouteInfo.getName()).orElse("");
+ }
+ }
+ } catch (Exception e) {
+ log.info("获取航线名称失败,fileId:{},异常原因:{}", fileId, e.getMessage());
+ fileName = "";
+ }
+ WaylineJobDTO.WaylineJobDTOBuilder builder = WaylineJobDTO.builder()
+ .jobId(entity.getJobId())
+ .jobName(entity.getName())
+ .fileId(entity.getFileId())
+// .fileName(waylineFileService.getWaylineByWaylineId(entity.getFileId())
+// .orElse(WaylineFileDTO.builder().build()).getName())
+ .fileName(fileName)
+ .dockSn(entity.getDockSn())
+ .dockName(deviceService.getDeviceBySn(entity.getDockSn())
+ .orElse(DeviceDTO.builder().build()).getNickname())
+ .username(entity.getUsername())
+ .status(WaylineJobStatusEnum.IN_PROGRESS.getVal() == entity.getStatus() &&
+ RedisOpsUtils.checkExist(RedisConst.WAYLINE_JOB_PAUSED_PREFIX + entity.getJobId()) ?
+ WaylineJobStatusEnum.PAUSED.getVal() : entity.getStatus())
+ .code(entity.getErrorCode())
+ .beginTime(LocalDateTime.ofInstant(Instant.ofEpochMilli(entity.getBeginTime()), ZoneId.systemDefault()))
+ .endTime(Objects.nonNull(entity.getEndTime()) ?
+ LocalDateTime.ofInstant(Instant.ofEpochMilli(entity.getEndTime()), ZoneId.systemDefault()) : null)
+ .executeTime(Objects.nonNull(entity.getExecuteTime()) ?
+ LocalDateTime.ofInstant(Instant.ofEpochMilli(entity.getExecuteTime()), ZoneId.systemDefault()) : null)
+ .completedTime(WaylineJobStatusEnum.find(entity.getStatus()).getEnd() ?
+ LocalDateTime.ofInstant(Instant.ofEpochMilli(entity.getUpdateTime()), ZoneId.systemDefault()) : null)
+ .taskType(entity.getTaskType())
+ .waylineType(entity.getWaylineType())
+ .rthAltitude(entity.getRthAltitude())
+ .rthMode(entity.getRthMode())
+ .outOfControlAction(entity.getOutOfControlAction())
+ .mediaCount(entity.getMediaCount())
+ .loopSum(entity.getLoopSum());
+
+
+ if (Objects.nonNull(entity.getEndTime())) {
+ builder.endTime(LocalDateTime.ofInstant(Instant.ofEpochMilli(entity.getEndTime()), ZoneId.systemDefault()));
+ }
+ if (WaylineJobStatusEnum.IN_PROGRESS.getVal() == entity.getStatus() && RedisOpsUtils.getExpire(entity.getJobId()) > 0) {
+ EventsReceiver<WaylineTaskProgressReceiver> taskProgress = (EventsReceiver<WaylineTaskProgressReceiver>) RedisOpsUtils.get(RedisConst.WAYLINE_JOB_RUNNING_PREFIX + entity.getDockSn());
+ if (Objects.nonNull(taskProgress.getOutput()) && Objects.nonNull(taskProgress.getOutput().getProgress())) {
+ builder.progress(taskProgress.getOutput().getProgress().getPercent());
+ }
+ }
+
+ if (entity.getMediaCount() == 0) {
+ return builder.build();
+ }
+
+ // sync the number of media files
+ String key = RedisConst.MEDIA_HIGHEST_PRIORITY_PREFIX + entity.getDockSn();
+ String countKey = RedisConst.MEDIA_FILE_PREFIX + entity.getDockSn();
+ Object mediaFileCount = RedisOpsUtils.hashGet(countKey, entity.getJobId());
+ if (Objects.nonNull(mediaFileCount)) {
+ builder.uploadedCount(((MediaFileCountDTO) mediaFileCount).getUploadedCount())
+ .uploading(RedisOpsUtils.checkExist(key) && entity.getJobId().equals(((MediaFileCountDTO) RedisOpsUtils.get(key)).getJobId()));
+ return builder.build();
+ }
+
+ int uploadedSize = fileService.getFilesByWorkspaceAndJobId(entity.getJobId()).size();
+ // All media for this job have been uploaded.
+ if (uploadedSize >= entity.getMediaCount()) {
+ return builder.uploadedCount(uploadedSize).build();
+ }
+ RedisOpsUtils.hashSet(countKey, entity.getJobId(),
+ MediaFileCountDTO.builder()
+ .jobId(entity.getJobId())
+ .mediaCount(entity.getMediaCount())
+ .uploadedCount(uploadedSize).build());
+ return builder.build();
+ }
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/manage/model/enums/DockModelEnum.java b/ard-sample/src/main/java/com/ard/sample/manage/model/enums/DockModelEnum.java
new file mode 100644
index 0000000..7b0a9fa
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/manage/model/enums/DockModelEnum.java
@@ -0,0 +1,64 @@
+package com.ard.sample.manage.model.enums;
+
+import lombok.Getter;
+
+/**
+ * 【机场1】机场机型代际枚举(Dock1 适配共享层新增)。
+ *
+ * <p>判据为 device_model_key(三段 {@code domain-type-sub_type},落库逻辑见
+ * DeviceServiceImpl.bindDevice2Entity)中的 type 段:
+ * 机场设备(domain=3)Dock1=1 / Dock2=2;飞行器(domain=0)M30 系列=67 / M3D 系列=91。
+ *
+ * @author ard
+ * @date 2026/10/16
+ */
+@Getter
+public enum DockModelEnum {
+
+ /** 机场1(DJI Dock 1):飞行器为 M30/M30T(type=67,sub_type 0=M30 / 1=M30T) */
+ DOCK1(1, 67),
+
+ /** 机场2(DJI Dock 2):飞行器为 M3D/M3TD(type=91,sub_type 0=M3D / 1=M3TD) */
+ DOCK2(2, 91);
+
+ /** 机场设备(domain=3)的 device type */
+ private final int dockType;
+
+ /** 飞行器(domain=0)的 device type */
+ private final int droneType;
+
+ DockModelEnum(int dockType, int droneType) {
+ this.dockType = dockType;
+ this.droneType = droneType;
+ }
+
+ /**
+ * 按机场设备的 device type 解析机型。
+ *
+ * @param type 机场设备 type(dock1=1 / dock2=2)
+ * @return 对应机型;未知返回 null
+ */
+ public static DockModelEnum fromDockType(int type) {
+ for (DockModelEnum model : values()) {
+ if (model.dockType == type) {
+ return model;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 按飞行器的 device type 解析机型。
+ *
+ * @param type 飞行器 type(M30 系列=67 / M3D 系列=91)
+ * @return 对应机型;未知返回 null
+ */
+ public static DockModelEnum fromDroneType(int type) {
+ for (DockModelEnum model : values()) {
+ if (model.droneType == type) {
+ return model;
+ }
+ }
+ return null;
+ }
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/manage/service/impl/DeviceServiceImpl.java b/ard-sample/src/main/java/com/ard/sample/manage/service/impl/DeviceServiceImpl.java
index b768415..a607a8b 100644
--- a/ard-sample/src/main/java/com/ard/sample/manage/service/impl/DeviceServiceImpl.java
+++ b/ard-sample/src/main/java/com/ard/sample/manage/service/impl/DeviceServiceImpl.java
@@ -44,7 +44,7 @@
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.formula.functions.T;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.dao.DuplicateKeyException;
import org.springframework.integration.annotation.ServiceActivator;
import org.springframework.integration.mqtt.support.MqttHeaders;
import org.springframework.messaging.Message;
@@ -116,9 +116,9 @@
@Resource
private AuthorizeService authorizeService;
+ // 机场1:OSD 按机型分发(原为 @Qualifier("gatewayOSDServiceImpl") ITSAService tsaService,直连 dock2 链头)
@Autowired
- @Qualifier("gatewayOSDServiceImpl")
- private ITSAService tsaService;
+ private DockOsdDispatcher dockOsdDispatcher;
@Resource
private ISysDeptService sysDeptService;
@@ -359,8 +359,13 @@
return;
}
- DeviceDTO subDevice =
- getDevicesByParams(DeviceQueryParam.builder().deviceSn(gateway.getChildDeviceSn()).build()).get(0);
+ List<DeviceDTO> subDevices =
+ getDevicesByParams(DeviceQueryParam.builder().deviceSn(gateway.getChildDeviceSn()).build());
+ if (CollectionUtils.isEmpty(subDevices)) {
+ log.warn("网关 {} 的子设备 {} 不存在,跳过拓扑拼接", gateway.getDeviceSn(), gateway.getChildDeviceSn());
+ return;
+ }
+ DeviceDTO subDevice = subDevices.get(0);
subDevice.setStatus(RedisOpsUtils.checkExist(RedisConst.DEVICE_ONLINE_PREFIX + subDevice.getDeviceSn()));
gateway.setChildren(subDevice);
@@ -410,8 +415,13 @@
if (!StringUtils.hasText(gateway.getChildDeviceSn())) {
return;
}
- DeviceDTO subDevice =
- getDevicesByParams(DeviceQueryParam.builder().deviceSn(gateway.getChildDeviceSn()).build()).get(0);
+ List<DeviceDTO> subDevices =
+ getDevicesByParams(DeviceQueryParam.builder().deviceSn(gateway.getChildDeviceSn()).build());
+ if (CollectionUtils.isEmpty(subDevices)) {
+ log.warn("网关 {} 的子设备 {} 不存在,跳过拓扑拼接", gateway.getDeviceSn(), gateway.getChildDeviceSn());
+ return;
+ }
+ DeviceDTO subDevice = subDevices.get(0);
subDevice.setStatus(RedisOpsUtils.checkExist(RedisConst.DEVICE_ONLINE_PREFIX + subDevice.getDeviceSn()));
gateway.setChildren(subDevice);
}
@@ -437,7 +447,7 @@
})
.eq(param.getBoundStatus() != null, DeviceEntity::getBoundStatus,
param.getBoundStatus())
- .like(param.getNickname()!=null, DeviceEntity::getNickname, "%" + param.getNickname() + "%")
+ .like(StringUtils.hasText(param.getNickname()), DeviceEntity::getNickname, "%" + param.getNickname() + "%")
.orderBy(param.isOrderBy(),
param.isAsc(), DeviceEntity::getId))
.stream()
@@ -662,7 +672,8 @@
.getValueAndUserType(UserTypeEnum.WEB.getVal());
- tsaService.handleOSD(receiver, device, webSessions, wsMessage);
+ // 机场1:OSD 按机型分发(dock1=67 → dock1 链;其余透传 dock2 原链)
+ dockOsdDispatcher.dispatch(receiver, device, webSessions, wsMessage);
} catch (NullPointerException e) {
log.error("处理OSD消息NPE,headers={}, payload={}",
@@ -746,7 +757,22 @@
}
// 调用验证方法
- return mapper.insert(entity) > 0 ? Optional.of(entity) : Optional.empty();
+ try {
+ return mapper.insert(entity) > 0 ? Optional.of(entity) : Optional.empty();
+ } catch (DuplicateKeyException e) {
+ // 机场首次上线时 MQTT QoS1 可能重复投递 update_topo(或多线程并发),
+ // 两个线程同时查到“设备不存在”后会并发 insert,后插入者触发唯一键冲突。
+ // 此时数据已被另一线程写入,退化为更新即可,避免整条状态消息处理失败。
+ DeviceEntity exist = mapper.selectOne(
+ new LambdaQueryWrapper<DeviceEntity>()
+ .eq(DeviceEntity::getDeviceSn, entity.getDeviceSn()));
+ if (exist == null) {
+ throw e;
+ }
+ entity.setId(exist.getId());
+ mapper.updateById(entity);
+ return Optional.of(exist);
+ }
}
/**
diff --git a/ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockModelResolver.java b/ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockModelResolver.java
new file mode 100644
index 0000000..f40e3a1
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockModelResolver.java
@@ -0,0 +1,41 @@
+package com.ard.sample.manage.service.impl;
+
+import com.ard.sample.manage.model.dto.DeviceDTO;
+import com.ard.sample.manage.model.enums.DeviceDomainEnum;
+import com.ard.sample.manage.model.enums.DockModelEnum;
+
+/**
+ * 【机场1】机型解析器(Dock1 适配共享层新增)。
+ *
+ * <p>从 DeviceDTO 的 domain/type(即 device_model_key 三段 domain-type-sub_type 的落库结果)
+ * 解析设备所属的机场代际:机场设备按 type 判 1/2,飞行器按 type 判 67/91。
+ * 无法识别(遥控器、未绑定设备、type 为空等)返回 null——调用方应兜底走 dock2 原链,
+ * 保证 dock2 与改造前行为完全一致。
+ *
+ * @author ard
+ * @date 2026/10/16
+ */
+public final class DockModelResolver {
+
+ private DockModelResolver() {
+ }
+
+ /**
+ * 解析设备所属机型。
+ *
+ * @param device 在线设备(DeviceDTO 的 domain/type/subType 由绑定/上线时 device_model_key 三段解析落库)
+ * @return {@link DockModelEnum#DOCK1} / {@link DockModelEnum#DOCK2};无法识别返回 null
+ */
+ public static DockModelEnum resolve(DeviceDTO device) {
+ if (device == null || device.getDomain() == null || device.getType() == null) {
+ return null;
+ }
+ if (DeviceDomainEnum.DOCK.getVal() == device.getDomain()) {
+ return DockModelEnum.fromDockType(device.getType());
+ }
+ if (DeviceDomainEnum.SUB_DEVICE.getVal() == device.getDomain()) {
+ return DockModelEnum.fromDroneType(device.getType());
+ }
+ return null;
+ }
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockOnlineServiceImpl.java b/ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockOnlineServiceImpl.java
index 8e56522..58c93c6 100644
--- a/ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockOnlineServiceImpl.java
+++ b/ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockOnlineServiceImpl.java
@@ -20,6 +20,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
@Slf4j
@@ -51,25 +52,92 @@
//String key = "online:" + device.getDeviceSn();
//System.out.println("在线状态:"+RedisOpsUtils.checkExist(key));
// 更新机场设备状态:"3"-"设备已离线",
- if(!_deviceService.checkDeviceOnline(device.getDeviceSn())){
+ if (!_deviceService.checkDeviceOnline(device.getDeviceSn())) {
_deviceService.updateWorkType(device.getDeviceSn(), 3L);
RedisOpsUtils.set(RedisConst.OSD_STATUS_PREFIX + device.getDeviceSn(), 3);
RedisOpsUtils.del(RedisConst.WEBSOCKET_VIDEO_ADDRESS_PREFIX + device.getDeviceSn());
RedisOpsUtils.del(RedisConst.COVER_STATE_PREFIX + device.getDeviceSn());
- }
- else {
- StartLiveStreaming(device.getDeviceSn());
- }
- if(!_deviceService.checkDeviceOnline(device.getChildDeviceSn())){
- _deviceService.updateWorkType(device.getChildDeviceSn(), 6L);
- RedisOpsUtils.del(RedisConst.WEBSOCKET_VIDEO_ADDRESS_PREFIX + device.getChildDeviceSn());
- RedisOpsUtils.del(RedisConst.COVER_STATE_PREFIX + device.getChildDeviceSn());
- }
- else{
- StartLiveStreaming(device.getChildDeviceSn());
+ clearLiveRetry(device.getDeviceSn());
+ } else {
+ tryStartLiveStreaming(device.getDeviceSn());
}
+ // 机场1:未绑定飞行器时 childDeviceSn 为空,跳过,避免无效的在线判断和起流
+ String childDeviceSn = device.getChildDeviceSn();
+ if (childDeviceSn == null || childDeviceSn.isEmpty()) {
+ continue;
+ }
+ if (!_deviceService.checkDeviceOnline(childDeviceSn)) {
+ _deviceService.updateWorkType(childDeviceSn, 6L);
+ RedisOpsUtils.del(RedisConst.WEBSOCKET_VIDEO_ADDRESS_PREFIX + childDeviceSn);
+ RedisOpsUtils.del(RedisConst.COVER_STATE_PREFIX + childDeviceSn);
+ clearLiveRetry(childDeviceSn);
+ } else if (isDronePoweredOff(childDeviceSn)) {
+ // 飞行器舱内关机(6)或开机中(7)时相机尚未就绪,起流必然失败(如 513006),直接跳过
+ clearLiveRetry(childDeviceSn);
+ } else {
+ tryStartLiveStreaming(childDeviceSn);
+ }
}
+ }
+
+ /**
+ * 按退避策略尝试开启直播。
+ * 原实现每 2 秒对未就绪设备执行一次“停-开”,开机阶段会刷出大量 513006/613011 日志;
+ * 这里失败后按 2s、4s、8s、16s、30s 递增间隔重试。
+ */
+ private void tryStartLiveStreaming(String sn) {
+ if (!canRetryLive(sn)) {
+ return;
+ }
+ markLiveRetry(sn, StartLiveStreaming(sn));
+ }
+
+ /**
+ * 飞行器是否处于关机或开机中状态。
+ * 6-舱内关机(机场 OSD 写入),7-开机中(下发 drone_open 时写入)。
+ */
+ private boolean isDronePoweredOff(String childDeviceSn) {
+ Object status = RedisOpsUtils.get(RedisConst.OSD_STATUS_PREFIX + childDeviceSn);
+ if (status == null) {
+ return false;
+ }
+ String value = String.valueOf(status);
+ return "6".equals(value) || "7".equals(value);
+ }
+
+ /**
+ * 连续失败次数与下次允许重试的时间戳。
+ */
+ private static class LiveRetryState {
+ private int failures;
+ private long nextRetryTime;
+ }
+
+ private static final long LIVE_RETRY_BASE_DELAY_MS = 2000L;
+ private static final long LIVE_RETRY_MAX_DELAY_MS = 30000L;
+
+ private final Map<String, LiveRetryState> liveRetryStates = new ConcurrentHashMap<>();
+
+ private boolean canRetryLive(String sn) {
+ LiveRetryState state = liveRetryStates.get(sn);
+ return state == null || System.currentTimeMillis() >= state.nextRetryTime;
+ }
+
+ private void markLiveRetry(String sn, boolean success) {
+ if (success) {
+ liveRetryStates.remove(sn);
+ return;
+ }
+ LiveRetryState state = liveRetryStates.computeIfAbsent(sn, k -> new LiveRetryState());
+ state.failures++;
+ long delay = Math.min(LIVE_RETRY_BASE_DELAY_MS << Math.min(state.failures - 1, 4), LIVE_RETRY_MAX_DELAY_MS);
+ state.nextRetryTime = System.currentTimeMillis() + delay;
+ log.info("SN: {}-直播启动失败(第 {} 次),{} 秒后重试", sn, state.failures, delay / 1000);
+ }
+
+ private void clearLiveRetry(String sn) {
+ liveRetryStates.remove(sn);
}
/**
@@ -126,7 +194,12 @@
return jsonString;
}
- public void StartLiveStreaming(String sn) {
+ /**
+ * 开启直播(后台定时任务调用)。
+ *
+ * @return true-已存在可用地址或起流成功;false-起流失败
+ */
+ public boolean StartLiveStreaming(String sn) {
//调用开始直播
ResponseResult rlive = null;
String resultLive = "";
@@ -136,12 +209,13 @@
// 判断param是否为空,不为空则返回正确的直播参数,为null则未获取直播参数,不为空则继续操作
if (param == null) {
log.info("SN: {}-未获取到直播参数param为null,无法开启直播!", sn);
- return;
+ return false;
}
// 如果存在直播则读取直播地址返回
if (RedisOpsUtils.checkExist(RedisConst.WEBSOCKET_VIDEO_ADDRESS_PREFIX + sn)) {
resultLive = String.valueOf(RedisOpsUtils.get(RedisConst.WEBSOCKET_VIDEO_ADDRESS_PREFIX + sn));
// log.info("SN: {}-redis中存在相机的播放地址信息,直接返回地址!", sn);
+ return true;
} else {
// log.info("SN: {}-redis中未找到相机的播放地址信息!停止直播后再开启直播", sn);
_liveStreamHandler.StopLiveStreaming(sn);
@@ -159,11 +233,17 @@
// if (isWeb) {
// log.info("SN: {}-更新webrtc地址成功!", sn);
// }
+ // 定时任务是后台自动起流,前端并没有发订阅,必须主动把地址推给已订阅该设备的页面;
+ // 否则飞行器起飞后相机就绪、后台起流成功,页面却收不到地址,只能刷新后才出画面
+ _dockWebSocketHandler.sendMessageToSubscribers(sn, MessageType.LIVESTREAM.getType(), resultLive, "");
+ return true;
}
+ log.info("SN: {}-开启直播失败!返回值:{}", sn, rlive);
}
} catch (Exception e) {
log.info("订时任务推流-SN: {}-开启直播失败!原因:{}", sn, e.getMessage());
e.printStackTrace();
}
+ return false;
}
}
diff --git a/ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockOsdDispatcher.java b/ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockOsdDispatcher.java
new file mode 100644
index 0000000..c8f77a5
--- /dev/null
+++ b/ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockOsdDispatcher.java
@@ -0,0 +1,60 @@
+package com.ard.sample.manage.service.impl;
+
+import com.ard.sample.component.mqtt.model.CommonTopicReceiver;
+import com.ard.sample.component.websocket.config.ConcurrentWebSocketSession;
+import com.ard.sample.component.websocket.model.CustomWebSocketMessage;
+import com.ard.sample.manage.model.dto.DeviceDTO;
+import com.ard.sample.manage.model.dto.TelemetryDTO;
+import com.ard.sample.manage.model.enums.DockModelEnum;
+import com.ard.sample.manage.service.ITSAService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+
+import java.util.Collection;
+
+/**
+ * 【机场1】OSD 入站分发器(Dock1 适配共享层新增,唯一侵入共享层的分流点)。
+ *
+ * <p>DeviceServiceImpl.handleOSD 收到 INBOUND_OSD 消息后委托本类,按设备机型分发到两条 OSD 处理链:
+ * <ul>
+ * <li>DOCK1(67) → dock1 链:{@code dock1GatewayOSDServiceImpl → dock1DeviceOSDServiceImpl → dock1DockOSDServiceImpl}</li>
+ * <li>其余(DOCK2(91) / 遥控器等无法识别来源)→ dock2 原链:
+ * {@code gatewayOSDServiceImpl → deviceOSDServiceImpl → dockOSDServiceImpl}(行为与改造前完全一致)</li>
+ * </ul>
+ * dock1 链为普通 @Service(不带 @ServiceActivator),INBOUND_OSD 通道消费者仍只有 DeviceServiceImpl 一个。
+ *
+ * @author ard
+ * @date 2026/10/16
+ */
+@Service
+public class DockOsdDispatcher {
+
+ @Autowired
+ @Qualifier("gatewayOSDServiceImpl")
+ private ITSAService dock2TsaService;
+
+ @Autowired
+ @Qualifier("dock1GatewayOSDServiceImpl")
+ private ITSAService dock1TsaService;
+
+ /**
+ * 分发 OSD 消息到对应机型的处理链。
+ *
+ * @param receiver OSD 报文
+ * @param device 消息来源设备(含 domain/type/subType)
+ * @param webSessions 目标 WebSocket 会话
+ * @param wsMessage 待推送的 WebSocket 消息
+ */
+ public void dispatch(CommonTopicReceiver receiver, DeviceDTO device,
+ Collection<ConcurrentWebSocketSession> webSessions,
+ CustomWebSocketMessage<TelemetryDTO> wsMessage) {
+ DockModelEnum model = DockModelResolver.resolve(device);
+ if (DockModelEnum.DOCK1 == model) {
+ dock1TsaService.handleOSD(receiver, device, webSessions, wsMessage);
+ return;
+ }
+ // dock2 及一切无法识别的来源:原链透传,保证 dock2 行为零变化
+ dock2TsaService.handleOSD(receiver, device, webSessions, wsMessage);
+ }
+}
diff --git a/ard-sample/src/main/java/com/ard/sample/media/service/impl/FileServiceImpl.java b/ard-sample/src/main/java/com/ard/sample/media/service/impl/FileServiceImpl.java
index ebf91a4..8982a89 100644
--- a/ard-sample/src/main/java/com/ard/sample/media/service/impl/FileServiceImpl.java
+++ b/ard-sample/src/main/java/com/ard/sample/media/service/impl/FileServiceImpl.java
@@ -114,17 +114,21 @@
*/
@Override
public PaginationData<MediaFileDTO> getJobFiles(long page, long pageSize,String jobId, String isType) {
- Page<MediaFileEntity> pageData = mapper.selectPage(
- new Page<MediaFileEntity>(page, pageSize),
- new LambdaQueryWrapper<MediaFileEntity>()
- .eq(Objects.nonNull(jobId), MediaFileEntity::getJobId, jobId)
-// .orderByDesc(MediaFileEntity::getId));
- // isType=1 时匹配 V.jpeg
- .like("1".equals(isType), MediaFileEntity::getFileName, "V.jpeg")
- // isType=0 时匹配 V.mp4
- .like("0".equals(isType), MediaFileEntity::getFileName, "V.mp4")
- // 排序修改:按start_time正序(升序)排列
- .orderByAsc(MediaFileEntity::getStartTime)); // 关键改动1:替换排序方式
+ LambdaQueryWrapper<MediaFileEntity> queryWrapper = new LambdaQueryWrapper<MediaFileEntity>()
+ .eq(Objects.nonNull(jobId), MediaFileEntity::getJobId, jobId);
+ // 按文件扩展名区分图片/视频,不再写死镜头前缀:
+ // Dock2 可见光文件名为 _V.jpeg/_V.mp4;机场1(M30T)三光相机为 _W/_Z/_T.jpeg(广角/变焦/红外)及 _W.mp4。
+ // 原实现写死 V.jpeg/V.mp4,导致机场1的媒体文件在计划库中查询不到。
+ if ("1".equals(isType)) {
+ queryWrapper.and(w -> w.like(MediaFileEntity::getFileName, ".jpeg")
+ .or().like(MediaFileEntity::getFileName, ".jpg"));
+ } else if ("0".equals(isType)) {
+ queryWrapper.like(MediaFileEntity::getFileName, ".mp4");
+ }
+ // 排序:按 start_time 正序(升序)排列
+ queryWrapper.orderByAsc(MediaFileEntity::getStartTime);
+
+ Page<MediaFileEntity> pageData = mapper.selectPage(new Page<MediaFileEntity>(page, pageSize), queryWrapper);
List<MediaFileDTO> records = pageData.getRecords()
.stream()
diff --git a/ard-sample/src/main/java/com/ard/sample/storage/service/impl/StorageServiceImpl.java b/ard-sample/src/main/java/com/ard/sample/storage/service/impl/StorageServiceImpl.java
index 6842b2c..97d9d06 100644
--- a/ard-sample/src/main/java/com/ard/sample/storage/service/impl/StorageServiceImpl.java
+++ b/ard-sample/src/main/java/com/ard/sample/storage/service/impl/StorageServiceImpl.java
@@ -2,6 +2,7 @@
import com.ard.common.config.OssConfiguration;
import com.ard.oss.model.CredentialsDTO;
+import com.ard.sample.common.error.CommonErrorEnum;
import com.ard.sample.component.mqtt.model.*;
import com.ard.sample.component.mqtt.service.IMessageSenderService;
import com.ard.oss.model.StsCredentialsDTO;
@@ -77,10 +78,20 @@
@ServiceActivator(inputChannel = ChannelName.INBOUND_REQUESTS_STORAGE_CONFIG_GET, outputChannel = ChannelName.OUTBOUND)
public void replyConfigGet(CommonTopicReceiver receiver, MessageHeaders headers) {
// 20251219 这里是获取上传临时凭证的接口,稍候分析下是否满足条件,如果可以的话就直接回复临时凭证即可
+ RequestsReply reply;
+ try {
+ reply = RequestsReply.success(this.getSTSCredentials());
+ } catch (Exception e) {
+ // 机场1 上送 storage_config_get 时,若 MinIO STS 不可用,原实现会直接抛异常且不回复,
+ // 机场会每 5 秒重试一次并持续刷 error.log。这里回一个明确的失败应答,避免消息处理中断。
+ // 底层 MinIOServiceImpl 已打印完整堆栈,此处仅记录关键信息。
+ log.error("获取媒体上传临时凭证失败,gateway={},原因:{}", receiver.getGateway(), e.getMessage());
+ reply = RequestsReply.error(CommonErrorEnum.SYSTEM_ERROR);
+ }
CommonTopicResponse<RequestsReply> response = CommonTopicResponse.<RequestsReply>builder()
.tid(receiver.getTid())
.bid(receiver.getBid())
- .data(RequestsReply.success(this.getSTSCredentials()))
+ .data(reply)
.timestamp(System.currentTimeMillis())
.method(RequestsMethodEnum.STORAGE_CONFIG_GET.getMethod())
.build();
diff --git a/sql/dock1_dictionary.sql b/sql/dock1_dictionary.sql
new file mode 100644
index 0000000..7b9b1e0
--- /dev/null
+++ b/sql/dock1_dictionary.sql
@@ -0,0 +1,58 @@
+-- =====================================================================
+-- 机场1(DJI Dock 1 / M30)设备型号字典数据
+-- 表:manage_device_dictionary(DJI 上云线设备型号字典)
+--
+-- 背景:经核实 2026-10-16,2025-11-06 数据备份
+-- (sql/backup/20251106091248.nb3)中以下数据行已随
+-- DJI 官方 Demo 种子数据存在('DJI Dock'=id18 / 'Matrice 30'=id2 /
+-- 'Matrice 30T'=id3 等),开发库无需执行即可直接联调。
+-- 本脚本为幂等兜底:跨环境部署(dev / prod / daqing)若缺失则补齐,
+-- 已存在则跳过,可重复执行。
+--
+-- 消费点(皆为共享层,由 dock1/dock2 两条链共用):
+-- DeviceServiceImpl#deviceGatewayConvertToDeviceEntity :793 机场上线 domain=3
+-- DeviceServiceImpl#subDeviceConvertToDeviceEntity :827 飞行器上线 domain=0
+-- DeviceServiceImpl#bindDevice2Entity :1373 设备绑定
+-- DevicePayloadServiceImpl :205 负载上线 domain=1
+-- CapacityCameraServiceImpl :68 相机能力上报 domain=1
+-- FileServiceImpl :209 媒体上传 domain=1
+--
+-- 官方文档:https://developer.dji.com/doc/cloud-api-tutorial/cn/api-reference/
+-- dock-to-cloud/mqtt/dock/dock1/properties.html
+-- (device_model_key 三段式 domain-type-sub_type 即下列前 3 列)
+-- =====================================================================
+
+-- 机场1(Dock1):domain=3(机场), device_type=1, sub_type=0
+INSERT INTO `manage_device_dictionary` (`domain`, `device_type`, `sub_type`, `device_name`, `device_desc`)
+SELECT 3, 1, 0, 'DJI Dock', 'DJI Airport' FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `manage_device_dictionary` WHERE `domain` = 3 AND `device_type` = 1 AND `sub_type` = 0);
+
+-- 飞行器 M30:domain=0(飞行器), device_type=67, sub_type=0
+INSERT INTO `manage_device_dictionary` (`domain`, `device_type`, `sub_type`, `device_name`, `device_desc`)
+SELECT 0, 67, 0, 'Matrice 30', NULL FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `manage_device_dictionary` WHERE `domain` = 0 AND `device_type` = 67 AND `sub_type` = 0);
+
+-- 飞行器 M30T:domain=0, device_type=67, sub_type=1
+INSERT INTO `manage_device_dictionary` (`domain`, `device_type`, `sub_type`, `device_name`, `device_desc`)
+SELECT 0, 67, 1, 'Matrice 30T', NULL FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `manage_device_dictionary` WHERE `domain` = 0 AND `device_type` = 67 AND `sub_type` = 1);
+
+-- M30 相机:domain=1(负载), device_type=52, sub_type=0
+INSERT INTO `manage_device_dictionary` (`domain`, `device_type`, `sub_type`, `device_name`, `device_desc`)
+SELECT 1, 52, 0, 'M30 Camera', NULL FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `manage_device_dictionary` WHERE `domain` = 1 AND `device_type` = 52 AND `sub_type` = 0);
+
+-- M30T 相机:domain=1, device_type=53, sub_type=0
+INSERT INTO `manage_device_dictionary` (`domain`, `device_type`, `sub_type`, `device_name`, `device_desc`)
+SELECT 1, 53, 0, 'M30T Camera', NULL FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `manage_device_dictionary` WHERE `domain` = 1 AND `device_type` = 53 AND `sub_type` = 0);
+
+-- 机场相机:domain=1, device_type=165, sub_type=0
+INSERT INTO `manage_device_dictionary` (`domain`, `device_type`, `sub_type`, `device_name`, `device_desc`)
+SELECT 1, 165, 0, 'DJI Dock Camera', NULL FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `manage_device_dictionary` WHERE `domain` = 1 AND `device_type` = 165 AND `sub_type` = 0);
+
+-- M30 遥控器:domain=2(遥控器), device_type=119, sub_type=0
+INSERT INTO `manage_device_dictionary` (`domain`, `device_type`, `sub_type`, `device_name`, `device_desc`)
+SELECT 2, 119, 0, 'Matrice 30 Smart Controller', 'Remote control for M30' FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `manage_device_dictionary` WHERE `domain` = 2 AND `device_type` = 119 AND `sub_type` = 0);
diff --git a/sql/dock1_sys_dict.sql b/sql/dock1_sys_dict.sql
new file mode 100644
index 0000000..bf452d4
--- /dev/null
+++ b/sql/dock1_sys_dict.sql
@@ -0,0 +1,34 @@
+-- =====================================================================
+-- 机场1(DJI Dock 1 / M30、M30T)前端航线机型字典兜底
+-- 表:sys_dict_type / sys_dict_data
+--
+-- 背景:
+-- uav-project3 的航线新建页(views/airway/components/createAirway.vue)
+-- 与计划新建页(views/plan/components/addPlan.vue)都通过
+-- useDict('dji_m30_series') 读取 M30 系列机型选项。
+-- 若部署环境的 sys_dict_data 中缺少 0-67-1(Matrice 30T),
+-- 则无法在航线库中新建 M30T 航线,计划页也会显示“暂无类型”。
+--
+-- 说明:
+-- 本脚本仅做幂等兜底:已存在同 dict_type/dict_value 时跳过,可重复执行。
+-- M30(0-67-0)与 M30T(0-67-1)的 device_type 均为 67,
+-- 子类型 sub_type 分别为 0 / 1(见 sql/dock1_dictionary.sql)。
+-- =====================================================================
+
+-- 字典类型:dji_m30_series
+INSERT INTO `sys_dict_type` (`dict_name`, `dict_type`, `status`, `create_by`, `create_time`, `remark`)
+SELECT '经纬 M30 系列', 'dji_m30_series', '0', 'admin', sysdate(), '机场1(Dock1)航线机型'
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_type` WHERE `dict_type` = 'dji_m30_series');
+
+-- 机型:M30 -> 0-67-0
+INSERT INTO `sys_dict_data` (`dict_sort`, `dict_label`, `dict_value`, `dict_type`, `is_default`, `status`, `create_by`, `create_time`, `remark`)
+SELECT 1, 'Matrice 30', '0-67-0', 'dji_m30_series', 'N', '0', 'admin', sysdate(), '机场1 飞行器 M30'
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'dji_m30_series' AND `dict_value` = '0-67-0');
+
+-- 机型:M30T -> 0-67-1
+INSERT INTO `sys_dict_data` (`dict_sort`, `dict_label`, `dict_value`, `dict_type`, `is_default`, `status`, `create_by`, `create_time`, `remark`)
+SELECT 2, 'Matrice 30T', '0-67-1', 'dji_m30_series', 'N', '0', 'admin', sysdate(), '机场1 飞行器 M30T'
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `dict_type` = 'dji_m30_series' AND `dict_value` = '0-67-1');
--
Gitblit v1.9.3