diff --git a/src/main/java/com/iot/transport/jt808/server/Jt808NettyServer.java b/src/main/java/com/iot/transport/jt808/server/Jt808NettyServer.java index f9da41a..2babe9a 100644 --- a/src/main/java/com/iot/transport/jt808/server/Jt808NettyServer.java +++ b/src/main/java/com/iot/transport/jt808/server/Jt808NettyServer.java @@ -1,6 +1,7 @@ package com.iot.transport.jt808.server; import com.iot.transport.jt808.service.ApiLogService; +import com.iot.transport.jt808.service.RawDataCollectorService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.CommandLineRunner; @@ -21,21 +22,23 @@ public class Jt808NettyServer implements CommandLineRunner { @Autowired private ApiLogService apiLogService; + @Autowired + private RawDataCollectorService rawDataCollectorService; + private TCPServer tcpServer; @Override public void run(String... args) throws Exception { log.info("Initializing JT808 TCP Server on port: {}", port); - tcpServer = new TCPServer(port, apiLogService); + tcpServer = new TCPServer(port, apiLogService, rawDataCollectorService); tcpServer.startServer(); } // You might want to add a @PreDestroy method to stop the server gracefully // @PreDestroy // public void destroy() { - // if (tcpServer != null) { - // tcpServer.stopServer(); - // } + // if (tcpServer != null) { + // tcpServer.stopServer(); + // } // } } - diff --git a/src/main/java/com/iot/transport/jt808/server/TCPServer.java b/src/main/java/com/iot/transport/jt808/server/TCPServer.java index a3039f3..1ae0695 100644 --- a/src/main/java/com/iot/transport/jt808/server/TCPServer.java +++ b/src/main/java/com/iot/transport/jt808/server/TCPServer.java @@ -1,6 +1,5 @@ package com.iot.transport.jt808.server; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.concurrent.TimeUnit; @@ -21,13 +20,15 @@ import io.netty.handler.timeout.IdleStateHandler; import io.netty.util.concurrent.Future; import com.iot.transport.jt808.service.ApiLogService; +import com.iot.transport.jt808.service.RawDataCollectorService; public class TCPServer { private Logger log = LoggerFactory.getLogger(getClass()); - + private int port; - private ApiLogService apiLogService; + private ApiLogService apiLogService; + private RawDataCollectorService rawDataCollectorService; private EventLoopGroup bossGroup = null; private EventLoopGroup workerGroup = null; private volatile boolean isRunning = false; @@ -35,10 +36,11 @@ public class TCPServer { public TCPServer() { } - public TCPServer(int port, ApiLogService apiLogService) { + public TCPServer(int port, ApiLogService apiLogService, RawDataCollectorService rawDataCollectorService) { this(); this.port = port; - this.apiLogService = apiLogService; + this.apiLogService = apiLogService; + this.rawDataCollectorService = rawDataCollectorService; } private void bind() throws Exception { @@ -50,19 +52,19 @@ public class TCPServer { .childHandler(new ChannelInitializer() { // @Override public void initChannel(SocketChannel ch) throws Exception { - + ch.pipeline().addLast("idleStateHandler", new IdleStateHandler(Consts.TCP_CLIENT_IDLE, 0, 0, TimeUnit.MINUTES)); - + ch.pipeline().addLast(new LogDecoder()); - + // 1024表示单条消息的最大长度,解码器在查找分隔符的时候,达到该长度还没找到的话会抛异常 ch.pipeline().addLast( new DelimiterBasedFrameDecoder(1024, Unpooled.copiedBuffer(new byte[] { 0x7e }), Unpooled.copiedBuffer(new byte[] { 0x7e, 0x7e }))); - //ch.pipeline().addLast(new PackageDataDecoder()); - - ch.pipeline().addLast(new TCPServerHandler(apiLogService)); + // ch.pipeline().addLast(new PackageDataDecoder()); + + ch.pipeline().addLast(new TCPServerHandler(apiLogService, rawDataCollectorService)); } }).option(ChannelOption.SO_BACKLOG, 128) // .childOption(ChannelOption.SO_KEEPALIVE, true); diff --git a/src/main/java/com/iot/transport/jt808/service/RawDataCollectorService.java b/src/main/java/com/iot/transport/jt808/service/RawDataCollectorService.java new file mode 100644 index 0000000..0a61882 --- /dev/null +++ b/src/main/java/com/iot/transport/jt808/service/RawDataCollectorService.java @@ -0,0 +1,227 @@ +package com.iot.transport.jt808.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.iot.transport.jt808.entity.DataPack; +import com.iot.transport.jt808.entity.DataPack.PackHead; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + * 原始数据收集服务 + * 用于收集JT808设备上传的所有原始数据和解析后的消息内容 + * 便于分析设备的数据上传习惯 + */ +@Service +public class RawDataCollectorService { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @Value("${jt808.raw-data.enabled:true}") + private boolean enabled; + + @Value("${jt808.raw-data.output-dir:./raw-data}") + private String outputDir; + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final ConcurrentHashMap writers = new ConcurrentHashMap<>(); + private final DateTimeFormatter timestampFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"); + private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @PostConstruct + public void init() { + if (enabled) { + File dir = new File(outputDir); + if (!dir.exists()) { + dir.mkdirs(); + } + logger.info("原始数据收集服务已启动, 输出目录: {}", dir.getAbsolutePath()); + } else { + logger.info("原始数据收集服务已禁用"); + } + } + + @PreDestroy + public void shutdown() { + executor.shutdown(); + try { + executor.awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + // 关闭所有writer + writers.forEach((key, writer) -> { + try { + writer.close(); + } catch (IOException e) { + logger.error("关闭writer失败: {}", key, e); + } + }); + writers.clear(); + logger.info("原始数据收集服务已关闭"); + } + + /** + * 记录原始字节数据 + * + * @param sessionId 会话ID + * @param rawBytes 原始字节数据 + */ + public void collectRawBytes(String sessionId, byte[] rawBytes) { + if (!enabled || rawBytes == null || rawBytes.length == 0) { + return; + } + + String hexData = bytesToHex(rawBytes); + String timestamp = LocalDateTime.now().format(timestampFormatter); + + executor.submit(() -> { + try { + BufferedWriter writer = getWriter(sessionId); + writer.write("================================================================================\n"); + writer.write(String.format("时间: %s\n", timestamp)); + writer.write(String.format("会话ID: %s\n", sessionId)); + writer.write(String.format("原始数据(HEX): %s\n", hexData)); + writer.flush(); + } catch (IOException e) { + logger.error("写入原始数据失败: {}", sessionId, e); + } + }); + } + + /** + * 记录解析后的消息 + * + * @param sessionId 会话ID + * @param dataPack 解析后的数据包 + * @param parsedDetails 解析后的详细信息 + */ + public void collectParsedMessage(String sessionId, DataPack dataPack, String parsedDetails) { + if (!enabled || dataPack == null) { + return; + } + + PackHead header = dataPack.getPackHead(); + + executor.submit(() -> { + try { + BufferedWriter writer = getWriter(sessionId); + writer.write("--------------------------------------------------------------------------------\n"); + writer.write("解析结果:\n"); + writer.write(String.format(" 设备号: %s\n", header.getTerminalPhone())); + writer.write(String.format(" 消息ID: 0x%04X (%s)\n", header.getId(), getMsgTypeName(header.getId()))); + writer.write(String.format(" 流水号: %d\n", header.getFlowId())); + if (parsedDetails != null && !parsedDetails.isEmpty()) { + writer.write(String.format(" 消息体: %s\n", parsedDetails)); + } + writer.write("================================================================================\n\n"); + writer.flush(); + } catch (IOException e) { + logger.error("写入解析数据失败: {}", sessionId, e); + } + }); + } + + /** + * 完整记录一条消息(原始数据+解析结果) + */ + public void collectMessage(String sessionId, byte[] rawBytes, DataPack dataPack, String parsedDetails) { + if (!enabled) { + return; + } + + String hexData = rawBytes != null ? bytesToHex(rawBytes) : "N/A"; + String timestamp = LocalDateTime.now().format(timestampFormatter); + PackHead header = dataPack != null ? dataPack.getPackHead() : null; + String terminalPhone = header != null ? header.getTerminalPhone() : sessionId; + + executor.submit(() -> { + try { + // 使用设备号作为文件名(如果有的话) + String fileKey = (terminalPhone != null && !terminalPhone.isEmpty()) ? terminalPhone : sessionId; + BufferedWriter writer = getWriter(fileKey); + + writer.write("================================================================================\n"); + writer.write(String.format("时间: %s\n", timestamp)); + if (terminalPhone != null && !terminalPhone.isEmpty()) { + writer.write(String.format("设备号: %s\n", terminalPhone)); + } + writer.write(String.format("会话ID: %s\n", sessionId)); + writer.write(String.format("原始数据(HEX): %s\n", hexData)); + + if (header != null) { + writer.write("--------------------------------------------------------------------------------\n"); + writer.write("解析结果:\n"); + writer.write( + String.format(" 消息ID: 0x%04X (%s)\n", header.getId(), getMsgTypeName(header.getId()))); + writer.write(String.format(" 流水号: %d\n", header.getFlowId())); + if (parsedDetails != null && !parsedDetails.isEmpty()) { + writer.write(String.format(" 消息体: %s\n", parsedDetails)); + } + } + writer.write("================================================================================\n\n"); + writer.flush(); + } catch (IOException e) { + logger.error("写入消息数据失败: {}", sessionId, e); + } + }); + } + + private BufferedWriter getWriter(String key) throws IOException { + String dateStr = LocalDateTime.now().format(dateFormatter); + String fileName = String.format("%s_%s.txt", key, dateStr); + String fullPath = outputDir + File.separator + fileName; + + // 检查是否需要创建新的writer(日期变化或首次创建) + String writerKey = key + "_" + dateStr; + + return writers.computeIfAbsent(writerKey, k -> { + try { + return new BufferedWriter(new FileWriter(fullPath, true)); + } catch (IOException e) { + logger.error("创建writer失败: {}", fullPath, e); + throw new RuntimeException(e); + } + }); + } + + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02X", b)); + } + return sb.toString(); + } + + private String getMsgTypeName(int msgId) { + return switch (msgId) { + case 0x0001 -> "终端通用应答"; + case 0x0002 -> "终端心跳"; + case 0x0003 -> "终端注销"; + case 0x0100 -> "终端注册"; + case 0x0102 -> "终端鉴权"; + case 0x0200 -> "位置上报"; + case 0x0704 -> "批量位置上报"; + case 0x0006 -> "按键事件"; + default -> "未知消息"; + }; + } + + public boolean isEnabled() { + return enabled; + } +} diff --git a/src/main/java/com/iot/transport/jt808/service/handler/TCPServerHandler.java b/src/main/java/com/iot/transport/jt808/service/handler/TCPServerHandler.java index c438d1f..618b925 100644 --- a/src/main/java/com/iot/transport/jt808/service/handler/TCPServerHandler.java +++ b/src/main/java/com/iot/transport/jt808/service/handler/TCPServerHandler.java @@ -17,6 +17,7 @@ import io.netty.handler.timeout.IdleStateEvent; import io.netty.util.ReferenceCountUtil; import com.iot.transport.jt808.service.ApiLogService; +import com.iot.transport.jt808.service.RawDataCollectorService; import java.util.HashMap; import java.util.Map; @@ -30,35 +31,40 @@ import java.util.List; public class TCPServerHandler extends ChannelInboundHandlerAdapter { // (1) - private final Logger logger = LoggerFactory.getLogger(getClass()); + private final Logger logger = LoggerFactory.getLogger(getClass()); - private final DataDecoder decoder; - private final SessionManager sessionManager; + private final DataDecoder decoder; + private final SessionManager sessionManager; private final ApiLogService apiLogService; - + private final RawDataCollectorService rawDataCollectorService; - public TCPServerHandler(ApiLogService apiLogService) { - this.decoder = new DataDecoder(); - this.sessionManager = SessionManager.getInstance(); + // 保存当前处理的原始字节,用于记录 + private byte[] currentRawBytes; + + public TCPServerHandler(ApiLogService apiLogService, RawDataCollectorService rawDataCollectorService) { + this.decoder = new DataDecoder(); + this.sessionManager = SessionManager.getInstance(); this.apiLogService = apiLogService; - } - - /** - * - * 处理业务逻辑 - * - * @param packageData - * @throws IllegalAccessException - * @throws InstantiationException - * - */ - private void processPackageData(DataPack packageData) throws InstantiationException, IllegalAccessException { - - PackHead header = packageData.getPackHead(); - Integer msgId = header.getId(); + this.rawDataCollectorService = rawDataCollectorService; + } + + /** + * + * 处理业务逻辑 + * + * @param packageData + * @throws IllegalAccessException + * @throws InstantiationException + * + */ + private void processPackageData(DataPack packageData) throws InstantiationException, IllegalAccessException { + + PackHead header = packageData.getPackHead(); + Integer msgId = header.getId(); String terminalPhone = header.getTerminalPhone(); - - logger.info("消息头部:msgid={}, phone={}, flowid={}", msgId, terminalPhone, header.getFlowId()); + String sessionId = Session.buildId(packageData.getChannel()); + + logger.info("消息头部:msgid={}, phone={}, flowid={}", msgId, terminalPhone, header.getFlowId()); // Update Session with Phone Number if available if (terminalPhone != null && !terminalPhone.isEmpty()) { @@ -80,8 +86,9 @@ public class TCPServerHandler extends ChannelInboundHandlerAdapter { // (1) logMap.put("phone", header.getTerminalPhone()); logMap.put("flowId", header.getFlowId()); logMap.put("summary", "TCP Message Received"); - - // Special handling for Location Upload (0x0200) to show parsed details including Bluetooth + + // Special handling for Location Upload (0x0200) to show parsed details + // including Bluetooth if (msgId == Consts.MSGID_LOCATION_UPLOAD || msgId == 0x0200) { try { LocationPack locPack = this.decoder.toLocationInfoUploadMsg(packageData); @@ -90,7 +97,7 @@ public class TCPServerHandler extends ChannelInboundHandlerAdapter { // (1) // Enhanced fields for Frontend Dashboard logMap.put("type", "badge"); logMap.put("id", header.getTerminalPhone()); - + // Battery BatteryVersionInfo batInfo = locPack.getBatteryVersionInfo(); if (batInfo != null) { @@ -109,7 +116,7 @@ public class TCPServerHandler extends ChannelInboundHandlerAdapter { // (1) if (bles != null) { logMap.put("bluetooth", bles); } - + // Location Map locMap = new HashMap<>(); locMap.put("lat", locPack.getLatitude()); @@ -125,14 +132,14 @@ public class TCPServerHandler extends ChannelInboundHandlerAdapter { // (1) logMap.put("details", btnPack.toString()); logMap.put("type", "badge"); // Keep it as badge type to update the device logMap.put("id", header.getTerminalPhone()); - + // Add button event info Map btnInfo = new HashMap<>(); btnInfo.put("keyId", btnPack.getKeyId()); btnInfo.put("keyState", btnPack.getKeyState()); btnInfo.put("timestamp", System.currentTimeMillis()); logMap.put("buttonEvent", btnInfo); - + } catch (Exception e) { logMap.put("details", packageData.toString() + " (Parse Error: " + e.getMessage() + ")"); } @@ -142,17 +149,17 @@ public class TCPServerHandler extends ChannelInboundHandlerAdapter { // (1) logMap.put("details", "Batch Upload: " + batchPack.getCount() + " items"); logMap.put("type", "badge"); logMap.put("id", header.getTerminalPhone()); - + // Use the latest location item to update the UI if (batchPack.getItems() != null && !batchPack.getItems().isEmpty()) { LocationPack lastLoc = batchPack.getItems().get(batchPack.getItems().size() - 1); - + // Location Map locMap = new HashMap<>(); locMap.put("lat", lastLoc.getLatitude()); locMap.put("lon", lastLoc.getLongitude()); logMap.put("location", locMap); - + // Battery BatteryVersionInfo batInfo = lastLoc.getBatteryVersionInfo(); if (batInfo != null) { @@ -165,85 +172,93 @@ public class TCPServerHandler extends ChannelInboundHandlerAdapter { // (1) } else { logMap.put("details", packageData.toString()); } - + apiLogService.broadcastLog("TCP", logMap); } - - MessageHandler handler = MessageHandlerFactory.getInstance(msgId); - if(handler != null){ - handler.process(packageData); - }else { // 其他情况 - logger.error("[未知消息类型],msgId={},phone={},package={}", header.getId(), header.getTerminalPhone(), packageData); - } - } - + MessageHandler handler = MessageHandlerFactory.getInstance(msgId); + if (handler != null) { + handler.process(packageData); + } else { // 其他情况 + logger.error("[未知消息类型],msgId={},phone={},package={}", header.getId(), header.getTerminalPhone(), + packageData); + } - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) throws InterruptedException { // (2) - try { - ByteBuf buf = (ByteBuf) msg; - if (buf.readableBytes() <= 0) { - // ReferenceCountUtil.safeRelease(msg); - return; - } + // 记录原始数据和解析结果 + if (rawDataCollectorService != null && rawDataCollectorService.isEnabled()) { + String parsedDetails = packageData.toString(); + rawDataCollectorService.collectMessage(sessionId, currentRawBytes, packageData, parsedDetails); + } + } - byte[] bs = new byte[buf.readableBytes()]; - buf.readBytes(bs); + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws InterruptedException { // (2) + try { + ByteBuf buf = (ByteBuf) msg; + if (buf.readableBytes() <= 0) { + // ReferenceCountUtil.safeRelease(msg); + return; + } - // 字节数据转换为针对于808消息结构的实体类 - DataPack pkg = this.decoder.bytes2PackageData(bs); - // 引用channel,以便回送数据给硬件 - pkg.setChannel(ctx.channel()); - processPackageData(pkg); - }catch (Exception e) { - // TODO: handle exception - logger.error("消息处理异常", e); - } finally { - release(msg); - } - } + byte[] bs = new byte[buf.readableBytes()]; + buf.readBytes(bs); - @Override - public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { // (4) - logger.error("发生异常:{}", cause); - //cause.printStackTrace(); - } + // 保存原始字节用于记录 + this.currentRawBytes = bs; - @Override - public void channelActive(ChannelHandlerContext ctx) throws Exception { - Session session = Session.buildSession(ctx.channel()); - sessionManager.put(session.getId(), session); - logger.debug("终端连接:{}", session); - } + // 字节数据转换为针对于808消息结构的实体类 + DataPack pkg = this.decoder.bytes2PackageData(bs); + // 引用channel,以便回送数据给硬件 + pkg.setChannel(ctx.channel()); + processPackageData(pkg); + } catch (Exception e) { + // TODO: handle exception + logger.error("消息处理异常", e); + } finally { + release(msg); + } + } - @Override - public void channelInactive(ChannelHandlerContext ctx) throws Exception { - final String sessionId = ctx.channel().id().asLongText(); - Session session = sessionManager.findBySessionId(sessionId); - this.sessionManager.removeBySessionId(sessionId); - logger.debug("终端断开连接:{}", session); - ctx.channel().close(); - // ctx.close(); - } + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { // (4) + logger.error("发生异常:{}", cause); + // cause.printStackTrace(); + } - @Override - public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { - if (IdleStateEvent.class.isAssignableFrom(evt.getClass())) { - IdleStateEvent event = (IdleStateEvent) evt; - if (event.state() == IdleState.READER_IDLE) { - Session session = this.sessionManager.removeBySessionId(Session.buildId(ctx.channel())); - logger.error("服务器主动断开连接:{}", session); - ctx.close(); - } - } - } + @Override + public void channelActive(ChannelHandlerContext ctx) throws Exception { + Session session = Session.buildSession(ctx.channel()); + sessionManager.put(session.getId(), session); + logger.debug("终端连接:{}", session); + } - private void release(Object msg) { - try { - ReferenceCountUtil.release(msg); - } catch (Exception e) { - e.printStackTrace(); - } - } + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + final String sessionId = ctx.channel().id().asLongText(); + Session session = sessionManager.findBySessionId(sessionId); + this.sessionManager.removeBySessionId(sessionId); + logger.debug("终端断开连接:{}", session); + ctx.channel().close(); + // ctx.close(); + } + + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { + if (IdleStateEvent.class.isAssignableFrom(evt.getClass())) { + IdleStateEvent event = (IdleStateEvent) evt; + if (event.state() == IdleState.READER_IDLE) { + Session session = this.sessionManager.removeBySessionId(Session.buildId(ctx.channel())); + logger.error("服务器主动断开连接:{}", session); + ctx.close(); + } + } + } + + private void release(Object msg) { + try { + ReferenceCountUtil.release(msg); + } catch (Exception e) { + e.printStackTrace(); + } + } } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a110527..133670b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,7 +2,10 @@ server: port: 8080 jt808: - port: 20048 + port: 8091 + raw-data: + enabled: true + output-dir: ./raw-data logging: level: diff --git a/target/classes/application.yml b/target/classes/application.yml index a110527..133670b 100644 --- a/target/classes/application.yml +++ b/target/classes/application.yml @@ -2,7 +2,10 @@ server: port: 8080 jt808: - port: 20048 + port: 8091 + raw-data: + enabled: true + output-dir: ./raw-data logging: level: diff --git a/target/classes/static/index.html b/target/classes/static/index.html index 2936cbf..001e1b4 100644 --- a/target/classes/static/index.html +++ b/target/classes/static/index.html @@ -383,7 +383,7 @@
{{ formatCounterTime(counter.latestData.time) }} - Rx:{{counter.latestData.rxBat}}% / Tx:{{counter.latestData.txBat}}% + 接收端电量:{{counter.latestData.rxBat}}% / 电量:{{counter.latestData.txBat}}%
@@ -576,8 +576,15 @@ headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const result = await res.json(); - if (result.code !== 200) alert('Error: ' + result.message); + if (result.code !== 200) { + alert('Error: ' + (result.message || result.msg || '未知错误')); + } } catch (e) { alert('发送失败: ' + e.message); } @@ -647,7 +654,7 @@ }); const result = await res.json(); if (result.code === 200) alert('指令已发送'); - else alert('失败: ' + result.message); + else alert('失败: ' + (result.message || '未知错误')); } else if (this.commandForm.apiType === 'text') { // 调用 /api/v1/device/command/text @@ -663,7 +670,7 @@ }); const result = await res.json(); if (result.code === 200) alert('指令已发送'); - else alert('失败: ' + result.message); + else alert('失败: ' + (result.message || '未知错误')); } else { // 通用指令接口 /api/v1/device/command/send @@ -676,7 +683,7 @@ }); const result = await res.json(); if (result.code === 200) alert('指令已发送'); - else alert('失败: ' + result.message); + else alert('失败: ' + (result.message || '未知错误')); } } catch (e) { alert('发送异常: ' + e.message);