Jelajahi Sumber

应用远程更新功能模块完成

fanghuisheng 9 jam lalu
induk
melakukan
710a225fad
18 mengubah file dengan 1106 tambahan dan 2 penghapusan
  1. 6 2
      service-eg/service-eg-biz/pom.xml
  2. 10 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/adb/EgDeviceAdbConfiguration.java
  3. 75 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/adb/EgDeviceAdbProperties.java
  4. 97 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/controller/web/EgDeviceAdbController.java
  5. 67 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/mqtt/EgMqttOutboundConfig.java
  6. 81 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/mqtt/EgMqttProperties.java
  7. 20 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/service/EgDeviceAdbService.java
  8. 19 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/service/EgDeviceRemoteUpgradeMqttService.java
  9. 325 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/service/impl/EgDeviceAdbServiceImpl.java
  10. 173 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/service/impl/EgDeviceRemoteUpgradeMqttServiceImpl.java
  11. 13 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/service/vo/EgDeviceAdbEndpointVO.java
  12. 19 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/service/vo/EgDeviceAdbRemoteUpgradeBatchItemVO.java
  13. 21 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/service/vo/EgDeviceAdbRemoteUpgradeBatchResultVO.java
  14. 34 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/service/vo/EgDeviceAdbRemoteUpgradeItemResultVO.java
  15. 36 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/service/vo/EgDeviceAdbRemoteUpgradeVO.java
  16. 29 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/service/vo/EgDeviceAdbResultVO.java
  17. 15 0
      service-eg/service-eg-biz/src/main/java/com/usky/eg/service/vo/EgDeviceAdbTcpipVO.java
  18. 66 0
      service-eg/service-eg-biz/src/main/resources/bootstrap.yml

+ 6 - 2
service-eg/service-eg-biz/pom.xml

@@ -16,10 +16,14 @@
             <artifactId>common-cloud-starter</artifactId>
         </dependency>
 
-        <!-- WebSocket:ADB 局域网透传(浏览器 ←WS→ 服务端 ←TCP→ 设备) -->
+        <!-- MQTT:远程升级指令下发(与终端 connectWithDevice 订阅主题对齐) -->
         <dependency>
             <groupId>org.springframework.boot</groupId>
-            <artifactId>spring-boot-starter-websocket</artifactId>
+            <artifactId>spring-boot-starter-integration</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.integration</groupId>
+            <artifactId>spring-integration-mqtt</artifactId>
         </dependency>
 
         <dependency>

+ 10 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/adb/EgDeviceAdbConfiguration.java

@@ -0,0 +1,10 @@
+package com.usky.eg.adb;
+
+import com.usky.eg.mqtt.EgMqttProperties;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@EnableConfigurationProperties({EgDeviceAdbProperties.class, EgMqttProperties.class})
+public class EgDeviceAdbConfiguration {
+}

+ 75 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/adb/EgDeviceAdbProperties.java

@@ -0,0 +1,75 @@
+package com.usky.eg.adb;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * 服务端调用官方 platform-tools adb(需在 PATH 或可执行路径配置)。
+ */
+@ConfigurationProperties(prefix = "eg.adb")
+public class EgDeviceAdbProperties {
+
+    /**
+     * adb 可执行文件名或绝对路径,默认依赖系统 PATH 中的 adb
+     */
+    private String executable = "adb";
+
+    /**
+     * connect / disconnect / tcpip 等命令超时(秒)
+     */
+    private long commandTimeoutSeconds = 45L;
+
+    /**
+     * screencap 超时(秒)
+     */
+    private long screenshotTimeoutSeconds = 60L;
+
+    /**
+     * tcpip 失败时是否先执行一次 adb connect 再重试 tcpip
+     */
+    private boolean tcpipRetryAfterConnect = true;
+
+    /**
+     * 截屏前是否先执行 adb connect(避免仅调 screenshot 时 adb 侧尚无该 serial,报 device not found)
+     */
+    private boolean screenshotConnectFirst = true;
+
+    public String getExecutable() {
+        return executable;
+    }
+
+    public void setExecutable(String executable) {
+        this.executable = executable;
+    }
+
+    public long getCommandTimeoutSeconds() {
+        return commandTimeoutSeconds;
+    }
+
+    public void setCommandTimeoutSeconds(long commandTimeoutSeconds) {
+        this.commandTimeoutSeconds = commandTimeoutSeconds;
+    }
+
+    public long getScreenshotTimeoutSeconds() {
+        return screenshotTimeoutSeconds;
+    }
+
+    public void setScreenshotTimeoutSeconds(long screenshotTimeoutSeconds) {
+        this.screenshotTimeoutSeconds = screenshotTimeoutSeconds;
+    }
+
+    public boolean isTcpipRetryAfterConnect() {
+        return tcpipRetryAfterConnect;
+    }
+
+    public void setTcpipRetryAfterConnect(boolean tcpipRetryAfterConnect) {
+        this.tcpipRetryAfterConnect = tcpipRetryAfterConnect;
+    }
+
+    public boolean isScreenshotConnectFirst() {
+        return screenshotConnectFirst;
+    }
+
+    public void setScreenshotConnectFirst(boolean screenshotConnectFirst) {
+        this.screenshotConnectFirst = screenshotConnectFirst;
+    }
+}

+ 97 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/controller/web/EgDeviceAdbController.java

@@ -0,0 +1,97 @@
+package com.usky.eg.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.eg.service.EgDeviceAdbService;
+import com.usky.eg.service.EgDeviceRemoteUpgradeMqttService;
+import com.usky.eg.service.vo.EgDeviceAdbEndpointVO;
+import com.usky.eg.service.vo.EgDeviceAdbRemoteUpgradeBatchResultVO;
+import com.usky.eg.service.vo.EgDeviceAdbRemoteUpgradeVO;
+import com.usky.eg.service.vo.EgDeviceAdbResultVO;
+import com.usky.eg.service.vo.EgDeviceAdbTcpipVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 服务端官方 adb:部署机需安装 Android Platform Tools(adb 在 PATH),且网络可达设备。
+ * <p>
+ * 网关前缀一般为 /service-eg,完整路径示例:POST /service-eg/egDevice/adb/connect
+ */
+@RestController
+@RequestMapping("/egDevice/adb")
+public class EgDeviceAdbController {
+
+    @Autowired
+    private EgDeviceAdbService egDeviceAdbService;
+
+    @Autowired
+    private EgDeviceRemoteUpgradeMqttService egDeviceRemoteUpgradeMqttService;
+
+    @PostMapping("/connect")
+    public ApiResult<EgDeviceAdbResultVO> connect(@RequestBody EgDeviceAdbEndpointVO vo) {
+        EgDeviceAdbResultVO r = egDeviceAdbService.connect(vo.getDeviceIp(), vo.getDevicePort());
+        return ApiResult.success(r);
+    }
+
+    @PostMapping("/disconnect")
+    public ApiResult<EgDeviceAdbResultVO> disconnect(@RequestBody EgDeviceAdbEndpointVO vo) {
+        EgDeviceAdbResultVO r = egDeviceAdbService.disconnect(vo.getDeviceIp(), vo.getDevicePort());
+        return ApiResult.success(r);
+    }
+
+    @PostMapping("/tcpip")
+    public ApiResult<EgDeviceAdbResultVO> tcpip(@RequestBody EgDeviceAdbTcpipVO vo) {
+        EgDeviceAdbResultVO r = egDeviceAdbService.tcpip(vo.getDeviceIp(), vo.getDevicePort(), vo.getPort());
+        return ApiResult.success(r);
+    }
+
+    /**
+     * 远程升级:仅通过 MQTT 向 Broker 发布下行指令,由终端 {@code connectWithDevice} 订阅后在设备侧自行下载安装
+     * 单台:{@code fileUrl} 必填;{@code deviceId} 或 {@code deviceCode} 至少其一。
+     * 批量:非空 {@code items},每项含 {@code deviceId} 或 {@code deviceCode},并与同级 {@code fileUrl}、可选 {@code launchPackage} 共用。
+     * 完整路径示例:POST /service-eg/egDevice/adb/remoteUpgrade
+     */
+    @PostMapping("/remoteUpgrade")
+    public ApiResult<?> remoteUpgrade(@RequestBody EgDeviceAdbRemoteUpgradeVO vo) {
+        if (vo.getItems() != null && !vo.getItems().isEmpty()) {
+            EgDeviceAdbRemoteUpgradeBatchResultVO batch = egDeviceRemoteUpgradeMqttService.publishRemoteUpgradeBatch(
+                    vo.getItems(), vo.getFileUrl(), vo.getLaunchPackage());
+            return ApiResult.success(batch);
+        }
+        EgDeviceAdbResultVO r = egDeviceRemoteUpgradeMqttService.publishRemoteUpgrade(vo);
+        return ApiResult.success(r);
+    }
+
+    /**
+     * 原始 PNG 二进制,无 JSON 封装(前端 responseType: blob)。
+     * 使用 {@link HttpServletResponse} 写出流,避免全局 ApiResult 包装器把返回值当成 JSON 再强转为 byte[] 导致 ClassCastException。
+     */
+    @GetMapping(value = "/screenshot", produces = MediaType.IMAGE_PNG_VALUE)
+    public void screenshot(@RequestParam("deviceIp") String deviceIp,
+                           @RequestParam("devicePort") Integer devicePort,
+                           HttpServletResponse response) throws IOException {
+        try {
+            byte[] png = egDeviceAdbService.screenshot(deviceIp, devicePort);
+            response.setStatus(HttpServletResponse.SC_OK);
+            response.setContentType(MediaType.IMAGE_PNG_VALUE);
+            response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store, no-cache");
+            response.setContentLength(png.length);
+            response.getOutputStream().write(png);
+            response.getOutputStream().flush();
+        } catch (BusinessException e) {
+            String msg = e.getMessage() != null ? e.getMessage() : "Bad Request";
+            byte[] err = msg.getBytes(StandardCharsets.UTF_8);
+            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+            response.setContentType(MediaType.TEXT_PLAIN_VALUE);
+            response.setContentLength(err.length);
+            response.getOutputStream().write(err);
+            response.getOutputStream().flush();
+        }
+    }
+}

+ 67 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/mqtt/EgMqttOutboundConfig.java

@@ -0,0 +1,67 @@
+package com.usky.eg.mqtt;
+
+import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.integration.annotation.MessagingGateway;
+import org.springframework.integration.annotation.ServiceActivator;
+import org.springframework.integration.channel.DirectChannel;
+import org.springframework.integration.mqtt.core.DefaultMqttPahoClientFactory;
+import org.springframework.integration.mqtt.core.MqttPahoClientFactory;
+import org.springframework.integration.mqtt.outbound.MqttPahoMessageHandler;
+import org.springframework.integration.mqtt.support.MqttHeaders;
+import org.springframework.messaging.MessageChannel;
+import org.springframework.messaging.MessageHandler;
+import org.springframework.messaging.handler.annotation.Header;
+
+/**
+ * 出站 MQTT(发布远程升级下行指令)。
+ */
+@Configuration
+public class EgMqttOutboundConfig {
+
+    public static final String CHANNEL_NAME_OUT = "egMqttOutboundChannel";
+    public static final String DEFAULT_TOPIC = "eg/default";
+
+    @Autowired
+    private EgMqttProperties egMqttProperties;
+
+    @Bean
+    public MqttPahoClientFactory egMqttPahoClientFactory() {
+        DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory();
+        MqttConnectOptions options = new MqttConnectOptions();
+        options.setServerURIs(new String[]{egMqttProperties.getUrl()});
+        String user = egMqttProperties.getUsername();
+        if (user != null && !user.isEmpty()) {
+            options.setUserName(user);
+        }
+        String pwd = egMqttProperties.getPassword();
+        if (pwd != null && !pwd.isEmpty()) {
+            options.setPassword(pwd.toCharArray());
+        }
+        factory.setConnectionOptions(options);
+        return factory;
+    }
+
+    @Bean(name = CHANNEL_NAME_OUT)
+    public MessageChannel egMqttOutboundChannel() {
+        return new DirectChannel();
+    }
+
+    @Bean
+    @ServiceActivator(inputChannel = CHANNEL_NAME_OUT)
+    public MessageHandler egMqttOutbound(MqttPahoClientFactory egMqttPahoClientFactory) {
+        String clientId = "service-eg-mqtt-out-" + System.currentTimeMillis();
+        MqttPahoMessageHandler handler = new MqttPahoMessageHandler(clientId, egMqttPahoClientFactory);
+        handler.setAsync(true);
+        handler.setDefaultTopic(DEFAULT_TOPIC);
+        return handler;
+    }
+
+    @MessagingGateway(defaultRequestChannel = CHANNEL_NAME_OUT)
+    public interface EgMqttPublishGateway {
+
+        void sendToMqtt(@Header(MqttHeaders.TOPIC) String topic, @Header(MqttHeaders.QOS) int qos, String payload);
+    }
+}

+ 81 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/mqtt/EgMqttProperties.java

@@ -0,0 +1,81 @@
+package com.usky.eg.mqtt;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * 远程升级 MQTT 下发(服务端发布,终端 {@code connectWithDevice} 订阅)。
+ * <p>
+ * 配置前缀 {@code eg.mqtt};{@link com.usky.eg.controller.web.EgDeviceAdbController#remoteUpgrade} 仅通过本配置连接 Broker 并发布。
+ */
+@ConfigurationProperties(prefix = "eg.mqtt")
+public class EgMqttProperties {
+
+    /** 例如 tcp://47.98.201.73:1883 */
+    private String url = "tcp://47.98.201.73:1883";
+
+    private String username = "usky";
+
+    private String password = "usky";
+
+    /**
+     * 发布主题模板,须包含占位符 {@code {deviceId}},与终端 {@code deviceId: getSerial()} 一致。
+     * 示例:usky/device/{deviceId}/command
+     */
+    private String commandTopicTemplate = "usky/device/{deviceId}/command";
+
+    /** 发布 QoS,0/1/2 */
+    private int qos = 1;
+
+    /**
+     * 下发 JSON 中 {@code type} 字段,终端可在 {@code onCommand} 里按类型分支。
+     */
+    private String commandType = "egRemoteUpgrade";
+
+    public String getUrl() {
+        return url;
+    }
+
+    public void setUrl(String url) {
+        this.url = url;
+    }
+
+    public String getUsername() {
+        return username;
+    }
+
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    public String getPassword() {
+        return password;
+    }
+
+    public void setPassword(String password) {
+        this.password = password;
+    }
+
+    public String getCommandTopicTemplate() {
+        return commandTopicTemplate;
+    }
+
+    public void setCommandTopicTemplate(String commandTopicTemplate) {
+        this.commandTopicTemplate = commandTopicTemplate;
+    }
+
+    public int getQos() {
+        return qos;
+    }
+
+    public void setQos(int qos) {
+        this.qos = qos;
+    }
+
+    public String getCommandType() {
+        return commandType;
+    }
+
+    public void setCommandType(String commandType) {
+        this.commandType = commandType;
+    }
+}

+ 20 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/service/EgDeviceAdbService.java

@@ -0,0 +1,20 @@
+package com.usky.eg.service;
+
+import com.usky.eg.service.vo.EgDeviceAdbResultVO;
+
+/**
+ * 服务端调用官方 adb(部署机需安装 Android Platform Tools 且能访问设备网段)
+ */
+public interface EgDeviceAdbService {
+
+    EgDeviceAdbResultVO connect(String deviceIp, Integer devicePort);
+
+    EgDeviceAdbResultVO disconnect(String deviceIp, Integer devicePort);
+
+    EgDeviceAdbResultVO tcpip(String deviceIp, Integer devicePort, Integer listenPort);
+
+    /**
+     * exec-out screencap -p 原始 PNG 字节;调用方需保证已与设备建立 adb 会话(通常先 connect)
+     */
+    byte[] screenshot(String deviceIp, Integer devicePort);
+}

+ 19 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/service/EgDeviceRemoteUpgradeMqttService.java

@@ -0,0 +1,19 @@
+package com.usky.eg.service;
+
+import com.usky.eg.service.vo.EgDeviceAdbRemoteUpgradeBatchItemVO;
+import com.usky.eg.service.vo.EgDeviceAdbRemoteUpgradeBatchResultVO;
+import com.usky.eg.service.vo.EgDeviceAdbRemoteUpgradeVO;
+import com.usky.eg.service.vo.EgDeviceAdbResultVO;
+
+import java.util.List;
+
+/**
+ * 通过 MQTT 向终端下发远程升级参数(终端 {@code onCommand} 接收后自行下载安装)。
+ */
+public interface EgDeviceRemoteUpgradeMqttService {
+
+    EgDeviceAdbResultVO publishRemoteUpgrade(EgDeviceAdbRemoteUpgradeVO vo);
+
+    EgDeviceAdbRemoteUpgradeBatchResultVO publishRemoteUpgradeBatch(List<EgDeviceAdbRemoteUpgradeBatchItemVO> items,
+                                                                    String fileUrl, String launchPackage);
+}

+ 325 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/service/impl/EgDeviceAdbServiceImpl.java

@@ -0,0 +1,325 @@
+package com.usky.eg.service.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.StringUtils;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.eg.adb.EgDeviceAdbProperties;
+import com.usky.eg.service.EgDeviceAdbService;
+import com.usky.eg.service.vo.EgDeviceAdbResultVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 通过 {@link ProcessBuilder} 调用本机 PATH 中的官方 adb。
+ */
+@Slf4j
+@Service
+public class EgDeviceAdbServiceImpl implements EgDeviceAdbService {
+
+    private static final int BUFFER = 16384;
+
+    /**
+     * Windows 下 adb 常输出系统本地编码(如 GBK),勿固定按 UTF-8 解码以免日志/接口里出现乱码。
+     */
+    private static final Charset ADB_CONSOLE_CHARSET = Charset.defaultCharset();
+
+    private static final String NETWORK_TIMEOUT_HINT =
+            " 【排查】运行本服务的机器须与设备网络互通并能访问目标 TCP 端口(常见 5555);"
+                    + "检查防火墙/安全组/跨网段路由;确认手机「无线调试」已开启且端口与请求一致;"
+                    + "Android 11+ 有时需先用系统界面配对,端口可能不是 5555;"
+                    + "错误 10060 表示连接超时,多为网络不可达或端口未监听。";
+
+    @Autowired
+    private EgDeviceAdbProperties properties;
+
+    @Override
+    public EgDeviceAdbResultVO connect(String deviceIp, Integer devicePort) {
+        validateEndpoint(deviceIp, devicePort);
+        String endpoint = toEndpoint(deviceIp, devicePort);
+        try {
+            String out = runAdbMergedOutput(properties.getCommandTimeoutSeconds(),
+                    Arrays.asList(properties.getExecutable(), "connect", endpoint));
+            return EgDeviceAdbResultVO.ok(out);
+        } catch (Exception e) {
+            log.warn("adb connect failed: {}", endpoint, e);
+            return EgDeviceAdbResultVO.fail(e.getMessage() != null ? e.getMessage() : e.toString());
+        }
+    }
+
+    @Override
+    public EgDeviceAdbResultVO disconnect(String deviceIp, Integer devicePort) {
+        validateEndpoint(deviceIp, devicePort);
+        String endpoint = toEndpoint(deviceIp, devicePort);
+        try {
+            String out = runAdbMergedOutput(properties.getCommandTimeoutSeconds(),
+                    Arrays.asList(properties.getExecutable(), "disconnect", endpoint));
+            return EgDeviceAdbResultVO.ok(out);
+        } catch (Exception e) {
+            log.warn("adb disconnect failed: {}", endpoint, e);
+            return EgDeviceAdbResultVO.fail(e.getMessage() != null ? e.getMessage() : e.toString());
+        }
+    }
+
+    @Override
+    public EgDeviceAdbResultVO tcpip(String deviceIp, Integer devicePort, Integer listenPort) {
+        validateEndpoint(deviceIp, devicePort);
+        if (listenPort == null || listenPort < 1 || listenPort > 65535) {
+            throw new BusinessException("port 无效(tcpip 监听端口)");
+        }
+        String serial = toSerial(deviceIp, devicePort);
+        EgDeviceAdbResultVO first = runTcpipOnce(serial, listenPort);
+        if (EgDeviceAdbResultVO.STATUS_SUCCESS.equals(first.getStatus())) {
+            return first;
+        }
+        if (!properties.isTcpipRetryAfterConnect()) {
+            return first;
+        }
+        log.info("adb tcpip 首次失败,尝试 connect 后重试: {}", serial);
+        EgDeviceAdbResultVO c = connect(deviceIp, devicePort);
+        if (!EgDeviceAdbResultVO.STATUS_SUCCESS.equals(c.getStatus())) {
+            return EgDeviceAdbResultVO.fail("tcpip 失败且自动 connect 未成功: " + first.getDetail() + " | connect: " + c.getDetail());
+        }
+        return runTcpipOnce(serial, listenPort);
+    }
+
+    private EgDeviceAdbResultVO runTcpipOnce(String serial, int listenPort) {
+        try {
+            String out = runAdbMergedOutput(properties.getCommandTimeoutSeconds(),
+                    Arrays.asList(properties.getExecutable(), "-s", serial, "tcpip", String.valueOf(listenPort)));
+            return EgDeviceAdbResultVO.ok(out);
+        } catch (Exception e) {
+            log.warn("adb tcpip failed: {} {}", serial, listenPort, e);
+            return EgDeviceAdbResultVO.fail(e.getMessage() != null ? e.getMessage() : e.toString());
+        }
+    }
+
+    @Override
+    public byte[] screenshot(String deviceIp, Integer devicePort) {
+        validateEndpoint(deviceIp, devicePort);
+        String serial = toSerial(deviceIp, devicePort);
+        if (properties.isScreenshotConnectFirst()) {
+            try {
+                ensureAdbDeviceOnline(deviceIp, devicePort);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new BusinessException("设备未就绪,无法截屏: " + e.getMessage());
+            } catch (IOException e) {
+                throw new BusinessException("设备未就绪,无法截屏: " + e.getMessage());
+            }
+        }
+        List<String> cmd = Arrays.asList(
+                properties.getExecutable(),
+                "-s", serial,
+                "exec-out", "screencap", "-p");
+        try {
+            byte[] png = runAdbBinaryStdout(cmd, properties.getScreenshotTimeoutSeconds());
+            checkPng(png);
+            return png;
+        } catch (Exception e) {
+            log.warn("adb screencap failed: {}", serial, e);
+            throw new BusinessException("截屏失败: " + e.getMessage()
+                    + "(请确认设备已开无线调试、端口正确,或先调用 /egDevice/adb/connect)");
+        }
+    }
+
+    /**
+     * 让本机 adb 真正识别该 serial:执行 connect、校验 {@code adb devices} 为 {@code device} 状态。
+     * 若仅“假装 connect”或忽略失败,会继续出现 {@code device 'ip:port' not found}。
+     */
+    private void ensureAdbDeviceOnline(String deviceIp, int devicePort) throws IOException, InterruptedException {
+        String serial = toSerial(deviceIp, devicePort);
+        String endpoint = toEndpoint(deviceIp, devicePort);
+
+        String connectOut = runAdbMergedOutput(properties.getCommandTimeoutSeconds(),
+                Arrays.asList(properties.getExecutable(), "connect", endpoint));
+        if (connectOutputIndicatesFailure(connectOut)) {
+            throw new IOException(withTimeoutHint("adb connect 输出表明失败: " + connectOut));
+        }
+
+        if (waitUntilDeviceOnline(serial, 3, 400)) {
+            Thread.sleep(200);
+            return;
+        }
+
+        log.warn("首次 connect 后 devices 仍未就绪,重试 connect: {}", endpoint);
+        connectOut = runAdbMergedOutput(properties.getCommandTimeoutSeconds(),
+                Arrays.asList(properties.getExecutable(), "connect", endpoint));
+        if (connectOutputIndicatesFailure(connectOut)) {
+            throw new IOException(withTimeoutHint("adb connect(重试)失败: " + connectOut));
+        }
+        if (!waitUntilDeviceOnline(serial, 5, 500)) {
+            String devicesList = runAdbMergedOutput(properties.getCommandTimeoutSeconds(),
+                    Arrays.asList(properties.getExecutable(), "devices", "-l"));
+            throw new IOException(withTimeoutHint("adb devices 中无可用设备 " + serial
+                    + "。connect 输出: " + connectOut + ";当前列表:\n" + devicesList));
+        }
+        Thread.sleep(200);
+    }
+
+    private boolean waitUntilDeviceOnline(String serial, int attempts, long gapMs) throws IOException, InterruptedException {
+        for (int i = 0; i < attempts; i++) {
+            if (i > 0) {
+                Thread.sleep(gapMs);
+            }
+            String list = runAdbMergedOutput(properties.getCommandTimeoutSeconds(),
+                    Arrays.asList(properties.getExecutable(), "devices"));
+            String state = findSerialState(list, serial);
+            if ("device".equals(state)) {
+                return true;
+            }
+            if ("unauthorized".equals(state)) {
+                throw new IOException("设备已发现但未授权调试,请在设备上点「允许 USB 调试」: " + serial);
+            }
+            if ("offline".equals(state)) {
+                log.warn("设备当前为 offline: {}", serial);
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 解析 {@code adb devices},返回 serial 对应第二列状态;未找到返回 null
+     */
+    private static String findSerialState(String devicesOutput, String serial) {
+        if (devicesOutput == null) {
+            return null;
+        }
+        for (String raw : devicesOutput.split("\\r?\\n")) {
+            String line = raw.trim();
+            if (line.isEmpty() || line.startsWith("List of devices")) {
+                continue;
+            }
+            String[] parts = line.split("\\s+");
+            if (parts.length >= 2 && serial.equals(parts[0])) {
+                return parts[1];
+            }
+        }
+        return null;
+    }
+
+    private static boolean connectOutputIndicatesFailure(String connectOut) {
+        if (connectOut == null || connectOut.isEmpty()) {
+            return false;
+        }
+        String s = connectOut.toLowerCase();
+        return s.contains("cannot connect")
+                || s.contains("failed to connect")
+                || s.contains("unable to connect")
+                || s.contains("connection refused")
+                || s.contains("10061")
+                || s.contains("10060")
+                || s.contains("timed out")
+                || connectOut.contains("连接尝试失败")
+                || connectOut.contains("没有正确答复");
+    }
+
+    private static String withTimeoutHint(String message) {
+        if (message == null) {
+            return NETWORK_TIMEOUT_HINT.trim();
+        }
+        String m = message;
+        String lower = m.toLowerCase();
+        if (lower.contains("10060") || lower.contains("10061") || lower.contains("cannot connect")
+                || lower.contains("timed out") || m.contains("连接尝试失败") || m.contains("没有正确答复")) {
+            return m + NETWORK_TIMEOUT_HINT;
+        }
+        return m;
+    }
+
+    /**
+     * adb 文本类输出(stdout/stderr)按 JVM 默认字符集解码
+     */
+    private static String decodeAdbConsole(byte[] raw) {
+        if (raw == null || raw.length == 0) {
+            return "";
+        }
+        return new String(raw, ADB_CONSOLE_CHARSET).trim();
+    }
+
+    private static void validateEndpoint(String deviceIp, Integer devicePort) {
+        if (StringUtils.isBlank(deviceIp)) {
+            throw new BusinessException("deviceIp 不能为空");
+        }
+        if (devicePort == null || devicePort < 1 || devicePort > 65535) {
+            throw new BusinessException("devicePort 无效");
+        }
+    }
+
+    private static String toEndpoint(String deviceIp, int devicePort) {
+        return deviceIp.trim() + ":" + devicePort;
+    }
+
+    private static String toSerial(String deviceIp, int devicePort) {
+        return toEndpoint(deviceIp, devicePort);
+    }
+
+    /**
+     * 合并 stdout/stderr,非 0 退出码抛出异常(文本类命令)
+     */
+    private String runAdbMergedOutput(long timeoutSeconds, List<String> command) throws IOException, InterruptedException {
+        ProcessBuilder pb = new ProcessBuilder(command);
+        pb.redirectErrorStream(true);
+        Process p = pb.start();
+        byte[] raw = readAll(p.getInputStream());
+        String text = decodeAdbConsole(raw);
+        boolean finished = p.waitFor(timeoutSeconds, TimeUnit.SECONDS);
+        if (!finished) {
+            p.destroyForcibly();
+            throw new IOException("adb 执行超时(" + timeoutSeconds + "s)");
+        }
+        int code = p.exitValue();
+        if (code != 0) {
+            throw new IOException(withTimeoutHint("adb 退出码 " + code + (text.isEmpty() ? "" : (": " + text))));
+        }
+        return text.isEmpty() ? "OK" : text;
+    }
+
+    private byte[] runAdbBinaryStdout(List<String> command, long timeoutSeconds) throws IOException, InterruptedException {
+        ProcessBuilder pb = new ProcessBuilder(command);
+        pb.redirectErrorStream(false);
+        Process p = pb.start();
+        byte[] out = readAll(p.getInputStream());
+        byte[] err = readAll(p.getErrorStream());
+        boolean finished = p.waitFor(timeoutSeconds, TimeUnit.SECONDS);
+        if (!finished) {
+            p.destroyForcibly();
+            throw new IOException("adb 执行超时(" + timeoutSeconds + "s)");
+        }
+        int code = p.exitValue();
+        String errStr = err.length == 0 ? "" : decodeAdbConsole(err);
+        if (code != 0) {
+            throw new IOException(withTimeoutHint("adb 退出码 " + code + (errStr.isEmpty() ? "" : (": " + errStr))));
+        }
+        if (out.length == 0 && !errStr.isEmpty()) {
+            throw new IOException(withTimeoutHint(errStr));
+        }
+        return out;
+    }
+
+    private static byte[] readAll(InputStream in) throws IOException {
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        byte[] buf = new byte[BUFFER];
+        int n;
+        while ((n = in.read(buf)) != -1) {
+            bos.write(buf, 0, n);
+        }
+        return bos.toByteArray();
+    }
+
+    private static void checkPng(byte[] data) {
+        if (data == null || data.length < 24) {
+            throw new BusinessException("截屏数据无效(过小)");
+        }
+        if (data[0] != (byte) 0x89 || data[1] != 0x50 || data[2] != 0x4E || data[3] != 0x47) {
+            throw new BusinessException("截屏数据不是有效的 PNG");
+        }
+    }
+}

+ 173 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/service/impl/EgDeviceRemoteUpgradeMqttServiceImpl.java

@@ -0,0 +1,173 @@
+package com.usky.eg.service.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.toolkit.StringUtils;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.eg.mqtt.EgMqttOutboundConfig.EgMqttPublishGateway;
+import com.usky.eg.mqtt.EgMqttProperties;
+import com.usky.eg.service.EgDeviceRemoteUpgradeMqttService;
+import com.usky.eg.service.vo.EgDeviceAdbRemoteUpgradeBatchItemVO;
+import com.usky.eg.service.vo.EgDeviceAdbRemoteUpgradeBatchResultVO;
+import com.usky.eg.service.vo.EgDeviceAdbRemoteUpgradeItemResultVO;
+import com.usky.eg.service.vo.EgDeviceAdbRemoteUpgradeVO;
+import com.usky.eg.service.vo.EgDeviceAdbResultVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+@Slf4j
+@Service
+public class EgDeviceRemoteUpgradeMqttServiceImpl implements EgDeviceRemoteUpgradeMqttService {
+
+    @Autowired
+    private EgMqttPublishGateway mqttPublishGateway;
+
+    @Autowired
+    private EgMqttProperties mqttProperties;
+
+    @Override
+    public EgDeviceAdbResultVO publishRemoteUpgrade(EgDeviceAdbRemoteUpgradeVO vo) {
+        if (vo == null) {
+            return EgDeviceAdbResultVO.fail("请求体为空");
+        }
+        if (StringUtils.isBlank(vo.getFileUrl())) {
+            throw new BusinessException("fileUrl 不能为空");
+        }
+        String urlTrim = vo.getFileUrl().trim();
+        if (!urlTrim.startsWith("http://") && !urlTrim.startsWith("https://")) {
+            throw new BusinessException("fileUrl 须为 http 或 https 地址");
+        }
+        String deviceId = resolveDeviceId(vo.getDeviceId(), vo.getDeviceCode());
+        if (StringUtils.isBlank(deviceId)) {
+            throw new BusinessException("MQTT 下发须指定 deviceId 或 deviceCode(与终端 connectWithDevice.deviceId 一致)");
+        }
+        String topic = buildTopic(deviceId);
+        String cmdId = UUID.randomUUID().toString();
+        String payload = buildPayload(cmdId, urlTrim, vo.getLaunchPackage(),
+                vo.getDeviceCode(), vo.getDeviceIp(), vo.getDevicePort());
+        return publishOne(topic, payload, cmdId);
+    }
+
+    @Override
+    public EgDeviceAdbRemoteUpgradeBatchResultVO publishRemoteUpgradeBatch(List<EgDeviceAdbRemoteUpgradeBatchItemVO> items,
+                                                                           String fileUrl, String launchPackage) {
+        if (items == null || items.isEmpty()) {
+            throw new BusinessException("items 不能为空");
+        }
+        if (StringUtils.isBlank(fileUrl)) {
+            throw new BusinessException("fileUrl 不能为空");
+        }
+        String urlTrim = fileUrl.trim();
+        if (!urlTrim.startsWith("http://") && !urlTrim.startsWith("https://")) {
+            throw new BusinessException("fileUrl 须为 http 或 https 地址");
+        }
+        List<EgDeviceAdbRemoteUpgradeItemResultVO> rows = new ArrayList<>(items.size());
+        int success = 0;
+        int fail = 0;
+        int index = 0;
+        for (EgDeviceAdbRemoteUpgradeBatchItemVO item : items) {
+            EgDeviceAdbResultVO r;
+            String mqttTopic = null;
+            String mqttCmdId = null;
+            if (item == null) {
+                r = EgDeviceAdbResultVO.fail("请求项为空");
+            } else {
+                String deviceId = resolveDeviceId(item.getDeviceId(), item.getDeviceCode());
+                if (StringUtils.isBlank(deviceId)) {
+                    r = EgDeviceAdbResultVO.fail("须指定 deviceId 或 deviceCode");
+                } else {
+                    try {
+                        String topic = buildTopic(deviceId);
+                        String cmdId = UUID.randomUUID().toString();
+                        String payload = buildPayload(cmdId, urlTrim, launchPackage,
+                                item.getDeviceCode(), item.getDeviceIp(), item.getDevicePort());
+                        r = publishOne(topic, payload, cmdId);
+                        mqttTopic = topic;
+                        mqttCmdId = cmdId;
+                    } catch (BusinessException e) {
+                        r = EgDeviceAdbResultVO.fail(e.getMessage() != null ? e.getMessage() : "参数错误");
+                    } catch (Exception e) {
+                        log.warn("mqtt remote upgrade publish failed index={} deviceId={}", index, deviceId, e);
+                        r = EgDeviceAdbResultVO.fail(e.getMessage() != null ? e.getMessage() : e.toString());
+                    }
+                }
+            }
+            if (EgDeviceAdbResultVO.STATUS_SUCCESS.equals(r.getStatus())) {
+                success++;
+            } else {
+                fail++;
+            }
+            String code = item != null ? item.getDeviceCode() : null;
+            String ip = item != null ? item.getDeviceIp() : null;
+            String port = item != null ? item.getDevicePort() : null;
+            String routeId = item != null ? resolveDeviceId(item.getDeviceId(), item.getDeviceCode()) : null;
+            rows.add(new EgDeviceAdbRemoteUpgradeItemResultVO(index++, code, ip, port, routeId, mqttTopic, mqttCmdId, r));
+        }
+        return new EgDeviceAdbRemoteUpgradeBatchResultVO(items.size(), success, fail, rows);
+    }
+
+    private EgDeviceAdbResultVO publishOne(String topic, String payload, String cmdId) {
+        try {
+            int qos = clampQos(mqttProperties.getQos());
+            mqttPublishGateway.sendToMqtt(topic, qos, payload);
+            log.info("mqtt remote upgrade published topic={} cmdId={}", topic, cmdId);
+            return EgDeviceAdbResultVO.ok("topic=" + topic + "; cmdId=" + cmdId);
+        } catch (Exception e) {
+            log.warn("mqtt publish failed topic={} cmdId={}", topic, cmdId, e);
+            return EgDeviceAdbResultVO.fail(e.getMessage() != null ? e.getMessage() : e.toString());
+        }
+    }
+
+    private String buildPayload(String cmdId, String fileUrl, String launchPackage,
+                                String deviceCode, String deviceIp, String devicePort) {
+        JSONObject o = new JSONObject();
+        o.put("id", cmdId);
+        o.put("type", mqttProperties.getCommandType() != null ? mqttProperties.getCommandType() : "egRemoteUpgrade");
+        o.put("fileUrl", fileUrl.trim());
+        if (StringUtils.isNotBlank(launchPackage)) {
+            o.put("launchPackage", launchPackage.trim());
+        }
+        if (StringUtils.isNotBlank(deviceCode)) {
+            o.put("deviceCode", deviceCode.trim());
+        }
+        if (StringUtils.isNotBlank(deviceIp)) {
+            o.put("deviceIp", deviceIp.trim());
+        }
+        if (StringUtils.isNotBlank(devicePort)) {
+            o.put("devicePort", devicePort.trim());
+        }
+        return o.toJSONString();
+    }
+
+    private static String resolveDeviceId(String deviceId, String deviceCode) {
+        if (StringUtils.isNotBlank(deviceId)) {
+            return deviceId.trim();
+        }
+        if (StringUtils.isNotBlank(deviceCode)) {
+            return deviceCode.trim();
+        }
+        return null;
+    }
+
+    private String buildTopic(String deviceId) {
+        String template = mqttProperties.getCommandTopicTemplate();
+        if (StringUtils.isBlank(template) || !template.contains("{deviceId}")) {
+            throw new BusinessException("eg.mqtt.command-topic-template 须为非空且包含占位符 {deviceId}");
+        }
+        return template.replace("{deviceId}", deviceId);
+    }
+
+    private static int clampQos(int qos) {
+        if (qos < 0) {
+            return 0;
+        }
+        if (qos > 2) {
+            return 2;
+        }
+        return qos;
+    }
+}

+ 13 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/service/vo/EgDeviceAdbEndpointVO.java

@@ -0,0 +1,13 @@
+package com.usky.eg.service.vo;
+
+import lombok.Data;
+
+/**
+ * adb connect / disconnect 请求体
+ */
+@Data
+public class EgDeviceAdbEndpointVO {
+
+    private String deviceIp;
+    private Integer devicePort;
+}

+ 19 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/service/vo/EgDeviceAdbRemoteUpgradeBatchItemVO.java

@@ -0,0 +1,19 @@
+package com.usky.eg.service.vo;
+
+import lombok.Data;
+
+/**
+ * 批量远程升级中的单台设备:设备标识与连接信息;MQTT 模式须含 {@link #deviceId} 或 {@link #deviceCode}。
+ * 安装包与启动参数与 {@link EgDeviceAdbRemoteUpgradeVO} 中 {@code fileUrl}、{@code launchPackage} 同级。
+ */
+@Data
+public class EgDeviceAdbRemoteUpgradeBatchItemVO {
+
+    /** MQTT 路由用,未传时使用 {@link #deviceCode} */
+    private String deviceId;
+
+    private String deviceCode;
+    private String deviceIp;
+    /** 无线调试端口,缺省由服务端按 5555 处理 */
+    private String devicePort;
+}

+ 21 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/service/vo/EgDeviceAdbRemoteUpgradeBatchResultVO.java

@@ -0,0 +1,21 @@
+package com.usky.eg.service.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 批量远程升级汇总结果。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class EgDeviceAdbRemoteUpgradeBatchResultVO {
+
+    private int total;
+    private int successCount;
+    private int failCount;
+    private List<EgDeviceAdbRemoteUpgradeItemResultVO> items;
+}

+ 34 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/service/vo/EgDeviceAdbRemoteUpgradeItemResultVO.java

@@ -0,0 +1,34 @@
+package com.usky.eg.service.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 批量远程升级中单条设备的结果(与请求 {@code items} 下标对应)。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class EgDeviceAdbRemoteUpgradeItemResultVO {
+
+    /** 与请求体 {@code items} 数组下标一致,从 0 开始 */
+    private int index;
+    private String deviceCode;
+    private String deviceIp;
+    private String devicePort;
+    /**
+     * MQTT 下发时:用于路由的主题中的设备标识(与请求 deviceId 或 deviceCode 解析结果一致)。
+     */
+    private String mqttRouteDeviceId;
+    /** MQTT 下发成功时填充 */
+    private String mqttTopic;
+    /** MQTT 下发成功时填充,与下行 JSON 中 {@code id} 一致 */
+    private String mqttCmdId;
+    private EgDeviceAdbResultVO result;
+
+    public EgDeviceAdbRemoteUpgradeItemResultVO(int index, String deviceCode, String deviceIp, String devicePort,
+                                                EgDeviceAdbResultVO result) {
+        this(index, deviceCode, deviceIp, devicePort, null, null, null, result);
+    }
+}

+ 36 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/service/vo/EgDeviceAdbRemoteUpgradeVO.java

@@ -0,0 +1,36 @@
+package com.usky.eg.service.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * MQTT 远程升级请求体:向 Broker 发布下行 JSON(终端 {@code onCommand} 接收后自行下载安装)。
+ * <p>
+ * {@code fileUrl} 必填(http/https)。须指定 {@code deviceId} 或与终端序列号一致的 {@code deviceCode},用于主题
+ * {@code eg.mqtt.command-topic-template} 中的占位符 {@code {deviceId}}。
+ * {@code deviceIp}、{@code devicePort} 可选,会一并写入下行 JSON 供终端展示或日志。
+ * <p>
+ * 批量:当 {@code items} 非空时,与本对象同级的 {@code fileUrl}、{@code launchPackage} 对所有设备共用;
+ * 忽略顶层 {@code deviceCode}、{@code deviceIp}、{@code devicePort}。{@code items} 每项见 {@link EgDeviceAdbRemoteUpgradeBatchItemVO}。
+ */
+@Data
+public class EgDeviceAdbRemoteUpgradeVO {
+
+    /** 非空且非空列表时启用批量模式 */
+    private List<EgDeviceAdbRemoteUpgradeBatchItemVO> items;
+
+    /**
+     * 与终端 {@code connectWithDevice({ deviceId: getSerial() })} 一致;
+     * 未传时与 {@link #deviceCode} 等价(二选一至少填一个)。
+     */
+    private String deviceId;
+
+    private String deviceCode;
+    private String deviceIp;
+    /** 可选,写入下行 JSON */
+    private String devicePort;
+    private String fileUrl;
+    /** 可选,写入下行 JSON,终端安装成功后自行拉起 */
+    private String launchPackage;
+}

+ 29 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/service/vo/EgDeviceAdbResultVO.java

@@ -0,0 +1,29 @@
+package com.usky.eg.service.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 官方 adb 文本类命令的统一返回(与前端约定 status)
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class EgDeviceAdbResultVO {
+
+    public static final String STATUS_SUCCESS = "SUCCESS";
+    public static final String STATUS_FAIL = "FAIL";
+
+    private String status;
+    /** adb 标准输出/合并日志摘要,便于排查 */
+    private String detail;
+
+    public static EgDeviceAdbResultVO ok(String detail) {
+        return new EgDeviceAdbResultVO(STATUS_SUCCESS, detail == null ? "" : detail.trim());
+    }
+
+    public static EgDeviceAdbResultVO fail(String detail) {
+        return new EgDeviceAdbResultVO(STATUS_FAIL, detail == null ? "" : detail.trim());
+    }
+}

+ 15 - 0
service-eg/service-eg-biz/src/main/java/com/usky/eg/service/vo/EgDeviceAdbTcpipVO.java

@@ -0,0 +1,15 @@
+package com.usky.eg.service.vo;
+
+import lombok.Data;
+
+/**
+ * adb tcpip 请求体(对应 adb -s ip:devicePort tcpip &lt;port&gt;)
+ */
+@Data
+public class EgDeviceAdbTcpipVO {
+
+    private String deviceIp;
+    private Integer devicePort;
+    /** tcpip 命令中的监听端口参数,例如 5555 */
+    private Integer port;
+}

+ 66 - 0
service-eg/service-eg-biz/src/main/resources/bootstrap.yml

@@ -23,3 +23,69 @@ spring:
         # 共享配置
         shared-configs:
           - application-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}
+
+# SmartFace 人脸识别配置
+smartface:
+  # 启用人脸识别功能
+  enabled: true
+  # 是否延迟加载模型(true=首次使用时加载,false=启动时加载)
+  # 如果模型文件还未准备好,建议设置为true,避免启动失败
+  lazy-load: true
+  # 人脸检测配置
+  detection:
+    # 模型类型: MTCNN, RETINA_FACE, YOLOV5_FACE_320, YOLOV5_FACE_640
+    model-type: MTCNN
+    # 人脸检测模型路径(留空,将自动从classpath复制到临时目录)
+    model-path:
+    # 置信度阈值
+    confidence-threshold: 0.5
+    # NMS阈值
+    nms-threshold: 0.4
+    # 设备类型: CPU, GPU
+    device: CPU
+  # 人脸识别配置
+  recognition:
+    # 模型类型: INSIGHT_FACE_IRSE50_MODEL, INSIGHT_FACE_MOBILE_FACENET_MODEL, FACE_NET_MODEL
+    model-type: INSIGHT_FACE_IRSE50_MODEL
+    # 人脸识别模型路径(留空,将自动从classpath复制到临时目录)
+    model-path:
+    # 是否裁剪人脸
+    crop-face: true
+    # 是否启用人脸对齐
+    align: true
+    # 相似度阈值(0-1之间,推荐0.62)
+    similarity-threshold: 0.62
+    # 设备类型: CPU, GPU
+    device: CPU
+  # 向量数据库配置
+  vector-db:
+    # 数据库类型: SQLITE, MILVUS
+    type: SQLITE
+    # SQLite数据库文件路径
+    sqlite-path: ./data/face_db.sqlite
+    # Milvus配置(如果使用Milvus)
+    milvus:
+      host: 127.0.0.1
+      port: 19530
+      collection-name: face_collection
+
+# 服务端调用官方 adb(platform-tools 需在 PATH);详见 EgDeviceAdbController:/egDevice/adb/connect 等
+eg:
+  adb:
+    executable: adb
+    command-timeout-seconds: 45
+    screenshot-timeout-seconds: 60
+    tcpip-retry-after-connect: true
+    # 截屏前先 adb connect,避免服务端 adb 未登记设备 serial
+    screenshot-connect-first: true
+
+  # 远程升级仅 MQTT 下发(与终端 connectWithDevice 订阅主题一致)
+  mqtt:
+    url: tcp://47.98.201.73:1883
+    username: usky
+    password: usky
+    # 须含 {deviceId},与终端 deviceId(如 getSerial())一致
+    command-topic-template: usky/device/{deviceId}/command
+    qos: 1
+    # 下行 JSON 的 type 字段,终端 onCommand 可按此分支
+    command-type: egRemoteUpgrade