nishiwei
7 days ago 3f43e40ed1a1e9f66b3ad2862da9dbc4b710c712
智云3.0机场1适配内容提交
42 files added
20 files modified
6418 ■■■■■ changed files
ard-admin/pom.xml 11 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1CommandController.java 38 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceController.java 279 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceFirmwareController.java 111 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceHmsController.java 82 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceLogsController.java 124 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DevicePayloadController.java 39 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DockController.java 43 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1LiveStreamController.java 163 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1TopologyController.java 45 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/media/Dock1FileController.java 95 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/media/Dock1MediaController.java 80 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineFileController.java 170 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineJobController.java 116 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineTrajectoryController.java 46 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/java/com/ard/web/controller/sample/media/FileController.java 6 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/resources/application-daqing-druid.yml 8 ●●●●● patch | view | raw | blame | history
ard-admin/src/main/resources/application-daqing.yml 16 ●●●●● patch | view | raw | blame | history
ard-route/src/main/java/com/ard/route/domain/djuav/kml/KmlPayloadInfo.java 6 ●●●●● patch | view | raw | blame | history
ard-route/src/main/java/com/ard/route/service/impl/ArdRouteInfoServiceImpl.java 20 ●●●●● patch | view | raw | blame | history
ard-route/src/main/java/com/ard/route/util/InitializationUtils.java 13 ●●●●● patch | view | raw | blame | history
ard-route/src/main/java/com/ard/route/util/RouteFileUtils.java 6 ●●●●● patch | view | raw | blame | history
ard-route/src/main/java/com/ard/route/util/ToolUtils.java 17 ●●●●● patch | view | raw | blame | history
ard-route/src/main/java/com/ard/route/util/UavRouteReqUtils.java 7 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/component/mqtt/config/MqttMessageChannel.java 8 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/component/mqtt/handler/StateLiveStatusHandler.java 8 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/component/mqtt/model/ChannelName.java 5 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/component/mqtt/model/EventsMethodEnum.java 5 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/component/websocket/handler/DockWebSocketHandler.java 76 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/component/websocket/handler/LiveStreamHandler.java 63 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1DroneAuthorityInfoReceiver.java 35 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1DroneAuthorityPayloadReceiver.java 33 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1OsdDockReceiver.java 306 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1OsdSubDeviceReceiver.java 88 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/ICommandService.java 26 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/IControlService.java 35 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/ILiveStreamService.java 73 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/IWaylineTrajectoryService.java 28 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1CommandServiceImpl.java 126 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1ControlServiceImpl.java 246 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1DeviceOSDServiceImpl.java 142 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1DockOSDServiceImpl.java 125 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1FlightSetupExceptionHandler.java 68 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1GatewayOSDServiceImpl.java 70 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1LiveStreamServiceImpl.java 544 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1WaylineTrajectoryServiceImpl.java 274 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/wayline/model/dto/Dock1WaylineTaskCreateDTO.java 79 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IFlightTaskService.java 22 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IWaylineFileService.java 80 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IWaylineJobService.java 129 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1FlightTaskServiceImpl.java 288 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1WaylineFileServiceImpl.java 289 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1WaylineJobServiceImpl.java 1160 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/manage/model/enums/DockModelEnum.java 64 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/manage/service/impl/DeviceServiceImpl.java 44 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockModelResolver.java 41 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockOnlineServiceImpl.java 106 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockOsdDispatcher.java 60 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/media/service/impl/FileServiceImpl.java 26 ●●●●● patch | view | raw | blame | history
ard-sample/src/main/java/com/ard/sample/storage/service/impl/StorageServiceImpl.java 13 ●●●●● patch | view | raw | blame | history
sql/dock1_dictionary.sql 58 ●●●●● patch | view | raw | blame | history
sql/dock1_sys_dict.sql 34 ●●●●● patch | view | raw | blame | history
ard-admin/pom.xml
@@ -125,6 +125,17 @@
                    </execution>
                </executions>
            </plugin>
            <!-- 资源过滤使用 @ 作为分隔符,替换 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>   
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1CommandController.java
New file
@@ -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);
    }
}
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceController.java
New file
@@ -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();
    }
}
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceFirmwareController.java
New file
@@ -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();
    }
}
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceHmsController.java
New file
@@ -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());
    }
}
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DeviceLogsController.java
New file
@@ -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.");
    }
}
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DevicePayloadController.java
New file
@@ -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());
    }
}
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1DockController.java
New file
@@ -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);
    }
}
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1LiveStreamController.java
New file
@@ -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);
    }
}
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/Dock1TopologyController.java
New file
@@ -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)));
    }
}
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/media/Dock1FileController.java
New file
@@ -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);
    }
}
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/media/Dock1MediaController.java
New file
@@ -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)));
    }
}
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineFileController.java
New file
@@ -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();
    }
}
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineJobController.java
New file
@@ -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();
    }
}
ard-admin/src/main/java/com/ard/web/controller/sample/dock1/wayline/Dock1WaylineTrajectoryController.java
New file
@@ -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());
        }
    }
}
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);
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:
                # 从数据源开关/默认关闭
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/
  url : https://10.67.52.22:3001/tileset/
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;
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;
            }
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);
        // 机场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");
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;
    }
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";
    }
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()));
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();
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,8 +59,11 @@
                catch (Exception e){
                    log.error("接收state直播设备状态反馈转换异常:{}",e);
                }
                // 无子设备 SN 时不写缓存,避免产生 COVER_STATE_PREFIX + null 的非法 Redis key
                if (StringUtils.hasText(childSn)) {
                _deviceService.setChildRedisCamera(childSn, liveStatus);
            }
            }
            return stateReceiver;
        }
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";
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),
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);
                    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处理器资源已销毁");
    }
}
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();
            for (CapacityCameraDTO camera : camerasList) {
                // 机场3中飞行器,没有name,需要其他条件确定主摄像头参数
                for(CapacityVideoDTO video : camera.getVideosList())
                {
                    if(video.getSwitchVideoTypes().size() == 1 && camera.getName() == null)
                    {
            if (camerasList == null || camerasList.isEmpty()) {
                        continue;
                    }
                }
//                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();
            for (CapacityCameraDTO camera : camerasList) {
                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) {
                    videoid = videoid + "/" + video.getIndex();
                    selectedVideo = video;
                    if (m30SeriesCamera && video.getIndex() != null
                            && video.getIndex().startsWith("normal")) {
                        break;
                    }
                }
                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(video.getSwitchVideoTypes().get(0));
                }
                param.setVideoType(switchVideoTypes.get(0));
            }
        }
        return param;
ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1DroneAuthorityInfoReceiver.java
New file
@@ -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;
}
ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1DroneAuthorityPayloadReceiver.java
New file
@@ -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;
}
ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1OsdDockReceiver.java
New file
@@ -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;
}
ard-sample/src/main/java/com/ard/sample/dock1/manage/model/receiver/Dock1OsdSubDeviceReceiver.java
New file
@@ -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;
}
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/ICommandService.java
New file
@@ -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);
}
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/IControlService.java
New file
@@ -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);
}
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/ILiveStreamService.java
New file
@@ -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);
}
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/IWaylineTrajectoryService.java
New file
@@ -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);
}
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1CommandServiceImpl.java
New file
@@ -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());
        }
    }
}
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1ControlServiceImpl.java
New file
@@ -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());
        }
    }
}
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1DeviceOSDServiceImpl.java
New file
@@ -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);
    }
}
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1DockOSDServiceImpl.java
New file
@@ -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, "");
        }
    }
}
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1FlightSetupExceptionHandler.java
New file
@@ -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);
        }
    }
}
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1GatewayOSDServiceImpl.java
New file
@@ -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);
    }
}
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1LiveStreamServiceImpl.java
New file
@@ -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);
    }
}
ard-sample/src/main/java/com/ard/sample/dock1/manage/service/impl/Dock1WaylineTrajectoryServiceImpl.java
New file
@@ -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;
    }
}
ard-sample/src/main/java/com/ard/sample/dock1/wayline/model/dto/Dock1WaylineTaskCreateDTO.java
New file
@@ -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;
}
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IFlightTaskService.java
New file
@@ -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);
}
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IWaylineFileService.java
New file
@@ -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);
}
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/IWaylineJobService.java
New file
@@ -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);
}
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1FlightTaskServiceImpl.java
New file
@@ -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);
            }
        }
    }
}
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1WaylineFileServiceImpl.java
New file
@@ -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();
    }
}
ard-sample/src/main/java/com/ard/sample/dock1/wayline/service/impl/Dock1WaylineJobServiceImpl.java
New file
@@ -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();
    }
}
ard-sample/src/main/java/com/ard/sample/manage/model/enums/DockModelEnum.java
New file
@@ -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;
    }
}
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 @@
        }
        // 调用验证方法
        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);
        }
    }
    /**
ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockModelResolver.java
New file
@@ -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;
    }
}
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
@@ -56,20 +57,87 @@
                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;
    }
}
ard-sample/src/main/java/com/ard/sample/manage/service/impl/DockOsdDispatcher.java
New file
@@ -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);
    }
}
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()
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();
sql/dock1_dictionary.sql
New file
@@ -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);
sql/dock1_sys_dict.sql
New file
@@ -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');