自动化立体仓库 - WMS系统
cl
2 天以前 18c51d40be82435289ba3be6bd5f8e15fdf786f7
mqtt
16个文件已添加
7个文件已修改
1090 ■■■■■ 已修改文件
.local/iot-mqtt/asrs-iot-client-mqttsa21wi8dwvkjf1datsiotcn-north-1amazonawscomcn8883/.lck 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/config/ControllerResAdvice.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/integration/iot/client/IotMqttClient.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/integration/iot/handler/impl/IotInboundMessageHandlerImpl.java 42 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/integration/iot/paho/MqttNetworkModuleFactory.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/integration/iot/paho/MqttsNetworkModuleFactory.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/integration/iot/task/IotPendingPublishScheduler.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/iot/config/IotProperties.java 61 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/iot/constant/IotSysConfigCodes.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/iot/controller/IotMqttAdminController.java 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/iot/controller/IotMqttOutboundController.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/iot/service/IotDbConfigService.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/iot/service/impl/IotDbConfigServiceImpl.java 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/META-INF/services/org.eclipse.paho.client.mqttv3.spi.NetworkModuleFactory 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application.yml 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/iot-certs/AmazonRootCA1.pem 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/iot-certs/device-certificate.pem.crt 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/iot-certs/device-private.pem.key 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/iot-certs/device-public.pem.key 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260403_iot_mqtt_sys_config.sql 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/iotMqttOutbound/iotMqttOutbound.js 233 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/iotMqttOutbound/iotMqttOutbound.html 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/test/java/com/zy/integration/iot/IotSupportTest.java 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.local/iot-mqtt/asrs-iot-client-mqttsa21wi8dwvkjf1datsiotcn-north-1amazonawscomcn8883/.lck
src/main/java/com/zy/common/config/ControllerResAdvice.java
@@ -47,14 +47,14 @@
        if (serverHttpRequest instanceof ServletServerHttpRequest) {
            HttpServletRequest request = ((ServletServerHttpRequest) serverHttpRequest).getServletRequest();
            Object appAuth = request.getAttribute("appAuth");
            if (appAuth != null) {
                if (o instanceof R) {
            if (appAuth != null && o instanceof R) {
                    String appkey = request.getHeader("appkey");
                if (Cools.isEmpty(appkey)) {
                    appkey = "-";
                }
                    Object reqCache = request.getAttribute("cache");
                    if (!Cools.isEmpty(appkey)) {
                        boolean success = String.valueOf(((R) o).get("code")).equalsIgnoreCase("200");
                        if (success){
                            // 保存接口日志
                            apiLogService.save(
                                    String.valueOf(appAuth),
                                    request.getRequestURI(),
@@ -74,13 +74,53 @@
                                    JSON.toJSONString(o),
                                    success);
                        }
            } else if (o instanceof R && isInboundThirdPartyUri(request.getRequestURI())) {
                String appkey = request.getHeader("appkey");
                if (Cools.isEmpty(appkey)) {
                    appkey = "-";
                    }
                Object reqCache = request.getAttribute("cache");
                boolean success = String.valueOf(((R) o).get("code")).equalsIgnoreCase("200");
                String ns = inboundNamespace(request.getRequestURI());
                if (success) {
                    apiLogService.save(
                            ns,
                            request.getRequestURI(),
                            appkey,
                            IpTools.gainRealIp(request),
                            reqCache == null ? "" : JSON.toJSONString(reqCache),
                            JSON.toJSONString(o),
                            success
                    );
                } else {
                    beforeBodyWriteCallApiLogSave(
                            ns,
                            request.getRequestURI(),
                            appkey,
                            IpTools.gainRealIp(request),
                            reqCache == null ? "" : JSON.toJSONString(reqCache),
                            JSON.toJSONString(o),
                            success);
                }
            }
        }
        return o;
    }
    private static boolean isInboundThirdPartyUri(String uri) {
        if (uri == null) {
            return false;
        }
        return uri.contains("/open/asrs") || uri.contains("/wcs/openapi/report");
    }
    private static String inboundNamespace(String uri) {
        if (uri != null && uri.contains("/wcs/openapi/report")) {
            return "WCS回写";
        }
        return "开放接口";
    }
    public void beforeBodyWriteCallApiLogSave(String name, String url, String appkey, String ip, String request, String response, boolean success) {
        ApiLogService apiLogService = SpringUtils.getBean(ApiLogService.class);
        String memo = response;
src/main/java/com/zy/integration/iot/client/IotMqttClient.java
@@ -7,4 +7,7 @@
    void publish(String topic, String payload) throws Exception;
    boolean isConnected();
    /** 重新读库并断开/重建连接,用于后台改配置后生效。 */
    void reconnectFromDbConfig();
}
src/main/java/com/zy/integration/iot/handler/impl/IotInboundMessageHandlerImpl.java
@@ -1,13 +1,15 @@
package com.zy.integration.iot.handler.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONException;
import com.core.common.Cools;
import com.zy.integration.iot.biz.IotInstructionService;
import com.zy.integration.iot.handler.IotInboundMessageHandler;
import com.zy.integration.iot.publish.IotPublishService;
import com.zy.iot.config.IotProperties;
import com.zy.iot.service.IotDbConfigService;
import com.zy.iot.entity.IotFeedbackMessage;
import com.zy.iot.entity.IotInstructionMessage;
import com.zy.iot.entity.IotTopicConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -21,7 +23,7 @@
public class IotInboundMessageHandlerImpl implements IotInboundMessageHandler {
    @Autowired
    private IotProperties iotProperties;
    private IotDbConfigService iotDbConfigService;
    @Autowired
    private IotInstructionService iotInstructionService;
    @Autowired
@@ -32,17 +34,30 @@
     */
    @Override
    public void handleMessage(String topic, String payload) {
        if (!iotProperties.isEnabled() || Cools.isEmpty(topic) || payload == null) {
        if (!iotDbConfigService.isMqttEnabled() || Cools.isEmpty(topic) || payload == null) {
            return;
        }
        try {
            IotTopicConfig topics = iotDbConfigService.getEffectiveTopics();
            Long recordId = null;
            if (topic.equals(iotProperties.getTopics().getEgressStow())) {
                recordId = iotInstructionService.handleStowInstruction(JSON.parseObject(payload, IotInstructionMessage.class), topic, payload);
            } else if (topic.equals(iotProperties.getTopics().getEgressPick())) {
                recordId = iotInstructionService.handlePickInstruction(JSON.parseObject(payload, IotInstructionMessage.class), topic, payload);
            } else if (topic.equals(iotProperties.getTopics().getEgressFeedback())) {
                iotInstructionService.handleFeedbackAck(JSON.parseObject(payload, IotFeedbackMessage.class), topic, payload);
            if (topic.equals(topics.getEgressStow())) {
                IotInstructionMessage stow = parseJsonPayload(payload, topic, IotInstructionMessage.class);
                if (stow == null) {
                    return;
                }
                recordId = iotInstructionService.handleStowInstruction(stow, topic, payload);
            } else if (topic.equals(topics.getEgressPick())) {
                IotInstructionMessage pick = parseJsonPayload(payload, topic, IotInstructionMessage.class);
                if (pick == null) {
                    return;
                }
                recordId = iotInstructionService.handlePickInstruction(pick, topic, payload);
            } else if (topic.equals(topics.getEgressFeedback())) {
                IotFeedbackMessage ack = parseJsonPayload(payload, topic, IotFeedbackMessage.class);
                if (ack == null) {
                    return;
                }
                iotInstructionService.handleFeedbackAck(ack, topic, payload);
            } else {
                log.warn("ignore unknown iot topic={}, payload={}", topic, payload);
            }
@@ -54,4 +69,13 @@
            log.error("handle iot inbound message failed, topic={}, payload={}", topic, payload, e);
        }
    }
    private <T> T parseJsonPayload(String payload, String topic, Class<T> type) {
        try {
            return JSON.parseObject(payload, type);
        } catch (JSONException e) {
            log.warn("IoT MQTT 非合法 JSON topic={} payload={}", topic, payload, e);
            return null;
        }
    }
}
src/main/java/com/zy/integration/iot/paho/MqttNetworkModuleFactory.java
New file
@@ -0,0 +1,50 @@
package com.zy.integration.iot.paho;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
import org.eclipse.paho.client.mqttv3.internal.NetworkModule;
import org.eclipse.paho.client.mqttv3.internal.TCPNetworkModuleFactory;
import org.eclipse.paho.client.mqttv3.spi.NetworkModuleFactory;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collections;
import java.util.Set;
/**
 * Paho 仅内置 ssl/tcp/ws/wss,通过 SPI 增加 mqtt,与 tcp 等价建连。
 */
public final class MqttNetworkModuleFactory implements NetworkModuleFactory {
    private static final TCPNetworkModuleFactory DELEGATE = new TCPNetworkModuleFactory();
    @Override
    public Set<String> getSupportedUriSchemes() {
        return Collections.singleton("mqtt");
    }
    @Override
    public void validateURI(URI uri) throws IllegalArgumentException {
        DELEGATE.validateURI(toTcpUri(uri));
    }
    @Override
    public NetworkModule createNetworkModule(URI uri, MqttConnectOptions options, String clientId) throws MqttException {
        return DELEGATE.createNetworkModule(toTcpUri(uri), options, clientId);
    }
    private static URI toTcpUri(URI uri) {
        try {
            return new URI(
                    "tcp",
                    uri.getRawUserInfo(),
                    uri.getHost(),
                    uri.getPort(),
                    uri.getRawPath(),
                    uri.getRawQuery(),
                    uri.getRawFragment());
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException(e);
        }
    }
}
src/main/java/com/zy/integration/iot/paho/MqttsNetworkModuleFactory.java
New file
@@ -0,0 +1,50 @@
package com.zy.integration.iot.paho;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
import org.eclipse.paho.client.mqttv3.internal.NetworkModule;
import org.eclipse.paho.client.mqttv3.internal.SSLNetworkModuleFactory;
import org.eclipse.paho.client.mqttv3.spi.NetworkModuleFactory;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collections;
import java.util.Set;
/**
 * Paho 仅内置 ssl/tcp/ws/wss,通过 SPI 增加 mqtts,与 ssl 等价建连。
 */
public final class MqttsNetworkModuleFactory implements NetworkModuleFactory {
    private static final SSLNetworkModuleFactory DELEGATE = new SSLNetworkModuleFactory();
    @Override
    public Set<String> getSupportedUriSchemes() {
        return Collections.singleton("mqtts");
    }
    @Override
    public void validateURI(URI uri) throws IllegalArgumentException {
        DELEGATE.validateURI(toSslUri(uri));
    }
    @Override
    public NetworkModule createNetworkModule(URI uri, MqttConnectOptions options, String clientId) throws MqttException {
        return DELEGATE.createNetworkModule(toSslUri(uri), options, clientId);
    }
    private static URI toSslUri(URI uri) {
        try {
            return new URI(
                    "ssl",
                    uri.getRawUserInfo(),
                    uri.getHost(),
                    uri.getPort(),
                    uri.getRawPath(),
                    uri.getRawQuery(),
                    uri.getRawFragment());
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException(e);
        }
    }
}
src/main/java/com/zy/integration/iot/task/IotPendingPublishScheduler.java
@@ -1,7 +1,7 @@
package com.zy.integration.iot.task;
import com.zy.integration.iot.publish.IotPublishService;
import com.zy.iot.config.IotProperties;
import com.zy.iot.service.IotDbConfigService;
import com.zy.iot.entity.IotPublishRecord;
import com.zy.iot.service.IotPublishRecordService;
import lombok.extern.slf4j.Slf4j;
@@ -20,7 +20,7 @@
public class IotPendingPublishScheduler {
    @Autowired
    private IotProperties iotProperties;
    private IotDbConfigService iotDbConfigService;
    @Autowired
    private IotPublishRecordService iotPublishRecordService;
    @Autowired
@@ -31,7 +31,7 @@
     */
    @Scheduled(cron = "0/5 * * * * ? ")
    private void execute() {
        if (!iotProperties.isEnabled()) {
        if (!iotDbConfigService.isMqttEnabled()) {
            return;
        }
        List<IotPublishRecord> records = iotPublishRecordService.selectPendingPublishes(50);
src/main/java/com/zy/iot/config/IotProperties.java
@@ -1,12 +1,16 @@
package com.zy.iot.config;
import com.zy.iot.entity.IotTopicConfig;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
@Data
@Component
@@ -43,27 +47,70 @@
    private Map<String, Integer> pickStationMappings = new LinkedHashMap<>();
    @Getter(AccessLevel.NONE)
    private volatile String resolvedClientIdCache;
    public String getResolvedClientId() {
        if (clientId != null && clientId.trim().length() > 0) {
            return clientId.trim();
        if (resolvedClientIdCache != null) {
            return resolvedClientIdCache;
        }
        return thingName;
        synchronized (this) {
            if (resolvedClientIdCache != null) {
                return resolvedClientIdCache;
            }
            String base = null;
            if (clientId != null && clientId.trim().length() > 0) {
                base = clientId.trim();
            } else if (thingName != null && thingName.trim().length() > 0) {
                base = thingName.trim();
            }
            if (base == null) {
                return null;
            }
            resolvedClientIdCache = base + "-" + newClientIdSuffix();
            return resolvedClientIdCache;
        }
    }
    private static String newClientIdSuffix() {
        long n = ThreadLocalRandom.current().nextLong() & 0xFFFFFFFFL;
        return Long.toHexString(n);
    }
    public boolean isTlsEnabled() {
        String serverUri = getServerUri();
        return serverUri != null && serverUri.startsWith("ssl://");
        return isTlsMqttScheme(getServerUri());
    }
    /** 与 Paho 建连 URI:完整前缀 mqtts/mqtt/wss/ws/ssl/tcp 原样;仅主机名时默认 mqtts 或 mqtt。 */
    public String getServerUri() {
        if (endpoint == null || endpoint.trim().length() == 0) {
            return null;
        }
        String trimmed = endpoint.trim();
        if (trimmed.startsWith("ssl://") || trimmed.startsWith("tcp://")) {
        if (hasExplicitBrokerScheme(trimmed)) {
            return trimmed;
        }
        int resolvedPort = port == null ? 8883 : port;
        return "ssl://" + trimmed + ":" + resolvedPort;
        String scheme = resolvedPort == 1883 ? "mqtt://" : "mqtts://";
        return scheme + trimmed + ":" + resolvedPort;
    }
    private static boolean hasExplicitBrokerScheme(String s) {
        String lower = s.toLowerCase(Locale.ROOT);
        return lower.startsWith("mqtts://") || lower.startsWith("mqtt://")
                || lower.startsWith("ssl://") || lower.startsWith("tcp://")
                || lower.startsWith("wss://") || lower.startsWith("ws://");
    }
    private static boolean isTlsMqttScheme(String serverUri) {
        if (serverUri == null || serverUri.isEmpty()) {
            return false;
        }
        int p = serverUri.indexOf("://");
        if (p <= 0) {
            return false;
        }
        String scheme = serverUri.substring(0, p).toLowerCase(Locale.ROOT);
        return "mqtts".equals(scheme) || "ssl".equals(scheme) || "wss".equals(scheme);
    }
}
src/main/java/com/zy/iot/constant/IotSysConfigCodes.java
New file
@@ -0,0 +1,16 @@
package com.zy.iot.constant;
public final class IotSysConfigCodes {
    private IotSysConfigCodes() {
    }
    public static final String MQTT_ENABLED = "iot.mqtt.enabled";
    public static final String TOPIC_EGRESS_STOW = "iot.topic.egress_stow";
    public static final String TOPIC_EGRESS_PICK = "iot.topic.egress_pick";
    public static final String TOPIC_EGRESS_FEEDBACK = "iot.topic.egress_feedback";
    public static final String TOPIC_INGRESS_STOW = "iot.topic.ingress_stow";
    public static final String TOPIC_INGRESS_PICK = "iot.topic.ingress_pick";
    public static final String TOPIC_INGRESS_FEEDBACK = "iot.topic.ingress_feedback";
}
src/main/java/com/zy/iot/controller/IotMqttAdminController.java
New file
@@ -0,0 +1,97 @@
package com.zy.iot.controller;
import com.alibaba.fastjson.JSONObject;
import com.core.annotations.ManagerAuth;
import com.core.common.Cools;
import com.core.common.R;
import com.zy.common.web.BaseController;
import com.zy.integration.iot.client.IotMqttClient;
import com.zy.iot.constant.IotSysConfigCodes;
import com.zy.iot.service.IotDbConfigService;
import com.zy.system.entity.Config;
import com.zy.system.service.ConfigService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
 * 后台:IoT MQTT 开关与 topic 存 sys_config,重连后订阅生效。
 */
@RestController
public class IotMqttAdminController extends BaseController {
    @Autowired
    private ConfigService configService;
    @Autowired
    private IotDbConfigService iotDbConfigService;
    @Autowired
    private IotMqttClient iotMqttClient;
    @RequestMapping(value = "/iotMqttAdmin/config/auth", method = RequestMethod.GET)
    @ManagerAuth(memo = "IoT MQTT 配置查询")
    public R getConfig() {
        Map<String, Object> data = new LinkedHashMap<>(iotDbConfigService.snapshotForAdmin());
        data.put("mqttConnected", iotMqttClient.isConnected());
        return R.ok(data);
    }
    @RequestMapping(value = "/iotMqttAdmin/config/save/auth", method = RequestMethod.POST)
    @ManagerAuth(memo = "IoT MQTT 配置保存")
    public R saveConfig(@RequestBody JSONObject body) {
        if (body == null) {
            return R.error("参数为空");
        }
        if (body.containsKey("enabled")) {
            Boolean en = body.getBoolean("enabled");
            upsert(IotSysConfigCodes.MQTT_ENABLED, "IoT MQTT 总开关", en == null ? "false" : String.valueOf(en));
        }
        JSONObject topics = body.getJSONObject("topics");
        if (topics != null) {
            putTopicIfPresent(topics, "egressStow", IotSysConfigCodes.TOPIC_EGRESS_STOW, "IoT topic egress stow");
            putTopicIfPresent(topics, "egressPick", IotSysConfigCodes.TOPIC_EGRESS_PICK, "IoT topic egress pick");
            putTopicIfPresent(topics, "egressFeedback", IotSysConfigCodes.TOPIC_EGRESS_FEEDBACK, "IoT topic egress feedback");
            putTopicIfPresent(topics, "ingressStow", IotSysConfigCodes.TOPIC_INGRESS_STOW, "IoT topic ingress stow");
            putTopicIfPresent(topics, "ingressPick", IotSysConfigCodes.TOPIC_INGRESS_PICK, "IoT topic ingress pick");
            putTopicIfPresent(topics, "ingressFeedback", IotSysConfigCodes.TOPIC_INGRESS_FEEDBACK, "IoT topic ingress feedback");
        }
        iotDbConfigService.refreshCache();
        return R.ok();
    }
    private void putTopicIfPresent(JSONObject topics, String key, String code, String name) {
        if (!topics.containsKey(key)) {
            return;
        }
        String v = topics.getString(key);
        if (v == null) {
            return;
        }
        upsert(code, name, v.trim());
    }
    @RequestMapping(value = "/iotMqttAdmin/reconnect/auth", method = RequestMethod.POST)
    @ManagerAuth(memo = "IoT MQTT 重连")
    public R reconnect() {
        iotMqttClient.reconnectFromDbConfig();
        return R.ok();
    }
    private void upsert(String code, String name, String value) {
        if (Cools.isEmpty(code)) {
            return;
        }
        Config existing = configService.selectConfigByCode(code);
        if (existing != null) {
            existing.setValue(value);
            existing.setStatus((short) 1);
            configService.updateById(existing);
        } else {
            configService.insert(new Config(name, code, value, (short) 1, (short) 1));
        }
    }
}
src/main/java/com/zy/iot/controller/IotMqttOutboundController.java
New file
@@ -0,0 +1,89 @@
package com.zy.iot.controller;
import com.baomidou.mybatisplus.mapper.EntityWrapper;
import com.baomidou.mybatisplus.plugins.Page;
import com.core.annotations.ManagerAuth;
import com.core.common.Cools;
import com.core.common.R;
import com.zy.common.web.BaseController;
import com.zy.integration.iot.publish.IotPublishService;
import com.zy.iot.service.IotDbConfigService;
import com.zy.iot.constant.IotConstants;
import com.zy.iot.entity.IotPublishRecord;
import com.zy.iot.service.IotPublishRecordService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
 * 后台:仅出站 MQTT(WMS 发出),支持列表与手动重发。
 */
@RestController
public class IotMqttOutboundController extends BaseController {
    @Autowired
    private IotDbConfigService iotDbConfigService;
    @Autowired
    private IotPublishRecordService iotPublishRecordService;
    @Autowired
    private IotPublishService iotPublishService;
    @RequestMapping(value = "/iotMqttOutbound/list/auth")
    @ManagerAuth(memo = "IoT MQTT 发送记录")
    public R list(@RequestParam(defaultValue = "1") Integer curr,
                  @RequestParam(defaultValue = "16") Integer limit,
                  @RequestParam(required = false) String instruction_id,
                  @RequestParam(required = false) String publish_status,
                  @RequestParam(required = false) String publish_topic,
                  @RequestParam(required = false) String container_id) {
        EntityWrapper<IotPublishRecord> wrapper = new EntityWrapper<>();
        wrapper.eq("direction", IotConstants.DIRECTION_OUTBOUND);
        wrapper.isNotNull("publish_topic");
        wrapper.isNotNull("publish_payload");
        if (!Cools.isEmpty(instruction_id)) {
            wrapper.like("instruction_id", instruction_id);
        }
        if (!Cools.isEmpty(publish_status)) {
            wrapper.eq("publish_status", publish_status);
        }
        if (!Cools.isEmpty(publish_topic)) {
            wrapper.like("publish_topic", publish_topic);
        }
        if (!Cools.isEmpty(container_id)) {
            wrapper.like("container_id", container_id);
        }
        wrapper.orderBy("create_time", false);
        return R.ok(iotPublishRecordService.selectPage(new Page<>(curr, limit), wrapper));
    }
    @RequestMapping(value = "/iotMqttOutbound/resend/auth")
    @ManagerAuth(memo = "IoT MQTT 发送重发")
    public R resend(@RequestParam Long id) {
        if (id == null) {
            return R.error("缺少 id");
        }
        if (!iotDbConfigService.isMqttEnabled()) {
            return R.error("IoT 未开启");
        }
        IotPublishRecord record = iotPublishRecordService.selectById(id);
        if (record == null) {
            return R.error("记录不存在");
        }
        if (!IotConstants.DIRECTION_OUTBOUND.equals(record.getDirection())) {
            return R.error("仅支持出站记录重发");
        }
        if (Cools.isEmpty(record.getPublishTopic()) || Cools.isEmpty(record.getPublishPayload())) {
            return R.error("无发送主题或消息体");
        }
        iotPublishService.publishRecordNow(id);
        record = iotPublishRecordService.selectById(id);
        if (record != null && IotConstants.PUBLISH_STATUS_SUCCESS.equals(record.getPublishStatus())) {
            return R.ok();
        }
        String err = record != null && !Cools.isEmpty(record.getErrorMessage())
                ? record.getErrorMessage()
                : "发送失败";
        return R.error(err);
    }
}
src/main/java/com/zy/iot/service/IotDbConfigService.java
New file
@@ -0,0 +1,17 @@
package com.zy.iot.service;
import com.zy.iot.entity.IotTopicConfig;
import java.util.Map;
public interface IotDbConfigService {
    void refreshCache();
    boolean isMqttEnabled();
    IotTopicConfig getEffectiveTopics();
    /** 当前生效开关与 topic,供后台表单展示。 */
    Map<String, Object> snapshotForAdmin();
}
src/main/java/com/zy/iot/service/impl/IotDbConfigServiceImpl.java
New file
@@ -0,0 +1,132 @@
package com.zy.iot.service.impl;
import com.core.common.Cools;
import com.zy.iot.config.IotProperties;
import com.zy.iot.constant.IotSysConfigCodes;
import com.zy.iot.entity.IotTopicConfig;
import com.zy.iot.service.IotDbConfigService;
import com.zy.system.entity.Config;
import com.zy.system.service.ConfigService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.util.LinkedHashMap;
import java.util.Map;
@Service
public class IotDbConfigServiceImpl implements IotDbConfigService {
    @Autowired
    private ConfigService configService;
    @Autowired
    private IotProperties iotProperties;
    private volatile Snapshot snapshot;
    @PostConstruct
    public void init() {
        refreshCache();
    }
    @Override
    public synchronized void refreshCache() {
        Snapshot s = new Snapshot();
        s.enabled = resolveEnabled();
        s.topics = resolveTopics();
        this.snapshot = s;
    }
    @Override
    public boolean isMqttEnabled() {
        Snapshot s = snapshot;
        return s != null && s.enabled;
    }
    @Override
    public IotTopicConfig getEffectiveTopics() {
        Snapshot s = snapshot;
        if (s == null || s.topics == null) {
            return copyTopics(iotProperties.getTopics());
        }
        return copyTopics(s.topics);
    }
    @Override
    public Map<String, Object> snapshotForAdmin() {
        Snapshot s = snapshot;
        if (s == null) {
            refreshCache();
            s = snapshot;
        }
        Map<String, Object> root = new LinkedHashMap<>();
        root.put("enabled", s != null && s.enabled);
        Map<String, String> topics = new LinkedHashMap<>();
        IotTopicConfig t = s != null && s.topics != null ? s.topics : iotProperties.getTopics();
        topics.put("egressStow", t.getEgressStow());
        topics.put("egressPick", t.getEgressPick());
        topics.put("egressFeedback", t.getEgressFeedback());
        topics.put("ingressStow", t.getIngressStow());
        topics.put("ingressPick", t.getIngressPick());
        topics.put("ingressFeedback", t.getIngressFeedback());
        root.put("topics", topics);
        return root;
    }
    private boolean resolveEnabled() {
        Config c = configService.selectConfigByCode(IotSysConfigCodes.MQTT_ENABLED);
        if (c != null && isConfigActive(c) && !Cools.isEmpty(c.getValue())) {
            String v = c.getValue().trim();
            if ("1".equals(v) || "true".equalsIgnoreCase(v) || "yes".equalsIgnoreCase(v)) {
                return true;
            }
            if ("0".equals(v) || "false".equalsIgnoreCase(v) || "no".equalsIgnoreCase(v)) {
                return false;
            }
        }
        return iotProperties.isEnabled();
    }
    private IotTopicConfig resolveTopics() {
        IotTopicConfig yml = iotProperties.getTopics();
        IotTopicConfig t = new IotTopicConfig();
        t.setEgressStow(orDb(IotSysConfigCodes.TOPIC_EGRESS_STOW, yml.getEgressStow()));
        t.setEgressPick(orDb(IotSysConfigCodes.TOPIC_EGRESS_PICK, yml.getEgressPick()));
        t.setEgressFeedback(orDb(IotSysConfigCodes.TOPIC_EGRESS_FEEDBACK, yml.getEgressFeedback()));
        t.setIngressStow(orDb(IotSysConfigCodes.TOPIC_INGRESS_STOW, yml.getIngressStow()));
        t.setIngressPick(orDb(IotSysConfigCodes.TOPIC_INGRESS_PICK, yml.getIngressPick()));
        t.setIngressFeedback(orDb(IotSysConfigCodes.TOPIC_INGRESS_FEEDBACK, yml.getIngressFeedback()));
        return t;
    }
    private String orDb(String code, String ymlDefault) {
        Config c = configService.selectConfigByCode(code);
        if (c != null && isConfigActive(c) && !Cools.isEmpty(c.getValue())) {
            return c.getValue().trim();
        }
        return ymlDefault;
    }
    private static boolean isConfigActive(Config c) {
        return c.getStatus() != null && c.getStatus() == 1;
    }
    private static IotTopicConfig copyTopics(IotTopicConfig src) {
        if (src == null) {
            return new IotTopicConfig();
        }
        IotTopicConfig c = new IotTopicConfig();
        c.setEgressStow(src.getEgressStow());
        c.setEgressPick(src.getEgressPick());
        c.setEgressFeedback(src.getEgressFeedback());
        c.setIngressStow(src.getIngressStow());
        c.setIngressPick(src.getIngressPick());
        c.setIngressFeedback(src.getIngressFeedback());
        return c;
    }
    private static final class Snapshot {
        private boolean enabled;
        private IotTopicConfig topics;
    }
}
src/main/resources/META-INF/services/org.eclipse.paho.client.mqttv3.spi.NetworkModuleFactory
New file
@@ -0,0 +1,2 @@
com.zy.integration.iot.paho.MqttsNetworkModuleFactory
com.zy.integration.iot.paho.MqttNetworkModuleFactory
src/main/resources/application.yml
@@ -105,12 +105,14 @@
  switch: true
  status-sync:
    enabled: true
    # 失败时是否打 WARN/ERROR(本地无 WCS 时可设 false,需排查时再开)
    log-on-failure: true
    initial-delay: 10000
    fixed-delay: 5000
    method: POST
  #  地址
  address:
    URL: https://127.0.0.1:9090/wcs
    URL: http://127.0.0.1:9090/wcs
    #入库任务下发地址
    createInTask : /openapi/createInTask
    #出库任务下发地址
@@ -121,14 +123,18 @@
    stopOutTask: /openapi/cancelTaskBatch
    #设备状态获取地址
    getDeviceStatus: /openapi/deviceStatus
    #按 WMS 任务号查询 WCS 是否已接收任务
    queryTask: /openapi/queryTask
# AWS IoT 对接开关与 topic/证书配置。
# 默认关闭,只有配置齐 endpoint 和证书路径后才会尝试建连。
iot:
  enabled: true
  endpoint: tcp://192.168.100.170:1883
  port: 1883
  enabled: false
#  endpoint: 192.168.100.170
#  port: 1883
  endpoint: a21wi8dwvkjf1d.ats.iot.cn-north-1.amazonaws.com.cn
  port: 8883
  thingName: asrs-iot-client
  clientId: asrs-iot-client
  cleanSession: false
@@ -136,21 +142,21 @@
  keepAliveSeconds: 60
  connectionTimeoutSeconds: 10
  persistenceDir: .local/iot-mqtt
  caCertPath:
  clientCertPath:
  privateKeyPath:
  caCertPath: D:/work/work-zy/gsl/zy-wms-gsl/src/main/resources/iot-certs/AmazonRootCA1.pem
  clientCertPath: D:/work/work-zy/gsl/zy-wms-gsl/src/main/resources/iot-certs/device-certificate.pem.crt
  privateKeyPath: D:/work/work-zy/gsl/zy-wms-gsl/src/main/resources/iot-certs/device-private.pem.key
  topics:
    #亚马逊发送组托给wms,在托盘上收到纸箱后,发布堆垛指令以将托盘发送到仓库。
    egressStow: glenn/instruction/icna/egress/asrs/stow
    egressStow: glenn/instruction/icng/egress/asrs/stow
    #亚马逊发送出库指令给wms,启动拣选请求后,发布拣选指令以引导ASRS从料箱中取货。
    egressPick: glenn/instruction/icna/egress/asrs/pick
    egressPick: glenn/instruction/icng/egress/asrs/pick
    #wms入库完成发送给亚马逊,ASRS实际拖拽托盘到料箱后,发布堆垛动作以同步BPS数据。
    egressFeedback: glenn/instruction/icna/egress/asrs/feedback
    egressFeedback: glenn/instruction/icng/egress/asrs/feedback
    #wms出库完成发送给亚马逊,在ASRS实际从料箱取货后,发布拣选动作sz XBPS数据。
    ingressStow: glenn/instruction/icna/ingress/asrs/stow
    ingressStow: glenn/instruction/icng/ingress/asrs/stow
    #ASRS 接收到 XBPS 指令后,向反馈XBPS 发送响应。
    ingressPick: glenn/instruction/icna/ingress/asrs/pick
    ingressPick: glenn/instruction/icng/ingress/asrs/pick
    #在XBPs收到ASRS操作后,向反馈ASRS发送响应
    ingressFeedback: glenn/instruction/icna/ingress/asrs/feedback
    ingressFeedback: glenn/instruction/icng/ingress/asrs/feedback
  pickStationMappings:
    ASRSOutbound1: 101
src/main/resources/iot-certs/AmazonRootCA1.pem
New file
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF
ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6
b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL
MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv
b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj
ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM
9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw
IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6
VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L
93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm
jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC
AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA
A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI
U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs
N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv
o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU
5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy
rqXRfboQnoZsG4q5WTP468SQvvG5
-----END CERTIFICATE-----
src/main/resources/iot-certs/device-certificate.pem.crt
New file
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDWTCCAkGgAwIBAgIUadE+uh4j8msPlgfGjldjbBLP+tMwDQYJKoZIhvcNAQEL
BQAwTTFLMEkGA1UECwxCQW1hem9uIFdlYiBTZXJ2aWNlcyBPPUFtYXpvbi5jb20g
SW5jLiBMPVNlYXR0bGUgU1Q9V2FzaGluZ3RvbiBDPVVTMB4XDTI2MDMyNTIxNDIx
M1oXDTQ5MTIzMTIzNTk1OVowHjEcMBoGA1UEAwwTQVdTIElvVCBDZXJ0aWZpY2F0
ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOdorxd56BY+uJqANr5B
N+zz+XeJFXd2ou6e30gG1URBomsMWZts0bhqfG+GyrqaZ0KFi6Z1yhbk0DfSir5e
FxPtfG5Rvo05V9RjsjGrSh3LeeZnTSgcGHimtwfz5D72j6nuxCdOiEpctLYyjxqM
whe02xOT+AuS82Q2kCurCrBlsxyzRh3HKJ0xT2/lohDgP1IDbxKTYJBOm1nvRRqa
GmFab+BRAIjT1Y4G7cs2EXmE23ykkEPZ6TSXMij20lN9VDF3fUUuBI5G7rcq9HfS
v1lQzVwF9NmbGfRNWobHwrvT7G9d74UXWmFfdrnzowaWRh2OtCRG7WlowHkDGVuL
AuMCAwEAAaNgMF4wHwYDVR0jBBgwFoAUzEvKR0Elhk6it4OonQyGA0e2EkowHQYD
VR0OBBYEFHyFYPxyk/7aYGwrS0S8Ai6uYgS/MAwGA1UdEwEB/wQCMAAwDgYDVR0P
AQH/BAQDAgeAMA0GCSqGSIb3DQEBCwUAA4IBAQBgPB5FZp8n6B3PNUuUV7HVbDrq
QAAYX18NQ7+vVOAnW6Yfisggqe8l3cC6ZLiGHpJ90bLmPTAiBr+tHJLLhJFi3e0w
WygplWlRdv3AbGEdQjMRAV86y0gx8LJZfLDzeG1K9HCj6p859Ugr3BbViFUz1YWh
rrFPnzsALBrem+J++2L834dFC+jFmSEWwBhRchRFEkJc7Iu0+dskOAlpIpJHw2X+
8xLIqhpPLbjWTv5LMWPrBXoH4dBvw6oTXLJLY0sn1OBZTobRJ+KRmCVZDC/Ircu/
dABftIoCMQw53ilPNQCpP0Qe1ecAGZxxQXvdrdR+AVJPdDhCYfkXvj1NVS3h
-----END CERTIFICATE-----
src/main/resources/iot-certs/device-private.pem.key
New file
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEA52ivF3noFj64moA2vkE37PP5d4kVd3ai7p7fSAbVREGiawxZ
m2zRuGp8b4bKuppnQoWLpnXKFuTQN9KKvl4XE+18blG+jTlX1GOyMatKHct55mdN
KBwYeKa3B/PkPvaPqe7EJ06ISly0tjKPGozCF7TbE5P4C5LzZDaQK6sKsGWzHLNG
HcconTFPb+WiEOA/UgNvEpNgkE6bWe9FGpoaYVpv4FEAiNPVjgbtyzYReYTbfKSQ
Q9npNJcyKPbSU31UMXd9RS4Ejkbutyr0d9K/WVDNXAX02ZsZ9E1ahsfCu9Psb13v
hRdaYV92ufOjBpZGHY60JEbtaWjAeQMZW4sC4wIDAQABAoIBAQCJWPbjZjW8Tknf
Wc4kKi15dG1S54hYOZAHNUCtTXDzbElsZA4jU/k+DeYBg+17x/0V3JHAoRTrda+o
EkzLJKlp6ID8MYR56dkZdHrlRBdfi8+0UwfWkKZtpfXowHdub4VhhRfjhJccG94e
be/GAFmLHIsTGbYVmIjhqAj2AjT78J/rA4Mvv9ng2qgXwb7WQgbauhpAZcG8cfYa
Z4sO4PJ8S4VSAxuoJYFY0A+3fYARfvxEO8jsrnqeRSGwjsWGPt9xzqbnJ/hcwFP9
4O7JYwJ8aqMEEGlx4FmcAt0qjz4E3qhNqPUMjyVpQcZObDoYFKOzHDHOsBxd+WXo
/+01U5IRAoGBAPQ4WRAmFs82IIG8B3OFX70TVd9qRwCqPi6bNysi60LX/0Bf7qTP
6NAnD9rxmyJJ1PVmCgmcdLH8CnF7ntyXAx5QsFxJ/XHZQ5KEa7FDo0aTFm4GqduR
MHBPEy5mlKgum72soFpbGG9mH4mXXOhR0xkYuRQUg+i13cmeX5HLBczdAoGBAPKS
JHCtf1jidjch71QtRze56dOPZQOgmAJqNEf+lIVXRbhHCCTQED+UNYAhDp+jUFpR
yE+oR/xx3+11XCHFjpaXssL2OMAPjee1JKm9F18woJjc2LMPW6XCk6hHjZ6Ltued
VlSQplwjTxewR+81tj0KGagmmXhcXkcku3iTxDK/AoGBAKOIB7tUhfmCmQnGSocE
TDNjeyD7HUhItxKmRK7R1w8Pa5BDrJ0XyyF2xpspJWQ0ZDFefmIpLcrwpl2PFbVI
OYJXLYDe2qMdhK3blfFBBVgArghG1f58nh7WFFYBwpFLhGXh7g4S6a3OiFetzzyR
bfVkJKpZgmqVPUoAjqYleGDRAoGBAJZVvqHa1UcYK13l+Tb5TN8bqPBGObuyxyMQ
AVDxVckCGqKn20M9dCSDTVkYo8CKbd1cPEIqMFsjlD3N84i2sLViVRcBlJBr023c
VVmhaJ/FOnMixGbNSOaFng+4MOwm+Pe5Cm0krQYDmBw9U4fMiSJxZQ9SxODUllWP
TWTgZ8NvAoGAKmveOulE1ykt26FLfemjEuZIHCT+BBhILHHBRWKW4uT2YBdreiCZ
DMDN2suu7gGf30grmE/DoFUfu1m7Ser15pwf/AMkZgTErLF8xB0hoYWbSqIGOBUa
uiLjUKVT6O7Q+YY5wiKv7p/BQaF+jmXkW4kwhE1L7dueEkl0zFPUguk=
-----END RSA PRIVATE KEY-----
src/main/resources/iot-certs/device-public.pem.key
New file
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA52ivF3noFj64moA2vkE3
7PP5d4kVd3ai7p7fSAbVREGiawxZm2zRuGp8b4bKuppnQoWLpnXKFuTQN9KKvl4X
E+18blG+jTlX1GOyMatKHct55mdNKBwYeKa3B/PkPvaPqe7EJ06ISly0tjKPGozC
F7TbE5P4C5LzZDaQK6sKsGWzHLNGHcconTFPb+WiEOA/UgNvEpNgkE6bWe9FGpoa
YVpv4FEAiNPVjgbtyzYReYTbfKSQQ9npNJcyKPbSU31UMXd9RS4Ejkbutyr0d9K/
WVDNXAX02ZsZ9E1ahsfCu9Psb13vhRdaYV92ufOjBpZGHY60JEbtaWjAeQMZW4sC
4wIDAQAB
-----END PUBLIC KEY-----
src/main/resources/sql/20260403_iot_mqtt_sys_config.sql
New file
@@ -0,0 +1,9 @@
-- IoT MQTT:sys_config 编码说明(无则走 application.yml 的 iot.enabled / topics)
-- iot.mqtt.enabled     值 true/false,status=1 生效
-- iot.topic.egress_stow / egress_pick / egress_feedback / ingress_stow / ingress_pick / ingress_feedback
IF NOT EXISTS (SELECT 1 FROM sys_config WHERE code = N'iot.mqtt.enabled')
BEGIN
    INSERT INTO sys_config (name, code, value, type, status)
    VALUES (N'IoT MQTT 总开关', N'iot.mqtt.enabled', N'false', 1, 1);
END;
src/main/webapp/static/js/iotMqttOutbound/iotMqttOutbound.js
New file
@@ -0,0 +1,233 @@
var pageCurr;
var tableIns;
function escHtml(s) {
    if (s == null || s === '') {
        return '';
    }
    return $('<div/>').text(String(s)).html();
}
function payloadPreview(s) {
    var t = s == null ? '' : String(s);
    if (t.length <= 100) {
        return escHtml(t);
    }
    return escHtml(t.substring(0, 100)) + '…';
}
layui.use(['table', 'form'], function () {
    var table = layui.table;
    var $ = layui.jquery;
    var layer = layui.layer;
    var form = layui.form;
    function loadIotMqttConfig() {
        $.ajax({
            url: baseUrl + '/iotMqttAdmin/config/auth',
            headers: {token: localStorage.getItem('token')},
            dataType: 'json',
            success: function (res) {
                if (res.code === 403) {
                    top.location.href = baseUrl + "/";
                    return;
                }
                if (res.code !== 200 || !res.data) {
                    return;
                }
                var d = res.data;
                var tp = d.topics || {};
                form.val('iotMqttCfgForm', {
                    enabled: d.enabled ? 'on' : '',
                    egressStow: tp.egressStow || '',
                    egressPick: tp.egressPick || '',
                    egressFeedback: tp.egressFeedback || '',
                    ingressStow: tp.ingressStow || '',
                    ingressPick: tp.ingressPick || '',
                    ingressFeedback: tp.ingressFeedback || ''
                });
                $('#iotMqttConnState').text(d.mqttConnected ? '当前:已连接' : '当前:未连接');
                form.render();
            }
        });
    }
    loadIotMqttConfig();
    $('#iotMqttReloadCfg').on('click', function () {
        loadIotMqttConfig();
    });
    $('#iotMqttReconnect').on('click', function () {
        $.ajax({
            url: baseUrl + '/iotMqttAdmin/reconnect/auth',
            headers: {token: localStorage.getItem('token')},
            type: 'POST',
            dataType: 'json',
            success: function (res) {
                if (res.code === 200) {
                    layer.msg('已执行重连');
                    loadIotMqttConfig();
                } else {
                    layer.msg(res.msg || '重连失败', {icon: 2});
                }
            },
            error: function () {
                layer.msg('请求失败', {icon: 2});
            }
        });
    });
    form.on('submit(iotMqttSave)', function () {
        var v = form.val('iotMqttCfgForm');
        var payload = {
            enabled: v.enabled === 'on',
            topics: {
                egressStow: v.egressStow || '',
                egressPick: v.egressPick || '',
                egressFeedback: v.egressFeedback || '',
                ingressStow: v.ingressStow || '',
                ingressPick: v.ingressPick || '',
                ingressFeedback: v.ingressFeedback || ''
            }
        };
        $.ajax({
            url: baseUrl + '/iotMqttAdmin/config/save/auth',
            headers: {token: localStorage.getItem('token'), 'Content-Type': 'application/json'},
            type: 'POST',
            data: JSON.stringify(payload),
            dataType: 'json',
            success: function (res) {
                if (res.code === 200) {
                    layer.msg('已保存,请点「重连 MQTT」使订阅生效');
                    loadIotMqttConfig();
                } else {
                    layer.msg(res.msg || '保存失败', {icon: 2});
                }
            },
            error: function () {
                layer.msg('请求失败', {icon: 2});
            }
        });
        return false;
    });
    tableIns = table.render({
        elem: '#iotMqttOutbound',
        headers: {token: localStorage.getItem('token')},
        url: baseUrl + '/iotMqttOutbound/list/auth',
        page: true,
        limit: 16,
        limits: [16, 30, 50, 100],
        cellMinWidth: 50,
        cols: [[
            {field: 'id', title: 'ID', width: 80, sort: true, align: 'center'}
            , {field: 'instructionId', title: 'instructionId', align: 'center', width: 200}
            , {field: 'messageType', title: '类型', align: 'center', width: 100}
            , {field: 'publishTopic', title: '发送主题', align: 'center', minWidth: 200}
            , {
                field: 'publishPayload',
                title: '发送消息体(摘要)',
                align: 'left',
                minWidth: 280,
                templet: function (d) {
                    return payloadPreview(d.publishPayload);
                }
            }
            , {field: 'publishStatus', title: '发布状态', align: 'center', width: 120}
            , {field: 'errorMessage', title: '错误信息', align: 'center', minWidth: 160}
            , {field: 'createTime', title: '创建时间', align: 'center', width: 170}
            , {field: 'updateTime', title: '更新时间', align: 'center', width: 170}
            , {fixed: 'right', title: '操作', align: 'center', toolbar: '#iotMqttOutboundOperate', width: 150}
        ]],
        request: {
            pageName: 'curr',
            pageSize: 'limit'
        },
        parseData: function (res) {
            return {
                'code': res.code,
                'msg': res.msg,
                'count': res.data.total,
                'data': res.data.records
            };
        },
        response: {
            statusCode: 200
        },
        done: function (res, curr) {
            if (res.code === 403) {
                top.location.href = baseUrl + "/";
            }
            pageCurr = curr;
            if (typeof limit === 'function') {
                limit();
            }
        }
    });
    form.on('submit(search)', function () {
        var searchData = {};
        $.each($('#search-box [name]').serializeArray(), function () {
            searchData[this.name] = this.value;
        });
        tableIns.reload({
            where: searchData,
            page: {curr: 1},
            done: function (res, curr) {
                if (res.code === 403) {
                    top.location.href = baseUrl + "/";
                }
                pageCurr = curr;
                if (typeof limit === 'function') {
                    limit();
                }
            }
        });
        return false;
    });
    form.on('submit(reset)', function () {
        $('#search-box [name]').val('');
        form.render();
        tableIns.reload({
            where: {},
            page: {curr: 1}
        });
        return false;
    });
    table.on('tool(iotMqttOutbound)', function (obj) {
        var data = obj.data;
        if (obj.event === 'payload') {
            layer.open({
                type: 1,
                title: '发送消息体',
                area: ['720px', '520px'],
                shadeClose: true,
                content: '<div style="padding:12px;"><pre style="white-space:pre-wrap;word-break:break-all;margin:0;">'
                    + escHtml(data.publishPayload) + '</pre></div>'
            });
        } else if (obj.event === 'resend') {
            layer.confirm('确定重发该条 MQTT?', function (index) {
                $.ajax({
                    url: baseUrl + '/iotMqttOutbound/resend/auth',
                    headers: {token: localStorage.getItem('token')},
                    type: 'POST',
                    data: {id: data.id},
                    dataType: 'json',
                    success: function (res) {
                        layer.close(index);
                        if (res.code === 200) {
                            layer.msg('重发成功');
                            tableIns.reload({page: {curr: pageCurr || 1}});
                        } else {
                            layer.msg(res.msg || '重发失败', {icon: 2});
                        }
                    },
                    error: function () {
                        layer.close(index);
                        layer.msg('请求失败', {icon: 2});
                    }
                });
            });
        }
    });
});
src/main/webapp/views/iotMqttOutbound/iotMqttOutbound.html
New file
@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>IoT MQTT 发送记录</title>
    <meta name="renderer" content="webkit">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <link rel="stylesheet" href="../../static/layui/css/layui.css" media="all">
    <link rel="stylesheet" href="../../static/css/cool.css" media="all">
    <link rel="stylesheet" href="../../static/css/common.css" media="all">
</head>
<body>
<div class="layui-card" style="margin:8px;">
    <div class="layui-card-header">MQTT 配置(sys_config,改 topic/开关后请先保存再点重连)</div>
    <div class="layui-card-body layui-form" lay-filter="iotMqttCfgForm">
        <div class="layui-form-item">
            <label class="layui-form-label">总开关</label>
            <div class="layui-input-block">
                <input type="checkbox" name="enabled" lay-skin="switch" lay-text="开|关">
            </div>
        </div>
        <div class="layui-form-item">
            <label class="layui-form-label">egressStow</label>
            <div class="layui-input-block">
                <input type="text" name="egressStow" autocomplete="off" class="layui-input" placeholder="订阅:XBPS→WMS 组托">
            </div>
        </div>
        <div class="layui-form-item">
            <label class="layui-form-label">egressPick</label>
            <div class="layui-input-block">
                <input type="text" name="egressPick" autocomplete="off" class="layui-input" placeholder="订阅:拣选指令">
            </div>
        </div>
        <div class="layui-form-item">
            <label class="layui-form-label">egressFeedback</label>
            <div class="layui-input-block">
                <input type="text" name="egressFeedback" autocomplete="off" class="layui-input" placeholder="订阅:feedback">
            </div>
        </div>
        <div class="layui-form-item">
            <label class="layui-form-label">ingressStow</label>
            <div class="layui-input-block">
                <input type="text" name="ingressStow" autocomplete="off" class="layui-input" placeholder="发布:入库完成">
            </div>
        </div>
        <div class="layui-form-item">
            <label class="layui-form-label">ingressPick</label>
            <div class="layui-input-block">
                <input type="text" name="ingressPick" autocomplete="off" class="layui-input" placeholder="发布:出库完成">
            </div>
        </div>
        <div class="layui-form-item">
            <label class="layui-form-label">ingressFeedback</label>
            <div class="layui-input-block">
                <input type="text" name="ingressFeedback" autocomplete="off" class="layui-input" placeholder="发布:feedback">
            </div>
        </div>
        <div class="layui-form-item">
            <div class="layui-input-block">
                <button class="layui-btn layui-btn-primary" type="button" id="iotMqttReloadCfg">重新加载</button>
                <button class="layui-btn" lay-submit lay-filter="iotMqttSave">保存</button>
                <button type="button" class="layui-btn layui-btn-normal" id="iotMqttReconnect">重连 MQTT</button>
                <span id="iotMqttConnState" style="margin-left:12px;color:#666;"></span>
            </div>
        </div>
    </div>
</div>
<div id="search-box" class="layui-form layui-card-header">
    <div class="layui-inline">
        <div class="layui-input-inline">
            <input class="layui-input" type="text" name="instruction_id" placeholder="instructionId" autocomplete="off">
        </div>
    </div>
    <div class="layui-inline">
        <div class="layui-input-inline">
            <input class="layui-input" type="text" name="publish_status" placeholder="发布状态 PENDING/SUCCESS/FAILURE..." autocomplete="off">
        </div>
    </div>
    <div class="layui-inline">
        <div class="layui-input-inline">
            <input class="layui-input" type="text" name="publish_topic" placeholder="发送主题" autocomplete="off">
        </div>
    </div>
    <div class="layui-inline">
        <div class="layui-input-inline">
            <input class="layui-input" type="text" name="container_id" placeholder="容器号" autocomplete="off">
        </div>
    </div>
    <div id="data-search-btn" class="layui-btn-container layui-form-item" style="display: inline-block">
        <button id="search" class="layui-btn layui-btn-primary layui-btn-radius" lay-submit lay-filter="search">搜索</button>
        <button id="reset" class="layui-btn layui-btn-primary layui-btn-radius" lay-submit lay-filter="reset">重置</button>
    </div>
</div>
<table class="layui-hide" id="iotMqttOutbound" lay-filter="iotMqttOutbound"></table>
<script type="text/html" id="iotMqttOutboundOperate">
    <a class="layui-btn layui-btn-primary layui-btn-xs" lay-event="payload">消息体</a>
    <a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="resend">重发</a>
</script>
<script type="text/javascript" src="../../static/js/jquery/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="../../static/layui/layui.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/common.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/cool.js" charset="utf-8"></script>
<script type="text/javascript" src="../../static/js/iotMqttOutbound/iotMqttOutbound.js" charset="utf-8"></script>
</body>
</html>
src/test/java/com/zy/integration/iot/IotSupportTest.java
@@ -14,11 +14,18 @@
        properties.setPort(8883);
        properties.setThingName("thing-a");
        Assertions.assertEquals("ssl://example-ats.iot.cn-north-1.amazonaws.com.cn:8883", properties.getServerUri());
        Assertions.assertEquals("thing-a", properties.getResolvedClientId());
        Assertions.assertEquals("mqtts://example-ats.iot.cn-north-1.amazonaws.com.cn:8883", properties.getServerUri());
        String thingId = properties.getResolvedClientId();
        Assertions.assertNotNull(thingId);
        Assertions.assertTrue(thingId.startsWith("thing-a-"));
        Assertions.assertEquals(thingId, properties.getResolvedClientId());
        properties.setClientId("client-b");
        Assertions.assertEquals("client-b", properties.getResolvedClientId());
        IotProperties withClient = new IotProperties();
        withClient.setClientId("client-b");
        String cid = withClient.getResolvedClientId();
        Assertions.assertNotNull(cid);
        Assertions.assertTrue(cid.startsWith("client-b-"));
        Assertions.assertEquals(cid, withClient.getResolvedClientId());
    }
    @Test