From 98f5f031a29675cae5c82b64ca2cbddc327a4a59 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 31 Dec 2025 13:32:26 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20IOT=E6=A8=A1=E5=9D=97=E5=8D=95?= =?UTF-8?q?=E4=BD=93=E7=89=88=E6=9C=AC=20-=20=E4=BB=A3=E7=A0=81=E5=90=88?= =?UTF-8?q?=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../iot/core/biz/dto/IotDeviceRespDTO.java | 12 + .../iot/core/enums/IotAuthTypeEnum.java | 58 ++ .../jt808/IotJt808DeviceMessageCodec.java | 672 ++++++++++++++++++ .../gateway/codec/jt808/Jt808Constants.java | 93 +++ .../iot/gateway/codec/jt808/Jt808Decoder.java | 295 ++++++++ .../iot/gateway/codec/jt808/Jt808Encoder.java | 155 ++++ .../codec/jt808/entity/Jt808BatteryInfo.java | 68 ++ .../jt808/entity/Jt808BluetoothInfo.java | 54 ++ .../codec/jt808/entity/Jt808ButtonEvent.java | 86 +++ .../codec/jt808/entity/Jt808DataPack.java | 116 +++ .../codec/jt808/entity/Jt808LocationInfo.java | 107 +++ .../codec/jt808/entity/Jt808RegisterInfo.java | 60 ++ .../jt808/parser/Jt808BatteryInfoParser.java | 134 ++++ .../parser/Jt808BluetoothBeaconParser.java | 102 +++ .../jt808/parser/Jt808ExtensionParser.java | 30 + .../parser/Jt808ExtensionParserFactory.java | 49 ++ .../jt808/parser/Jt808MileageParser.java | 48 ++ .../jt808/parser/Jt808NearbyBleParser.java | 86 +++ .../parser/Jt808SignalStrengthParser.java | 39 + .../codec/jt808/util/Jt808BcdUtil.java | 64 ++ .../codec/jt808/util/Jt808BitUtil.java | 182 +++++ .../codec/jt808/util/Jt808ProtocolUtil.java | 140 ++++ .../codec/people/IotPeopleCounterCodec.java | 131 ++++ .../people/dto/PeopleCounterUploadResp.java | 44 ++ .../tcp/IotTcpJsonDeviceMessageCodec.java | 55 +- .../config/IotGatewayConfiguration.java | 6 +- .../http/router/IotHttpAbstractHandler.java | 53 +- .../http/router/IotHttpUpstreamHandler.java | 31 +- .../protocol/tcp/IotTcpUpstreamProtocol.java | 11 +- .../tcp/handler/AbstractProtocolHandler.java | 127 ++++ .../protocol/tcp/handler/AuthResult.java | 146 ++++ .../tcp/handler/Jt808ProtocolHandler.java | 536 ++++++++++++++ .../protocol/tcp/handler/ProtocolHandler.java | 105 +++ .../tcp/handler/StandardProtocolHandler.java | 192 +++++ .../tcp/router/IotTcpDownstreamHandler.java | 22 +- .../tcp/router/IotTcpUpstreamHandler.java | 418 ++++++----- .../src/main/resources/application.yaml | 16 +- .../iot/api/device/IoTDeviceApiImpl.java | 19 +- .../admin/alert/IotAlertConfigController.java | 5 +- .../device/IotDevicePropertyController.java | 2 +- .../admin/ota/IotOtaTaskController.java | 2 +- .../dal/dataobject/device/IotDeviceDO.java | 13 +- .../dataobject/device/IotDeviceGroupDO.java | 5 +- .../product/IotProductCategoryDO.java | 5 +- .../dal/dataobject/product/IotProductDO.java | 18 +- .../iot/service/device/IotDeviceService.java | 13 + .../service/device/IotDeviceServiceImpl.java | 89 ++- .../message/IotDeviceMessageServiceImpl.java | 5 +- .../product/IotProductServiceImpl.java | 4 +- .../mapper/device/IotDeviceMessageMapper.xml | 2 +- 50 files changed, 4440 insertions(+), 285 deletions(-) create mode 100644 viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/enums/IotAuthTypeEnum.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/IotJt808DeviceMessageCodec.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/Jt808Constants.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/Jt808Decoder.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/Jt808Encoder.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808BatteryInfo.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808BluetoothInfo.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808ButtonEvent.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808DataPack.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808LocationInfo.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808RegisterInfo.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808BatteryInfoParser.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808BluetoothBeaconParser.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808ExtensionParser.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808ExtensionParserFactory.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808MileageParser.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808NearbyBleParser.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808SignalStrengthParser.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/util/Jt808BcdUtil.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/util/Jt808BitUtil.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/util/Jt808ProtocolUtil.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/people/IotPeopleCounterCodec.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/people/dto/PeopleCounterUploadResp.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/AbstractProtocolHandler.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/AuthResult.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/Jt808ProtocolHandler.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/ProtocolHandler.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/StandardProtocolHandler.java diff --git a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/biz/dto/IotDeviceRespDTO.java b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/biz/dto/IotDeviceRespDTO.java index bac75eb..10e83ae 100644 --- a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/biz/dto/IotDeviceRespDTO.java +++ b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/biz/dto/IotDeviceRespDTO.java @@ -38,4 +38,16 @@ public class IotDeviceRespDTO { */ private String codecType; + /** + * 设备级认证类型 + * 枚举 {@link com.viewsh.module.iot.core.enums.IotAuthTypeEnum} + */ + private String authType; + + /** + * 产品级认证类型 (兜底策略) + * 枚举 {@link com.viewsh.module.iot.core.enums.IotAuthTypeEnum} + */ + private String productAuthType; + } \ No newline at end of file diff --git a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/enums/IotAuthTypeEnum.java b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/enums/IotAuthTypeEnum.java new file mode 100644 index 0000000..10478a1 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/enums/IotAuthTypeEnum.java @@ -0,0 +1,58 @@ +package com.viewsh.module.iot.core.enums; + +import com.viewsh.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * IoT 认证类型枚举 + *

+ * 用于产品(Product)和设备(Device)的认证策略配置 + * + * @author 芋道源码 + */ +@AllArgsConstructor +@Getter +public enum IotAuthTypeEnum implements ArrayValuable { + + /** + * 一机一密:每个设备有独立的 DeviceSecret + */ + SECRET("SECRET", "一机一密"), + + /** + * 一型一密:同产品下所有设备共用 ProductSecret + */ + PRODUCT_SECRET("PRODUCT_SECRET", "一型一密"), + + /** + * 动态注册:设备初次连接时自动创建,通常配合一型一密或免鉴权使用 + * 注意:这通常是一个"能力开关"而非单纯的"认证方式",但在某些简单模型下可作为一种策略标识 + * TODO: 暂未实现动态注册 + */ + DYNAMIC("DYNAMIC", "动态注册"), + + /** + * 免鉴权:仅校验 ProductKey 和 DeviceName 存在 + */ + NONE("NONE", "免鉴权"); + + public static final String[] ARRAYS = Arrays.stream(values()).map(IotAuthTypeEnum::getType).toArray(String[]::new); + + /** + * 类型代码 + */ + private final String type; + /** + * 名字 + */ + private final String name; + + @Override + public String[] array() { + return ARRAYS; + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/IotJt808DeviceMessageCodec.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/IotJt808DeviceMessageCodec.java new file mode 100644 index 0000000..1c13d5b --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/IotJt808DeviceMessageCodec.java @@ -0,0 +1,672 @@ +package com.viewsh.module.iot.gateway.codec.jt808; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import com.viewsh.framework.common.util.json.JsonUtils; +import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum; +import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.gateway.codec.IotDeviceMessageCodec; +import com.viewsh.module.iot.gateway.codec.jt808.entity.*; +import com.viewsh.module.iot.gateway.codec.jt808.util.Jt808ProtocolUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * JT808 协议 {@link IotDeviceMessage} 编解码器 + *

+ * 将 JT808 标准协议转换为统一的 IotDeviceMessage 格式 + * + * @author lzh + */ +@Slf4j +@Component +public class IotJt808DeviceMessageCodec implements IotDeviceMessageCodec { + + public static final String TYPE = "JT808"; + + private final Jt808Decoder decoder; + private final Jt808Encoder encoder; + private final Jt808ProtocolUtil protocolUtil; + + public IotJt808DeviceMessageCodec() { + this.decoder = new Jt808Decoder(); + this.encoder = new Jt808Encoder(); + this.protocolUtil = new Jt808ProtocolUtil(); + } + + @Override + public String type() { + return TYPE; + } + + @Override + public byte[] encode(IotDeviceMessage message) { + Assert.notNull(message, "消息不能为空"); + Assert.notBlank(message.getMethod(), "消息方法不能为空"); + + try { + // 从消息中提取必要信息 + String phone = extractPhoneNumber(message); + int flowId = extractFlowId(message); + String method = message.getMethod(); + + // 根据方法名路由到不同的编码器 + return switch (method) { + // === 标准物模型方法 === + case "thing.service.invoke" -> encodeServiceInvoke(message, phone, flowId); // 服务调用 + case "thing.property.set" -> encodePropertySet(message, phone, flowId); // 属性设置 + + // === JT808 内部协议方法(向下兼容) === + case "jt808.platform.commonResp", "commonResp" -> encodeCommonResp(message, phone, flowId); + case "jt808.platform.registerResp", "registerResp" -> encodeRegisterResp(message, phone, flowId); + case "jt808.platform.textDown", "textDown" -> encodeTextDown(message, phone, flowId); + + default -> { + log.warn("[encode][不支持的消息方法: {}]", method); + yield new byte[0]; + } + }; + } catch (Exception e) { + log.error("[encode][JT808 消息编码失败,消息: {}]", message, e); + throw new RuntimeException("JT808 消息编码失败: " + e.getMessage(), e); + } + } + + @Override + public IotDeviceMessage decode(byte[] bytes) { + Assert.notNull(bytes, "待解码数据不能为空"); + Assert.isTrue(bytes.length >= 12, "数据包长度不足"); + + try { + // 1. 反转义(去除首尾标识符) + byte[] unescapedBytes = protocolUtil.doEscape4Receive(bytes, 1, bytes.length - 1); + + // 2. 解析为 JT808 数据包 + Jt808DataPack dataPack = decoder.bytes2PackageData(unescapedBytes); + + // 3. 转换为统一的 IotDeviceMessage + return convertToIotDeviceMessage(dataPack); + } catch (Exception e) { + log.error("[decode][JT808 消息解码失败,数据长度: {}]", bytes.length, e); + throw new RuntimeException("JT808 消息解码失败: " + e.getMessage(), e); + } + } + + /** + * 将 JT808 数据包转换为 IotDeviceMessage + */ + private IotDeviceMessage convertToIotDeviceMessage(Jt808DataPack dataPack) { + Jt808DataPack.PackHead head = dataPack.getPackHead(); + int msgId = head.getId(); + + // 生成消息ID(使用流水号作为标识) + String messageId = head.getTerminalPhone() + "_" + head.getFlowId(); + + // 根据消息ID确定物模型标准方法名 + String method = getStandardMethodName(msgId); + Object params = parseMessageParams(dataPack, msgId); + + // 构建元数据(保留在 params 中,用于调试和追踪) + if (params instanceof Map) { + @SuppressWarnings("unchecked") + Map paramsMap = (Map) params; + Map metadata = new HashMap<>(); + metadata.put("jt808MsgId", String.format("0x%04X", msgId)); + metadata.put("terminalPhone", head.getTerminalPhone()); + metadata.put("flowId", head.getFlowId()); + metadata.put("encryptionType", head.getEncryptionType()); + paramsMap.put("_metadata", metadata); + } + + // 创建 IotDeviceMessage + IotDeviceMessage message = IotDeviceMessage.of(messageId, method, params, null, null, null); + message.setReportTime(LocalDateTime.now()); + return message; + } + + /** + * 根据 JT808 消息ID获取物模型标准方法名 + * + * 映射关系: + * - 0x0002 心跳 -> thing.state.update(设备状态更新) + * - 0x0200 位置上报 -> thing.property.post(属性上报) + * - 0x0704 批量位置上报 -> thing.property.post(属性上报) + * - 0x0006 按键事件 -> thing.event.post(事件上报) + * - 0x0100 注册 -> 内部认证流程,不使用标准方法 + * - 0x0102 鉴权 -> 内部认证流程,不使用标准方法 + * - 0x0001 通用应答 -> 内部应答,不使用标准方法 + */ + private String getStandardMethodName(int msgId) { + return switch (msgId) { + // 设备状态类 + case 0x0002 -> IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod(); // 心跳 -> 状态更新 + + // 属性上报类 + case 0x0200 -> IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(); // 位置信息汇报 -> 属性上报 + case 0x0704 -> IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(); // 批量位置上传 -> 属性上报 + + // 事件上报类 + case 0x0006 -> IotDeviceMessageMethodEnum.EVENT_POST.getMethod(); // 按键事件 -> 事件上报 + + // 内部协议类(不映射到标准方法,使用 JT808 特定方法名) + case 0x0001 -> "jt808.terminal.commonResp"; // 终端通用应答 + case 0x0100 -> "jt808.terminal.register"; // 终端注册 + case 0x0102 -> "jt808.terminal.auth"; // 终端鉴权 + case 0x0003 -> "jt808.terminal.logout"; // 终端注销 + + // 未知消息 + default -> "jt808.unknown.0x" + Integer.toHexString(msgId); + }; + } + + /** + * 解析消息参数(根据物模型标准格式) + */ + private Object parseMessageParams(Jt808DataPack dataPack, int msgId) { + byte[] bodyBytes = dataPack.getBodyBytes(); + + // 针对不同消息类型进行特殊解析,返回符合物模型标准的格式 + return switch (msgId) { + // thing.property.post - 返回 properties + case 0x0200, 0x0704 -> parseLocationInfoAsProperties(dataPack); + + // thing.state.update - 返回 state 信息 + case 0x0002 -> parseHeartbeatAsState(); + + // thing.event.post - 返回 event 信息 + case 0x0006 -> parseButtonEventAsEvent(dataPack); + + // JT808 内部协议消息 - 保持原有格式 + case 0x0100 -> parseRegisterInfo(dataPack); + case 0x0102 -> parseAuthInfo(bodyBytes); + default -> parseGenericParams(bodyBytes); + }; + } + + /** + * 解析心跳为状态信息(thing.state.update) + */ + private Map parseHeartbeatAsState() { + Map result = new HashMap<>(); + result.put("state", "online"); + result.put("timestamp", System.currentTimeMillis()); + return result; + } + + /** + * 解析按键事件为事件上报格式(thing.event.post) + * + * 物模型标准格式: + * { + * "eventId": "button_event", + * "eventTime": 1234567890, + * "params": { + * "keyId": 1, + * "keyState": 1, + * "keyType": "short_press", + * "keyNumber": 1, + * "isLongPress": false + * } + * } + */ + private Map parseButtonEventAsEvent(Jt808DataPack dataPack) { + Jt808ButtonEvent event = decoder.toButtonEventMsg(dataPack); + + Map result = new HashMap<>(); + + // 统一使用一个事件标识符,通过 isLongPress 参数区分短按和长按 + result.put("eventId", "button_event"); + + // 事件时间戳 + result.put("eventTime", System.currentTimeMillis()); + + // 事件参数(包含 isLongPress 字段用于区分短按和长按) + Map eventParams = new HashMap<>(); + eventParams.put("keyId", event.getKeyId()); + eventParams.put("keyState", event.getKeyState()); + eventParams.put("keyType", event.getKeyType()); + eventParams.put("keyNumber", event.getKeyNumber()); + eventParams.put("isLongPress", event.getIsLongPress()); + result.put("params", eventParams); + + return result; + } + + /** + * 解析位置信息为属性上报格式(thing.property.post) + * + * 物模型标准格式:params 直接就是属性键值对 + * { + * "latitude": 31.123456, + * "longitude": 121.123456, + * "batteryLevel": 80, + * ... + * } + */ + private Map parseLocationInfoAsProperties(Jt808DataPack dataPack) { + Jt808LocationInfo locationInfo = decoder.toLocationInfoUploadMsg(dataPack); + + // 物模型属性集合(所有属性平铺在同一层) + Map properties = new HashMap<>(); + + // === 基础位置信息(核心属性) === + properties.put("latitude", locationInfo.getLatitude()); + properties.put("longitude", locationInfo.getLongitude()); + properties.put("elevation", locationInfo.getElevation()); + properties.put("speed", locationInfo.getSpeed()); + properties.put("direction", locationInfo.getDirection()); + properties.put("time", locationInfo.getTime()); + + // 状态和告警字段(转为整数便于处理) + properties.put("warningFlag", locationInfo.getWarningFlagField()); + properties.put("status", locationInfo.getStatusField()); + + // === 扩展信息(物模型属性) === + + // 电池信息 + if (locationInfo.getBatteryInfo() != null) { + Jt808BatteryInfo batteryInfo = locationInfo.getBatteryInfo(); + if (batteryInfo.getBatteryLevel() != null) { + properties.put("batteryLevel", batteryInfo.getBatteryLevel()); + } + if (batteryInfo.getVersion() != null) { + properties.put("firmwareVersion", batteryInfo.getVersion()); + } + if (batteryInfo.getIccid() != null) { + properties.put("iccid", batteryInfo.getIccid()); + } + } + + // 里程 + if (locationInfo.getMileage() != null) { + properties.put("mileage", locationInfo.getMileage()); + } + + // 信号强度 + if (locationInfo.getSignalStrength() != null) { + properties.put("signalStrength", locationInfo.getSignalStrength()); + } + + // 蓝牙设备列表 + if (locationInfo.getBluetoothInfos() != null && !locationInfo.getBluetoothInfos().isEmpty()) { + List> bluetoothList = new ArrayList<>(); + for (Jt808BluetoothInfo bleInfo : locationInfo.getBluetoothInfos()) { + Map bleMap = new HashMap<>(); + bleMap.put("mac", bleInfo.getMac()); + bleMap.put("rssi", bleInfo.getRssi()); + bleMap.put("type", bleInfo.getType()); + if (bleInfo.getCustomData() != null) { + bleMap.put("customData", bleInfo.getCustomData()); + } + bluetoothList.add(bleMap); + } + properties.put("bluetoothDevices", bluetoothList); + } + + // 保留原始扩展字段(用于调试和高级用途) + if (locationInfo.getExtensions() != null && !locationInfo.getExtensions().isEmpty()) { + properties.put("_rawExtensions", locationInfo.getExtensions()); + } + + return properties; + } + + /** + * 解析注册信息 + */ + private Map parseRegisterInfo(Jt808DataPack dataPack) { + Map result = new HashMap<>(); + try { + // 解析注册消息体 + com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808RegisterInfo registerInfo = + decoder.toRegisterMsg(dataPack); + + result.put("provinceId", registerInfo.getProvinceId()); + result.put("cityId", registerInfo.getCityId()); + result.put("manufacturerId", registerInfo.getManufacturerId()); + result.put("terminalType", registerInfo.getTerminalType()); + result.put("terminalId", registerInfo.getTerminalId()); + result.put("licensePlateColor", registerInfo.getLicensePlateColor()); + result.put("licensePlate", registerInfo.getLicensePlate()); + } catch (Exception e) { + log.error("[parseRegisterInfo][解析注册信息失败]", e); + // 失败时返回原始数据 + result.put("rawData", bytesToHex(dataPack.getBodyBytes())); + result.put("length", dataPack.getBodyBytes().length); + } + return result; + } + + /** + * 解析鉴权信息 + */ + private Map parseAuthInfo(byte[] bodyBytes) { + Map result = new HashMap<>(); + try { + // 鉴权码为字符串 + String authCode = new String(bodyBytes, Jt808Constants.DEFAULT_CHARSET).trim(); + result.put("authCode", authCode); + } catch (Exception e) { + result.put("rawData", bytesToHex(bodyBytes)); + } + return result; + } + + /** + * 通用参数解析 + */ + private Object parseGenericParams(byte[] bodyBytes) { + if (bodyBytes == null || bodyBytes.length == 0) { + return Map.of(); + } + + // 尝试解析为字符串,如果失败则返回十六进制 + try { + String str = new String(bodyBytes, Jt808Constants.DEFAULT_CHARSET); + // 检查是否为可打印字符 + if (str.matches("[\\x20-\\x7E\\u4E00-\\u9FA5]+")) { + return Map.of("content", str); + } + } catch (Exception ignored) { + } + + return Map.of("rawData", bytesToHex(bodyBytes)); + } + + /** + * 编码通用应答 + */ + private byte[] encodeCommonResp(IotDeviceMessage message, String phone, int flowId) { + Map params = getParamsAsMap(message); + int replyFlowId = ((Number) params.getOrDefault("replyFlowId", 0)).intValue(); + int replyId = ((Number) params.getOrDefault("replyId", 0)).intValue(); + byte replyCode = ((Number) params.getOrDefault("replyCode", 0)).byteValue(); + + return encoder.encodeCommonResp(phone, replyFlowId, replyId, replyCode, flowId); + } + + /** + * 编码注册应答 + */ + private byte[] encodeRegisterResp(IotDeviceMessage message, String phone, int flowId) { + Map params = getParamsAsMap(message); + int replyFlowId = ((Number) params.getOrDefault("replyFlowId", 0)).intValue(); + byte replyCode = ((Number) params.getOrDefault("replyCode", 0)).byteValue(); + String authToken = (String) params.get("authToken"); + + return encoder.encodeRegisterResp(phone, replyFlowId, replyCode, authToken, flowId); + } + + /** + * 编码文本下发 + */ + private byte[] encodeTextDown(IotDeviceMessage message, String phone, int flowId) { + Map params = getParamsAsMap(message); + byte flag = ((Number) params.getOrDefault("flag", 0)).byteValue(); + String content = (String) params.getOrDefault("content", ""); + + return encoder.encodeTextInfoDown(phone, flag, content, flowId); + } + + /** + * 编码服务调用(thing.service.invoke) + * + * 根据服务标识符映射到不同的 JT808 指令 + * + * 消息格式: + * { + * "identifier": "服务标识符", + * "params": { + * // 服务参数 + * } + * } + */ + private byte[] encodeServiceInvoke(IotDeviceMessage message, String phone, int flowId) { + Map params = getParamsAsMap(message); + + // 获取服务标识符 + String serviceIdentifier = (String) params.get("identifier"); + if (StrUtil.isBlank(serviceIdentifier)) { + log.error("[encodeServiceInvoke][服务标识符为空]"); + return new byte[0]; + } + + // 获取服务参数 + @SuppressWarnings("unchecked") + Map serviceParams = (Map) params.getOrDefault("params", new HashMap<>()); + + // 根据服务标识符路由到不同的 JT808 指令 + return switch (serviceIdentifier) { + case "TTS" -> { + // 语音播报服务 -> JT808 文本信息下发 (0x8300) + String ttsText = (String) serviceParams.getOrDefault("tts_text", ""); + int ttsFlag = ((Number) serviceParams.getOrDefault("tts_flag", 4)).intValue(); // 默认 4-TTS 播读 + + log.info("[encodeServiceInvoke][TTS 语音播报] phone={}, flag={}, text={}", phone, ttsFlag, ttsText); + yield encoder.encodeTextInfoDown(phone, (byte) ttsFlag, ttsText, flowId); + } + + case "locationQuery" -> { + // 位置查询服务 -> JT808 位置信息查询 (0x8201) + log.info("[encodeServiceInvoke][位置查询] phone={}", phone); + // TODO: 实现 encoder.encodeLocationInquiry(phone, flowId); + log.warn("[encodeServiceInvoke][位置查询服务暂未实现]"); + yield new byte[0]; + } + + default -> { + log.warn("[encodeServiceInvoke][不支持的服务标识符: {}]", serviceIdentifier); + yield new byte[0]; + } + }; + } + + /** + * 编码属性设置(thing.property.set) + * + * 属性设置映射到 JT808 的参数设置指令 (0x8103) + * + * 消息格式: + * { + * "properties": { + * "identifier1": value1, + * "identifier2": value2 + * } + * } + */ + private byte[] encodePropertySet(IotDeviceMessage message, String phone, int flowId) { + Map params = getParamsAsMap(message); + + @SuppressWarnings("unchecked") + Map properties = (Map) params.get("properties"); + if (properties == null || properties.isEmpty()) { + log.error("[encodePropertySet][属性列表为空]"); + return new byte[0]; + } + + // 将物模型属性映射到 JT808 参数 + Map jt808Params = mapPropertiesToJt808Params(properties); + if (jt808Params.isEmpty()) { + log.warn("[encodePropertySet][没有可映射的 JT808 参数]"); + return new byte[0]; + } + + log.info("[encodePropertySet][属性设置] phone={}, params={}", phone, jt808Params); + + // TODO: 实现 encoder.encodeParamSettings(phone, jt808Params, flowId); + log.warn("[encodePropertySet][参数设置指令暂未实现]"); + return new byte[0]; + } + + /** + * 将物模型属性映射到 JT808 参数 + * + * 映射关系参考 JT808 协议标准: + * - 0x0029: 心跳发送间隔 + * - 0x0027: 位置汇报间隔 + * - 0x0028: 休眠时汇报间隔 + * - 等等... + * + * @param properties 物模型属性 + * @return JT808 参数映射(参数ID -> 参数值) + */ + private Map mapPropertiesToJt808Params(Map properties) { + Map jt808Params = new HashMap<>(); + + properties.forEach((identifier, value) -> { + Integer paramId = switch (identifier) { + case "heartbeatInterval" -> 0x0029; // 心跳发送间隔(单位:秒) + case "locationInterval" -> 0x0027; // 位置汇报间隔(单位:秒) + case "sleepLocationInterval" -> 0x0028; // 休眠时汇报间隔(单位:秒) + case "emergencyAlarmInterval" -> 0x002A; // 紧急报警时汇报间隔(单位:秒) + case "defaultReportInterval" -> 0x002B; // 缺省汇报间隔(单位:秒) + case "defaultReportDistance" -> 0x002C; // 缺省汇报距离(单位:米) + case "driverUnloginReportInterval" -> 0x002D; // 驾驶员未登录汇报间隔(单位:秒) + // 更多映射关系可根据实际需求添加 + default -> null; + }; + + if (paramId != null) { + jt808Params.put(paramId, value); + log.debug("[mapPropertiesToJt808Params][属性映射] {} -> 0x{} = {}", + identifier, Integer.toHexString(paramId), value); + } else { + log.warn("[mapPropertiesToJt808Params][未知的属性标识符: {}]", identifier); + } + }); + + return jt808Params; + } + + /** + * 提取终端手机号 + * + * 优先级: + * 1. 从 params._deviceName 中获取(下发场景,IotTcpDownstreamHandler 自动注入) + * 2. 从 params._metadata.terminalPhone 中获取(上行消息回复场景) + * 3. 从 params.phone 中获取(手动指定,向下兼容,不推荐) + */ + private String extractPhoneNumber(IotDeviceMessage message) { + if (!(message.getParams() instanceof Map)) { + log.error("[extractPhoneNumber][params 不是 Map 类型,消息: {}]", message); + throw new IllegalArgumentException("消息参数格式错误,params 必须是 Map 类型"); + } + + Map params = (Map) message.getParams(); + + // 1. 优先从 _deviceName 获取(下发场景,由 IotTcpDownstreamHandler 注入) + Object deviceName = params.get("_deviceName"); + if (deviceName != null && StrUtil.isNotBlank(deviceName.toString())) { + String deviceNameStr = deviceName.toString().trim(); + // 验证是否为数字(终端手机号应该是纯数字) + if (deviceNameStr.matches("\\d+")) { + return deviceNameStr; + } else { + log.warn("[extractPhoneNumber][_deviceName 不是纯数字: {}]", deviceNameStr); + } + } + + // 2. 从 metadata 中获取(上行消息回复场景) + if (params.get("_metadata") instanceof Map) { + Map metadata = (Map) params.get("_metadata"); + Object terminalPhone = metadata.get("terminalPhone"); + if (terminalPhone != null && StrUtil.isNotBlank(terminalPhone.toString())) { + return terminalPhone.toString(); + } + } + + // 3. 从 phone 字段获取(向下兼容,不推荐) + Object phone = params.get("phone"); + if (phone != null && StrUtil.isNotBlank(phone.toString())) { + String phoneStr = phone.toString().trim(); + if (phoneStr.matches("\\d+")) { + return phoneStr; + } + } + + // 4. 如果都获取不到,抛出异常 + log.error("[extractPhoneNumber][无法提取终端手机号,params: {}]", params); + throw new IllegalArgumentException( + "消息中缺少终端手机号。请确保设备的 deviceName 为终端手机号(纯数字),例如: \"13800138000\""); + } + + /** + * 提取流水号 + * + * 对于下发消息,如果没有指定流水号,则生成一个随机流水号 + */ + private int extractFlowId(IotDeviceMessage message) { + if (message.getParams() instanceof Map) { + Map params = (Map) message.getParams(); + + // 尝试获取显式指定的流水号 + Object flowId = params.get("flowId"); + if (flowId instanceof Number) { + return ((Number) flowId).intValue(); + } + + // 尝试从 metadata 中获取(上行消息的流水号) + if (params.get("_metadata") instanceof Map) { + Map metadata = (Map) params.get("_metadata"); + Object metaFlowId = metadata.get("flowId"); + if (metaFlowId instanceof Number) { + return ((Number) metaFlowId).intValue(); + } + } + } + + // 生成随机流水号(1-65535) + return (int) (System.currentTimeMillis() % 65535) + 1; + } + + /** + * 获取参数为Map + */ + @SuppressWarnings("unchecked") + private Map getParamsAsMap(IotDeviceMessage message) { + if (message.getParams() instanceof Map) { + return (Map) message.getParams(); + } + // 尝试JSON转换 + if (message.getParams() != null) { + String json = JsonUtils.toJsonString(message.getParams()); + return JsonUtils.parseObject(json, Map.class); + } + return new HashMap<>(); + } + + /** + * 字节数组转十六进制字符串 + */ + private String bytesToHex(byte[] bytes) { + if (bytes == null || bytes.length == 0) { + return ""; + } + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02X", b)); + } + return sb.toString(); + } + + /** + * 快速检测是否为JT808格式 + * + * @param data 数据 + * @return 是否为JT808格式 + */ + public static boolean isJt808Format(byte[] data) { + // 检查起止标识符 0x7e + return data != null && data.length >= 12 + && data[0] == (byte) Jt808Constants.PKG_DELIMITER + && data[data.length - 1] == (byte) Jt808Constants.PKG_DELIMITER; + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/Jt808Constants.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/Jt808Constants.java new file mode 100644 index 0000000..e25972d --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/Jt808Constants.java @@ -0,0 +1,93 @@ +package com.viewsh.module.iot.gateway.codec.jt808; + +import java.nio.charset.Charset; + +/** + * JT808 协议常量 + * + * @author lzh + */ +public class Jt808Constants { + + /** + * 默认字符编码 + */ + public static final String DEFAULT_ENCODE = "GBK"; + public static final Charset DEFAULT_CHARSET = Charset.forName(DEFAULT_ENCODE); + + /** + * 标识位(起止符) + */ + public static final int PKG_DELIMITER = 0x7e; + + // ========== 终端上行消息ID ========== + + /** + * 终端通用应答 + */ + public static final Integer MSGID_COMMON_RESP = 0x0001; + + /** + * 终端心跳 + */ + public static final Integer MSGID_HEART_BEAT = 0x0002; + + /** + * 终端注册 + */ + public static final Integer MSGID_REGISTER = 0x0100; + + /** + * 终端注销 + */ + public static final Integer MSGID_LOG_OUT = 0x0003; + + /** + * 终端鉴权 + */ + public static final Integer MSGID_AUTHENTICATION = 0x0102; + + /** + * 位置信息汇报 + */ + public static final Integer MSGID_LOCATION_UPLOAD = 0x0200; + + /** + * 按键事件上报 + */ + public static final Integer MSGID_BUTTON_EVENT = 0x0006; + + /** + * 定位数据批量上传 + */ + public static final Integer MSGID_LOCATION_BATCH_UPLOAD = 0x0704; + + // ========== 平台下行命令ID ========== + + /** + * 平台通用应答 + */ + public static final int CMD_COMMON_RESP = 0x8001; + + /** + * 终端注册应答 + */ + public static final int CMD_REGISTER_RESP = 0x8100; + + /** + * 设置终端参数 + */ + public static final int CMD_PARAM_SETTINGS = 0X8103; + + /** + * 文本信息下发 + */ + public static final int CMD_TEXT_INFO_DOWN = 0x8300; + + /** + * 位置信息查询 + */ + public static final int CMD_LOCATION_INQUIRY = 0x8201; + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/Jt808Decoder.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/Jt808Decoder.java new file mode 100644 index 0000000..25c9ce7 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/Jt808Decoder.java @@ -0,0 +1,295 @@ +package com.viewsh.module.iot.gateway.codec.jt808; + +import com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808ButtonEvent; +import com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808DataPack; +import com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808DataPack.PackHead; +import com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808LocationInfo; +import com.viewsh.module.iot.gateway.codec.jt808.parser.Jt808ExtensionParser; +import com.viewsh.module.iot.gateway.codec.jt808.parser.Jt808ExtensionParserFactory; +import com.viewsh.module.iot.gateway.codec.jt808.util.Jt808BcdUtil; +import com.viewsh.module.iot.gateway.codec.jt808.util.Jt808BitUtil; +import lombok.extern.slf4j.Slf4j; + +/** + * JT808 协议解码器 + * + * @author lzh + */ +@Slf4j +public class Jt808Decoder { + + private final Jt808BitUtil bitUtil; + private final Jt808BcdUtil bcdUtil; + + public Jt808Decoder() { + this.bitUtil = new Jt808BitUtil(); + this.bcdUtil = new Jt808BcdUtil(); + } + + /** + * 字节数组转JT808数据包 + */ + public Jt808DataPack bytes2PackageData(byte[] data) { + Jt808DataPack ret = new Jt808DataPack(); + + // 1. 解析消息头 (16byte 或 12byte) + PackHead msgHeader = this.parseMsgHeaderFromBytes(data); + ret.setPackHead(msgHeader); + + // 2. 解析消息体 + int msgBodyByteStartIndex = 12; + // 有子包信息,消息体起始字节后移四个字节 + if (msgHeader.isHasSubPackage()) { + msgBodyByteStartIndex = 16; + } + + byte[] bodyBytes = new byte[msgHeader.getBodyLength()]; + System.arraycopy(data, msgBodyByteStartIndex, bodyBytes, 0, bodyBytes.length); + ret.setBodyBytes(bodyBytes); + + // 3. 解析校验码 + int checkSumInPkg = data[data.length - 1]; + int calculatedCheckSum = this.bitUtil.getCheckSum4JT808(data, 0, data.length - 1); + ret.setCheckSum(checkSumInPkg); + if (checkSumInPkg != calculatedCheckSum) { + log.warn("[bytes2PackageData][校验码不一致, msgid: 0x{}, pkg: {}, calculated: {}]", + Integer.toHexString(msgHeader.getId()), checkSumInPkg, calculatedCheckSum); + } + return ret; + } + + /** + * 解析消息头 + */ + private PackHead parseMsgHeaderFromBytes(byte[] data) { + PackHead msgHeader = new PackHead(); + + // 1. 消息ID word(16) + msgHeader.setId(this.parseIntFromBytes(data, 0, 2)); + + // 2. 消息体属性 word(16) + int msgBodyProps = this.parseIntFromBytes(data, 2, 2); + msgHeader.setBodyPropsField(msgBodyProps); + // [ 0-9 ] 消息体长度 + msgHeader.setBodyLength(msgBodyProps & 0x3ff); + // [10-12] 加密类型 + msgHeader.setEncryptionType((msgBodyProps & 0x1c00) >> 10); + // [ 13 ] 是否有子包 + msgHeader.setHasSubPackage(((msgBodyProps & 0x2000) >> 13) == 1); + // [14-15] 保留位 + msgHeader.setReservedBit(((msgBodyProps & 0xc000) >> 14) + ""); + + // 3. 终端手机号 bcd[6] + msgHeader.setTerminalPhone(this.parseBcdStringFromBytes(data, 4, 6)); + + // 4. 消息流水号 word(16) + msgHeader.setFlowId(this.parseIntFromBytes(data, 10, 2)); + + // 5. 消息包封装项(如果有子包) + if (msgHeader.isHasSubPackage()) { + msgHeader.setInfoField(this.parseIntFromBytes(data, 12, 4)); + msgHeader.setSubPackage(this.parseIntFromBytes(data, 12, 2)); + msgHeader.setSubPackageSequeue(this.parseIntFromBytes(data, 14, 2)); + } + + return msgHeader; + } + + /** + * 解析位置信息上报消息 + */ + public Jt808LocationInfo toLocationInfoUploadMsg(Jt808DataPack packageData) { + Jt808LocationInfo ret = new Jt808LocationInfo(); + final byte[] data = packageData.getBodyBytes(); + + // 1. byte[0-3] 报警标志(DWORD(32)) + ret.setWarningFlagField(this.parseIntFromBytes(data, 0, 4)); + // 2. byte[4-7] 状态(DWORD(32)) + ret.setStatusField(this.parseIntFromBytes(data, 4, 4)); + // 3. byte[8-11] 纬度(DWORD(32)) + ret.setLatitude(this.parseIntFromBytes(data, 8, 4) / 1000000.0f); + // 4. byte[12-15] 经度(DWORD(32)) + ret.setLongitude(this.parseIntFromBytes(data, 12, 4) / 1000000.0f); + // 5. byte[16-17] 高程(WORD(16)) + ret.setElevation(this.parseIntFromBytes(data, 16, 2)); + // 6. byte[18-19] 速度(WORD) + ret.setSpeed(this.parseIntFromBytes(data, 18, 2) / 10.0f); + // 7. byte[20-21] 方向(WORD) + ret.setDirection(this.parseIntFromBytes(data, 20, 2)); + // 8. byte[22-27] 时间(BCD[6]) + ret.setTime(this.parseBcdStringFromBytes(data, 22, 6)); + + // 9. 解析扩展字段 (从index 28开始) + parseExtensionFields(ret, data, 28); + + return ret; + } + + /** + * 解析扩展字段 + */ + private void parseExtensionFields(Jt808LocationInfo locationInfo, byte[] data, int startIndex) { + int index = startIndex; + + while (index < data.length) { + // 确保至少有ID和长度字节 + if (index + 1 >= data.length) { + break; + } + + int extId = data[index] & 0xFF; + int extLen = data[index + 1] & 0xFF; + + // 验证剩余长度 + if (index + 2 + extLen > data.length) { + log.warn("[parseExtensionFields][扩展字段长度越界: ID=0x{}, Len={}, Remaining={}]", + String.format("%02X", extId), extLen, data.length - index - 2); + break; + } + + int contentStart = index + 2; + + // 尝试使用注册的解析器解析 + Jt808ExtensionParser parser = Jt808ExtensionParserFactory.getParser(extId); + if (parser != null) { + try { + parser.parse(locationInfo, data, contentStart, extLen); + log.debug("[parseExtensionFields][扩展字段解析成功: ID=0x{}]", + String.format("%02X", extId)); + } catch (Exception e) { + log.error("[parseExtensionFields][扩展字段解析失败: ID=0x{}]", + String.format("%02X", extId), e); + } + } else { + // 未知扩展字段,保存为原始字节数组 + byte[] extData = new byte[extLen]; + System.arraycopy(data, contentStart, extData, 0, extLen); + locationInfo.addExtension(extId, extData); + log.debug("[parseExtensionFields][未知扩展字段: ID=0x{}, Len={}]", + String.format("%02X", extId), extLen); + } + + index += (2 + extLen); + } + } + + /** + * 解析按键事件消息 + */ + public Jt808ButtonEvent toButtonEventMsg(Jt808DataPack packageData) { + Jt808ButtonEvent event = new Jt808ButtonEvent(); + final byte[] data = packageData.getBodyBytes(); + + if (data.length >= 1) { + event.setKeyId(this.parseIntFromBytes(data, 0, 1)); + } + + if (data.length >= 2) { + event.setKeyState(this.parseIntFromBytes(data, 1, 1)); + } + + // 解析按键信息 + event.parseKeyInfo(); + + return event; + } + + /** + * 解析终端注册消息 (0x0100) + * + * @param packageData JT808 数据包 + * @return 注册信息 + */ + public com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808RegisterInfo toRegisterMsg(Jt808DataPack packageData) { + com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808RegisterInfo registerInfo = + new com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808RegisterInfo(); + final byte[] data = packageData.getBodyBytes(); + + if (data.length < 37) { + log.warn("[toRegisterMsg][注册消息体长度不足: {}]", data.length); + return registerInfo; + } + + // 1. byte[0-1] 省域ID (WORD) + registerInfo.setProvinceId(this.parseIntFromBytes(data, 0, 2)); + + // 2. byte[2-3] 市县域ID (WORD) + registerInfo.setCityId(this.parseIntFromBytes(data, 2, 2)); + + // 3. byte[4-8] 制造商ID (BYTE[5]) + registerInfo.setManufacturerId(this.parseStringFromBytes(data, 4, 5)); + + // 4. byte[9-28] 终端型号 (BYTE[20]) + registerInfo.setTerminalType(this.parseStringFromBytes(data, 9, 20)); + + // 5. byte[29-35] 终端ID (BYTE[7]) + registerInfo.setTerminalId(this.parseStringFromBytes(data, 29, 7)); + + // 6. byte[36] 车牌颜色 (BYTE) + registerInfo.setLicensePlateColor(this.parseIntFromBytes(data, 36, 1)); + + // 7. byte[37-x] 车牌 (STRING) + if (data.length > 37) { + registerInfo.setLicensePlate(this.parseStringFromBytes(data, 37, data.length - 37)); + } + + return registerInfo; + } + + /** + * 解析字符串 + */ + protected String parseStringFromBytes(byte[] data, int startIndex, int length) { + return parseStringFromBytes(data, startIndex, length, null); + } + + private String parseStringFromBytes(byte[] data, int startIndex, int length, String defaultVal) { + try { + byte[] tmp = new byte[length]; + System.arraycopy(data, startIndex, tmp, 0, length); + return new String(tmp, Jt808Constants.DEFAULT_CHARSET); + } catch (Exception e) { + log.error("[parseStringFromBytes][解析字符串出错: {}]", e.getMessage()); + return defaultVal; + } + } + + /** + * 解析BCD字符串 + */ + private String parseBcdStringFromBytes(byte[] data, int startIndex, int length) { + return parseBcdStringFromBytes(data, startIndex, length, null); + } + + private String parseBcdStringFromBytes(byte[] data, int startIndex, int length, String defaultVal) { + try { + byte[] tmp = new byte[length]; + System.arraycopy(data, startIndex, tmp, 0, length); + return this.bcdUtil.bcd2String(tmp); + } catch (Exception e) { + log.error("[parseBcdStringFromBytes][解析BCD(8421码)出错: {}]", e.getMessage()); + return defaultVal; + } + } + + /** + * 解析整数 + */ + private int parseIntFromBytes(byte[] data, int startIndex, int length) { + return parseIntFromBytes(data, startIndex, length, 0); + } + + private int parseIntFromBytes(byte[] data, int startIndex, int length, int defaultVal) { + try { + final int len = Math.min(length, 4); + byte[] tmp = new byte[len]; + System.arraycopy(data, startIndex, tmp, 0, len); + return bitUtil.byteToInteger(tmp); + } catch (Exception e) { + log.error("[parseIntFromBytes][解析整数出错: {}]", e.getMessage()); + return defaultVal; + } + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/Jt808Encoder.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/Jt808Encoder.java new file mode 100644 index 0000000..031ac67 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/Jt808Encoder.java @@ -0,0 +1,155 @@ +package com.viewsh.module.iot.gateway.codec.jt808; + +import com.viewsh.module.iot.gateway.codec.jt808.util.Jt808BitUtil; +import com.viewsh.module.iot.gateway.codec.jt808.util.Jt808ProtocolUtil; +import lombok.extern.slf4j.Slf4j; + +import java.util.Arrays; + +/** + * JT808 协议编码器 + * + * @author lzh + */ +@Slf4j +public class Jt808Encoder { + + private final Jt808BitUtil bitUtil; + private final Jt808ProtocolUtil jt808Util; + + public Jt808Encoder() { + this.bitUtil = new Jt808BitUtil(); + this.jt808Util = new Jt808ProtocolUtil(); + } + + /** + * 编码通用应答消息 + * + * @param phone 终端手机号 + * @param replyFlowId 应答流水号 + * @param replyId 应答ID + * @param replyCode 应答结果码 + * @param flowId 消息流水号 + * @return 编码后的字节数组 + */ + public byte[] encodeCommonResp(String phone, int replyFlowId, int replyId, byte replyCode, int flowId) { + try { + // 消息体 + byte[] msgBody = this.bitUtil.concatAll(Arrays.asList( + bitUtil.integerTo2Bytes(replyFlowId), // 应答流水号 + bitUtil.integerTo2Bytes(replyId), // 应答ID + new byte[]{replyCode} // 结果码 + )); + + // 消息头 + int msgBodyProps = this.jt808Util.generateMsgBodyProps(msgBody.length, 0b000, false, 0); + byte[] msgHeader = this.jt808Util.generateMsgHeader(phone, + Jt808Constants.CMD_COMMON_RESP, msgBody, msgBodyProps, flowId); + byte[] headerAndBody = this.bitUtil.concatAll(msgHeader, msgBody); + + // 校验码 + int checkSum = this.bitUtil.getCheckSum4JT808(headerAndBody, 0, headerAndBody.length); + + // 连接并转义 + return this.doEncode(headerAndBody, checkSum); + } catch (Exception e) { + log.error("[encodeCommonResp][编码通用应答失败]", e); + throw new RuntimeException("编码通用应答失败", e); + } + } + + /** + * 编码终端注册应答消息 + * + * @param phone 终端手机号 + * @param replyFlowId 应答流水号 + * @param replyCode 应答结果码 + * @param authToken 鉴权码(成功时必填) + * @param flowId 消息流水号 + * @return 编码后的字节数组 + */ + public byte[] encodeRegisterResp(String phone, int replyFlowId, byte replyCode, String authToken, int flowId) { + try { + // 消息体 + byte[] msgBody; + if (replyCode == 0 && authToken != null) { + // 成功:包含鉴权码 + msgBody = this.bitUtil.concatAll(Arrays.asList( + bitUtil.integerTo2Bytes(replyFlowId), // 流水号 + new byte[]{replyCode}, // 结果码 + authToken.getBytes(Jt808Constants.DEFAULT_CHARSET) // 鉴权码 + )); + } else { + // 失败:只有流水号和结果码 + msgBody = this.bitUtil.concatAll(Arrays.asList( + bitUtil.integerTo2Bytes(replyFlowId), // 流水号 + new byte[]{replyCode} // 结果码 + )); + } + + // 消息头 + int msgBodyProps = this.jt808Util.generateMsgBodyProps(msgBody.length, 0b000, false, 0); + byte[] msgHeader = this.jt808Util.generateMsgHeader(phone, + Jt808Constants.CMD_REGISTER_RESP, msgBody, msgBodyProps, flowId); + byte[] headerAndBody = this.bitUtil.concatAll(msgHeader, msgBody); + + // 校验码 + int checkSum = this.bitUtil.getCheckSum4JT808(headerAndBody, 0, headerAndBody.length); + + // 连接并转义 + return this.doEncode(headerAndBody, checkSum); + } catch (Exception e) { + log.error("[encodeRegisterResp][编码注册应答失败]", e); + throw new RuntimeException("编码注册应答失败", e); + } + } + + /** + * 编码文本信息下发消息 + * + * @param phone 终端手机号 + * @param flag 文本标志 + * @param content 文本内容 + * @param flowId 消息流水号 + * @return 编码后的字节数组 + */ + public byte[] encodeTextInfoDown(String phone, byte flag, String content, int flowId) { + try { + byte[] contentBytes = content.getBytes(Jt808Constants.DEFAULT_CHARSET); + byte[] msgBody = this.bitUtil.concatAll(Arrays.asList( + new byte[]{flag}, // 标志 + contentBytes // 文本信息 + )); + + // 消息头 + int msgBodyProps = this.jt808Util.generateMsgBodyProps(msgBody.length, 0b000, false, 0); + byte[] msgHeader = this.jt808Util.generateMsgHeader(phone, + Jt808Constants.CMD_TEXT_INFO_DOWN, msgBody, msgBodyProps, flowId); + byte[] headerAndBody = this.bitUtil.concatAll(msgHeader, msgBody); + + // 校验码 + int checkSum = this.bitUtil.getCheckSum4JT808(headerAndBody, 0, headerAndBody.length); + return this.doEncode(headerAndBody, checkSum); + } catch (Exception e) { + log.error("[encodeTextInfoDown][编码文本下发失败]", e); + throw new RuntimeException("编码文本下发失败", e); + } + } + + /** + * 编码完整消息(添加标识符和转义) + */ + private byte[] doEncode(byte[] headerAndBody, int checkSum) throws Exception { + byte[] noEscapedBytes = this.bitUtil.concatAll(Arrays.asList( + new byte[]{(byte) Jt808Constants.PKG_DELIMITER}, // 0x7e + headerAndBody, // 消息头 + 消息体 + bitUtil.integerTo1Bytes(checkSum), // 校验码 + new byte[]{(byte) Jt808Constants.PKG_DELIMITER} // 0x7e + )); + + // 转义(不包括首尾标识符) + return jt808Util.doEscape4Send(noEscapedBytes, 1, noEscapedBytes.length - 1); + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808BatteryInfo.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808BatteryInfo.java new file mode 100644 index 0000000..ae743cf --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808BatteryInfo.java @@ -0,0 +1,68 @@ +package com.viewsh.module.iot.gateway.codec.jt808.entity; + +import lombok.Data; + +import java.util.HashMap; +import java.util.Map; + +/** + * JT808 电池和版本信息 + *

+ * 对应扩展字段 0xFE + * + * @author lzh + */ +@Data +public class Jt808BatteryInfo { + + /** + * 电池电量百分比 (0-100) + * 扩展字段 ID: 0x02 + */ + private Integer batteryLevel; + + /** + * 版本信息 + * 扩展字段 ID: 0x07 + */ + private String version; + + /** + * ICCID (SIM卡号) + * 扩展字段 ID: 0x20 + */ + private String iccid; + + /** + * 其他扩展数据 + * Key: 子扩展字段ID + * Value: 数据值 + */ + private Map extraData; + + public Jt808BatteryInfo() { + this.extraData = new HashMap<>(); + } + + /** + * 添加额外数据 + */ + public void addExtra(Integer id, String data) { + if (this.extraData == null) { + this.extraData = new HashMap<>(); + } + this.extraData.put(id, data); + } + + @Override + public String toString() { + return "Jt808BatteryInfo{" + + "batteryLevel=" + batteryLevel + + "%, version='" + version + '\'' + + ", iccid='" + iccid + '\'' + + ", extraData=" + extraData + + '}'; + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808BluetoothInfo.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808BluetoothInfo.java new file mode 100644 index 0000000..1005ad8 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808BluetoothInfo.java @@ -0,0 +1,54 @@ +package com.viewsh.module.iot.gateway.codec.jt808.entity; + +import lombok.Data; + +/** + * JT808 蓝牙信息 + * + * @author lzh + */ +@Data +public class Jt808BluetoothInfo { + + /** + * MAC 地址 + */ + private String mac; + + /** + * RSSI 信号强度(负数,单位 dBm) + */ + private Integer rssi; + + /** + * 蓝牙类型 + * 0xF3 - 蓝牙信标 + * 0xF4 - 附近蓝牙设备 + */ + private Integer type; + + /** + * 自定义数据(可选) + */ + private String customData; + + public Jt808BluetoothInfo() { + } + + public Jt808BluetoothInfo(String mac, Integer rssi) { + this.mac = mac; + this.rssi = rssi; + } + + @Override + public String toString() { + return "Jt808BluetoothInfo{" + + "mac='" + mac + '\'' + + ", rssi=" + rssi + + ", type=0x" + (type != null ? String.format("%02X", type) : "null") + + ", customData='" + customData + '\'' + + '}'; + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808ButtonEvent.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808ButtonEvent.java new file mode 100644 index 0000000..e248887 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808ButtonEvent.java @@ -0,0 +1,86 @@ +package com.viewsh.module.iot.gateway.codec.jt808.entity; + +import lombok.Data; + +/** + * JT808 按键事件 + *

+ * 对应消息类型 0x0006 + * + * @author lzh + */ +@Data +public class Jt808ButtonEvent { + + /** + * 按键ID + * 0x01 - 短按1号键 + * 0x02 - 短按2号键 + * 0x0B - 长按1号键 + * 0x0C - 长按2号键 + * 0x0D - 长按3号键 + * 0x0E - 长按4号键 + */ + private Integer keyId; + + /** + * 按键状态/次数 + * 0x01 - 按键一次 + * 0x02 - 按键两次 + * ... + */ + private Integer keyState; + + /** + * 按键类型(解析后的) + */ + private String keyType; + + /** + * 按键编号(解析后的) + */ + private Integer keyNumber; + + /** + * 是否长按 + */ + private Boolean isLongPress; + + /** + * 解析按键信息 + */ + public void parseKeyInfo() { + if (keyId == null) { + return; + } + + // 解析按键类型和编号 + if (keyId >= 0x01 && keyId <= 0x0A) { + // 短按 + this.isLongPress = false; + this.keyNumber = keyId; + this.keyType = "short_press"; + } else if (keyId >= 0x0B && keyId <= 0x0F) { + // 长按 + this.isLongPress = true; + this.keyNumber = keyId - 0x0A; + this.keyType = "long_press"; + } else { + // 未知类型 + this.keyType = "unknown"; + } + } + + @Override + public String toString() { + return "Jt808ButtonEvent{" + + "keyId=0x" + (keyId != null ? String.format("%02X", keyId) : "null") + + ", keyState=" + keyState + + ", keyType='" + keyType + '\'' + + ", keyNumber=" + keyNumber + + ", isLongPress=" + isLongPress + + '}'; + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808DataPack.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808DataPack.java new file mode 100644 index 0000000..ac3c427 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808DataPack.java @@ -0,0 +1,116 @@ +package com.viewsh.module.iot.gateway.codec.jt808.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; + +import java.util.Arrays; + +/** + * JT808 数据包 + * + * @author lzh + */ +@Data +public class Jt808DataPack { + + /** + * 16byte 消息头 + */ + protected PackHead packHead; + + /** + * 消息体字节数组 + */ + @JsonIgnore + protected byte[] bodyBytes; + + /** + * 校验码 1byte + */ + protected int checkSum; + + @Override + public String toString() { + return "Jt808DataPack{" + + "packHead=" + packHead + + ", bodyBytes=" + (bodyBytes != null ? Arrays.toString(bodyBytes) : "null") + + ", checkSum=" + checkSum + + '}'; + } + + /** + * JT808 消息头 + */ + @Data + public static class PackHead { + + /** + * 消息ID + */ + protected int id; + + /** + * 消息体属性字段 + */ + protected int bodyPropsField; + + /** + * 消息体长度 + */ + protected int bodyLength; + + /** + * 数据加密方式 + */ + protected int encryptionType; + + /** + * 是否分包 + */ + protected boolean hasSubPackage; + + /** + * 保留位[14-15] + */ + protected String reservedBit; + + /** + * 终端手机号 + */ + protected String terminalPhone; + + /** + * 流水号 + */ + protected int flowId; + + /** + * 消息包封装项字段 + */ + protected int infoField; + + /** + * 消息包总数(word(16)) + */ + protected long subPackage; + + /** + * 包序号(word(16)) + */ + protected long subPackageSequeue; + + @Override + public String toString() { + return "PackHead{" + + "id=0x" + Integer.toHexString(id) + + ", bodyLength=" + bodyLength + + ", encryptionType=" + encryptionType + + ", hasSubPackage=" + hasSubPackage + + ", terminalPhone='" + terminalPhone + '\'' + + ", flowId=" + flowId + + '}'; + } + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808LocationInfo.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808LocationInfo.java new file mode 100644 index 0000000..7db09f9 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808LocationInfo.java @@ -0,0 +1,107 @@ +package com.viewsh.module.iot.gateway.codec.jt808.entity; + +import lombok.Data; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * JT808 位置信息 + * + * @author lzh + */ +@Data +public class Jt808LocationInfo { + + /** + * 报警标志 + */ + private Integer warningFlagField; + + /** + * 状态字段 + */ + private Integer statusField; + + /** + * 纬度(度) + */ + private Float latitude; + + /** + * 经度(度) + */ + private Float longitude; + + /** + * 高程(米) + */ + private Integer elevation; + + /** + * 速度(km/h) + */ + private Float speed; + + /** + * 方向(0-359) + */ + private Integer direction; + + /** + * 时间(BCD格式字符串) + */ + private String time; + + /** + * 扩展字段映射 + * Key: 扩展字段ID + * Value: 扩展字段值 + */ + private Map extensions = new HashMap<>(); + + // ========== 常用扩展字段(便于访问) ========== + + /** + * 蓝牙设备列表 + * 扩展字段 0xF3 (蓝牙信标) 或 0xF4 (附近蓝牙) + */ + private List bluetoothInfos; + + /** + * 电池和版本信息 + * 扩展字段 0xFE + */ + private Jt808BatteryInfo batteryInfo; + + /** + * 里程(km) + * 扩展字段 0x01 + */ + private Float mileage; + + /** + * 信号强度 + * 扩展字段 0x30 + */ + private Integer signalStrength; + + // ========== 扩展字段操作方法 ========== + + /** + * 添加扩展字段 + */ + public void addExtension(int id, Object value) { + this.extensions.put(id, value); + } + + /** + * 获取扩展字段 + */ + public Object getExtension(int id) { + return this.extensions.get(id); + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808RegisterInfo.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808RegisterInfo.java new file mode 100644 index 0000000..977d5f7 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/entity/Jt808RegisterInfo.java @@ -0,0 +1,60 @@ +package com.viewsh.module.iot.gateway.codec.jt808.entity; + +import lombok.Data; + +/** + * JT808 终端注册信息 + * + * @author lzh + */ +@Data +public class Jt808RegisterInfo { + + /** + * 省域ID (WORD) + * 设备安装车辆所在的省域,省域ID采用GB/T2260中规定的行政区划代码6位中前两位 + * 0保留,由平台取默认值 + */ + private Integer provinceId; + + /** + * 市县域ID (WORD) + * 设备安装车辆所在的市域或县域,市县域ID采用GB/T2260中规定的行政区划代码6位中后四位 + * 0保留,由平台取默认值 + */ + private Integer cityId; + + /** + * 制造商ID (BYTE[5]) + * 5个字节,终端制造商编码 + */ + private String manufacturerId; + + /** + * 终端型号 (BYTE[20]) + * 20个字节,此终端型号由制造商自行定义,位数不足时,后补"0X00" + */ + private String terminalType; + + /** + * 终端ID (BYTE[7]) + * 7个字节,由大写字母和数字组成,此终端ID由制造商自行定义,位数不足时,后补"0X00" + */ + private String terminalId; + + /** + * 车牌颜色 (BYTE) + * 车牌颜色,按照 JT/T415-2006 的 5.4.12 + * 0-未上车牌, 1-蓝色, 2-黄色, 3-黑色, 4-白色, 9-其他 + */ + private Integer licensePlateColor; + + /** + * 车牌 (STRING) + * 车牌颜色为0时,表示车辆VIN;否则,表示公安交通管理部门颁发的机动车号牌 + */ + private String licensePlate; + +} + + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808BatteryInfoParser.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808BatteryInfoParser.java new file mode 100644 index 0000000..949873c --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808BatteryInfoParser.java @@ -0,0 +1,134 @@ +package com.viewsh.module.iot.gateway.codec.jt808.parser; + +import com.viewsh.module.iot.gateway.codec.jt808.Jt808Constants; +import com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808BatteryInfo; +import com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808LocationInfo; +import com.viewsh.module.iot.gateway.codec.jt808.util.Jt808BcdUtil; +import lombok.extern.slf4j.Slf4j; + +/** + * JT808 电池和版本信息解析器 + *

+ * 扩展字段 ID: 0xFE + *

+ * 格式:E6 + N * (子ID 1字节 + 长度 2字节 + 数据) + *

+ * 子扩展字段: + * - 0x02: 电池电量 (1字节) + * - 0x07: 版本信息 (ASCII字符串) + * - 0x20: ICCID (10字节 BCD) + * + * @author lzh + */ +@Slf4j +public class Jt808BatteryInfoParser implements Jt808ExtensionParser { + + private final Jt808BcdUtil bcdUtil = new Jt808BcdUtil(); + + @Override + public int getExtensionId() { + return 0xFE; + } + + @Override + public void parse(Jt808LocationInfo locationInfo, byte[] data, int offset, int length) { + try { + Jt808BatteryInfo batteryInfo = new Jt808BatteryInfo(); + + // 检查是否以 0xE6 开头 + int currentOffset = offset; + if (length > 0 && (data[currentOffset] & 0xFF) == 0xE6) { + currentOffset++; // 跳过 E6 标识 + + // 循环解析子扩展字段 + while (currentOffset < offset + length) { + // 检查剩余长度 (至少需要 ID 1字节 + 长度 2字节) + if (currentOffset + 3 > offset + length) { + break; + } + + // 读取子扩展字段 ID + int subId = data[currentOffset] & 0xFF; + currentOffset++; + + // 读取子扩展字段长度 (2字节大端序) + int subLen = ((data[currentOffset] & 0xFF) << 8) | (data[currentOffset + 1] & 0xFF); + currentOffset += 2; + + // 检查数据长度 + if (currentOffset + subLen > offset + length) { + log.warn("[parse][0xFE 子字段长度越界: ID=0x{}, Len={}, Remaining={}]", + String.format("%02X", subId), subLen, offset + length - currentOffset); + break; + } + + // 解析具体字段 + parseSubField(batteryInfo, subId, data, currentOffset, subLen); + + currentOffset += subLen; + } + } + + locationInfo.setBatteryInfo(batteryInfo); + log.debug("[parse][电池信息解析完成: {}]", batteryInfo); + } catch (Exception e) { + log.error("[parse][电池信息解析失败]", e); + } + } + + /** + * 解析子扩展字段 + */ + private void parseSubField(Jt808BatteryInfo batteryInfo, int subId, byte[] data, int offset, int length) { + try { + switch (subId) { + case 0x02 -> { + // 电池电量 (1字节,百分比) + if (length >= 1) { + int batteryLevel = data[offset] & 0xFF; + batteryInfo.setBatteryLevel(batteryLevel); + log.debug("[parseSubField][电池电量: {}%]", batteryLevel); + } + } + case 0x07 -> { + // 版本信息 (ASCII 字符串) + String version = new String(data, offset, length, Jt808Constants.DEFAULT_CHARSET); + batteryInfo.setVersion(version.trim()); + log.debug("[parseSubField][版本信息: {}]", version); + } + case 0x20 -> { + // ICCID (10字节 BCD) + if (length >= 10) { + byte[] iccidBytes = new byte[10]; + System.arraycopy(data, offset, iccidBytes, 0, 10); + String iccid = bcdUtil.bcd2String(iccidBytes); + batteryInfo.setIccid(iccid); + log.debug("[parseSubField][ICCID: {}]", iccid); + } + } + default -> { + // 其他未知子字段,保存为十六进制字符串 + String hexData = bytesToHex(data, offset, length); + batteryInfo.addExtra(subId, hexData); + log.debug("[parseSubField][未知字段 0x{}: {}]", + String.format("%02X", subId), hexData); + } + } + } catch (Exception e) { + log.error("[parseSubField][解析子字段失败: ID=0x{}]", String.format("%02X", subId), e); + } + } + + /** + * 字节数组转十六进制字符串 + */ + private String bytesToHex(byte[] data, int offset, int length) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < length; i++) { + sb.append(String.format("%02X", data[offset + i])); + } + return sb.toString(); + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808BluetoothBeaconParser.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808BluetoothBeaconParser.java new file mode 100644 index 0000000..92ebd50 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808BluetoothBeaconParser.java @@ -0,0 +1,102 @@ +package com.viewsh.module.iot.gateway.codec.jt808.parser; + +import com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808BluetoothInfo; +import com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808LocationInfo; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; + +/** + * JT808 蓝牙信标解析器 + *

+ * 扩展字段 ID: 0xF3 + *

+ * 格式:E6 0C Count + N * (MAC 6字节 + RSSI 1字节) + * + * @author lzh + */ +@Slf4j +public class Jt808BluetoothBeaconParser implements Jt808ExtensionParser { + + @Override + public int getExtensionId() { + return 0xF3; + } + + @Override + public void parse(Jt808LocationInfo locationInfo, byte[] data, int offset, int length) { + try { + // 格式: E6 (1 byte) + 0C (1 byte) + Count (1 byte) + 数据 + if (length < 3) { + log.warn("[parse][蓝牙信标数据长度不足: {}]", length); + return; + } + + int currentOffset = offset + 2; // 跳过 E6 0C + int count = data[currentOffset] & 0xFF; + currentOffset++; + + List list = locationInfo.getBluetoothInfos(); + if (list == null) { + list = new ArrayList<>(); + } + + for (int i = 0; i < count; i++) { + // 检查剩余长度 + if (currentOffset + 7 > offset + length) { + log.warn("[parse][蓝牙信标数据不完整: 期望 {}, 剩余 {}]", + count, offset + length - currentOffset); + break; + } + + // 读取 MAC 地址 (6 字节) + byte[] macBytes = new byte[6]; + System.arraycopy(data, currentOffset, macBytes, 0, 6); + String mac = formatMac(bytesToHex(macBytes)); + + // 读取 RSSI (1 字节, 有符号) + int rssi = data[currentOffset + 6]; + + Jt808BluetoothInfo info = new Jt808BluetoothInfo(mac, rssi); + info.setType(0xF3); + list.add(info); + + currentOffset += 7; + } + + locationInfo.setBluetoothInfos(list); + log.debug("[parse][蓝牙信标解析完成: 数量={}]", list.size()); + } catch (Exception e) { + log.error("[parse][蓝牙信标解析失败]", e); + } + } + + /** + * 字节数组转十六进制字符串 + */ + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02X", b)); + } + return sb.toString(); + } + + /** + * 格式化 MAC 地址 + * 例如: 112233445566 → 11:22:33:44:55:66 + */ + private String formatMac(String hex) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < hex.length(); i += 2) { + if (i > 0) { + sb.append(":"); + } + sb.append(hex.substring(i, Math.min(i + 2, hex.length()))); + } + return sb.toString().toUpperCase(); + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808ExtensionParser.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808ExtensionParser.java new file mode 100644 index 0000000..fb00149 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808ExtensionParser.java @@ -0,0 +1,30 @@ +package com.viewsh.module.iot.gateway.codec.jt808.parser; + +import com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808LocationInfo; + +/** + * JT808 扩展字段解析器接口 + * + * @author lzh + */ +public interface Jt808ExtensionParser { + + /** + * 获取扩展字段ID + * + * @return 扩展字段ID (如 0xF3, 0xFE 等) + */ + int getExtensionId(); + + /** + * 解析扩展字段 + * + * @param locationInfo 位置信息对象(将解析结果填充到此对象) + * @param data 完整的消息字节数组 + * @param offset 扩展字段内容的起始偏移量 + * @param length 扩展字段内容的长度 + */ + void parse(Jt808LocationInfo locationInfo, byte[] data, int offset, int length); + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808ExtensionParserFactory.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808ExtensionParserFactory.java new file mode 100644 index 0000000..20c19d2 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808ExtensionParserFactory.java @@ -0,0 +1,49 @@ +package com.viewsh.module.iot.gateway.codec.jt808.parser; + +import java.util.HashMap; +import java.util.Map; + +/** + * JT808 扩展字段解析器工厂 + * + * @author lzh + */ +public class Jt808ExtensionParserFactory { + + private static final Map PARSERS = new HashMap<>(); + + static { + // 注册所有解析器 + register(new Jt808BluetoothBeaconParser()); // 0xF3 - 蓝牙信标 + register(new Jt808NearbyBleParser()); // 0xF4 - 附近蓝牙 + register(new Jt808BatteryInfoParser()); // 0xFE - 电池版本信息 + register(new Jt808MileageParser()); // 0x01 - 里程 + register(new Jt808SignalStrengthParser()); // 0x30 - 信号强度 + } + + /** + * 注册解析器 + */ + public static void register(Jt808ExtensionParser parser) { + PARSERS.put(parser.getExtensionId(), parser); + } + + /** + * 获取解析器 + * + * @param extensionId 扩展字段ID + * @return 对应的解析器,如果不存在则返回 null + */ + public static Jt808ExtensionParser getParser(int extensionId) { + return PARSERS.get(extensionId); + } + + /** + * 是否支持该扩展字段 + */ + public static boolean isSupported(int extensionId) { + return PARSERS.containsKey(extensionId); + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808MileageParser.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808MileageParser.java new file mode 100644 index 0000000..31b9fa0 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808MileageParser.java @@ -0,0 +1,48 @@ +package com.viewsh.module.iot.gateway.codec.jt808.parser; + +import com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808LocationInfo; +import com.viewsh.module.iot.gateway.codec.jt808.util.Jt808BitUtil; +import lombok.extern.slf4j.Slf4j; + +/** + * JT808 里程解析器 + *

+ * 扩展字段 ID: 0x01 + *

+ * 格式:4字节 DWORD,单位:0.1km + * + * @author lzh + */ +@Slf4j +public class Jt808MileageParser implements Jt808ExtensionParser { + + private final Jt808BitUtil bitUtil = new Jt808BitUtil(); + + @Override + public int getExtensionId() { + return 0x01; + } + + @Override + public void parse(Jt808LocationInfo locationInfo, byte[] data, int offset, int length) { + try { + if (length >= 4) { + byte[] mileageBytes = new byte[4]; + System.arraycopy(data, offset, mileageBytes, 0, 4); + + // 读取里程(DWORD, 单位:0.1km) + int mileageValue = bitUtil.byteToInteger(mileageBytes); + Float mileage = mileageValue / 10.0f; + + locationInfo.setMileage(mileage); + log.debug("[parse][里程解析完成: {} km]", mileage); + } else { + log.warn("[parse][里程数据长度不足: {}]", length); + } + } catch (Exception e) { + log.error("[parse][里程解析失败]", e); + } + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808NearbyBleParser.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808NearbyBleParser.java new file mode 100644 index 0000000..7daa2bd --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808NearbyBleParser.java @@ -0,0 +1,86 @@ +package com.viewsh.module.iot.gateway.codec.jt808.parser; + +import com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808BluetoothInfo; +import com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808LocationInfo; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; + +/** + * JT808 附近蓝牙设备解析器 + *

+ * 扩展字段 ID: 0xF4 + *

+ * 格式:Count (1字节) + N * (MAC 6字节 + RSSI 1字节) + * + * @author lzh + */ +@Slf4j +public class Jt808NearbyBleParser implements Jt808ExtensionParser { + + @Override + public int getExtensionId() { + return 0xF4; + } + + @Override + public void parse(Jt808LocationInfo locationInfo, byte[] data, int offset, int length) { + try { + // 格式: Count (1 byte) + 数据 + if (length < 1) { + log.warn("[parse][附近蓝牙数据长度不足: {}]", length); + return; + } + + int count = data[offset] & 0xFF; + int currentOffset = offset + 1; + + List list = locationInfo.getBluetoothInfos(); + if (list == null) { + list = new ArrayList<>(); + } + + for (int i = 0; i < count; i++) { + // 检查剩余长度 + if (currentOffset + 7 > offset + length) { + log.warn("[parse][附近蓝牙数据不完整: 期望 {}, 剩余 {}]", + count, offset + length - currentOffset); + break; + } + + // 读取 MAC 地址 (6 字节) + byte[] macBytes = new byte[6]; + System.arraycopy(data, currentOffset, macBytes, 0, 6); + String mac = bytesToHex(macBytes); + + // 读取 RSSI (1 字节) + int rssi = data[currentOffset + 6]; + + Jt808BluetoothInfo info = new Jt808BluetoothInfo(mac, rssi); + info.setType(0xF4); + list.add(info); + + currentOffset += 7; + } + + locationInfo.setBluetoothInfos(list); + log.debug("[parse][附近蓝牙解析完成: 数量={}]", list.size()); + } catch (Exception e) { + log.error("[parse][附近蓝牙解析失败]", e); + } + } + + /** + * 字节数组转十六进制字符串 + */ + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02X", b)); + } + return sb.toString(); + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808SignalStrengthParser.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808SignalStrengthParser.java new file mode 100644 index 0000000..edcd7d4 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/parser/Jt808SignalStrengthParser.java @@ -0,0 +1,39 @@ +package com.viewsh.module.iot.gateway.codec.jt808.parser; + +import com.viewsh.module.iot.gateway.codec.jt808.entity.Jt808LocationInfo; +import lombok.extern.slf4j.Slf4j; + +/** + * JT808 信号强度解析器 + *

+ * 扩展字段 ID: 0x30 + *

+ * 格式:1字节 BYTE,无线通信网络信号强度 + * + * @author lzh + */ +@Slf4j +public class Jt808SignalStrengthParser implements Jt808ExtensionParser { + + @Override + public int getExtensionId() { + return 0x30; + } + + @Override + public void parse(Jt808LocationInfo locationInfo, byte[] data, int offset, int length) { + try { + if (length >= 1) { + int signalStrength = data[offset] & 0xFF; + locationInfo.setSignalStrength(signalStrength); + log.debug("[parse][信号强度解析完成: {}]", signalStrength); + } else { + log.warn("[parse][信号强度数据长度不足: {}]", length); + } + } catch (Exception e) { + log.error("[parse][信号强度解析失败]", e); + } + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/util/Jt808BcdUtil.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/util/Jt808BcdUtil.java new file mode 100644 index 0000000..9e99257 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/util/Jt808BcdUtil.java @@ -0,0 +1,64 @@ +package com.viewsh.module.iot.gateway.codec.jt808.util; + +/** + * JT808 BCD码转换工具类 + * + * @author lzh + */ +public class Jt808BcdUtil { + + /** + * BCD字节数组 ==> String + * + * @param bytes BCD字节数组 + * @return 十进制字符串 + */ + public String bcd2String(byte[] bytes) { + StringBuilder temp = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + // 高四位 + temp.append((b & 0xf0) >>> 4); + // 低四位 + temp.append(b & 0x0f); + } + return temp.toString().substring(0, 1).equalsIgnoreCase("0") + ? temp.toString().substring(1) + : temp.toString(); + } + + /** + * 字符串 ==> BCD字节数组 + * + * @param str 十进制字符串 + * @return BCD字节数组 + */ + public byte[] string2Bcd(String str) { + // 奇数,前补零 + if ((str.length() & 0x1) == 1) { + str = "0" + str; + } + + byte[] ret = new byte[str.length() / 2]; + byte[] bs = str.getBytes(); + for (int i = 0; i < ret.length; i++) { + byte high = ascII2Bcd(bs[2 * i]); + byte low = ascII2Bcd(bs[2 * i + 1]); + ret[i] = (byte) ((high << 4) | low); + } + return ret; + } + + private byte ascII2Bcd(byte asc) { + if ((asc >= '0') && (asc <= '9')) { + return (byte) (asc - '0'); + } else if ((asc >= 'A') && (asc <= 'F')) { + return (byte) (asc - 'A' + 10); + } else if ((asc >= 'a') && (asc <= 'f')) { + return (byte) (asc - 'a' + 10); + } else { + return (byte) (asc - 48); + } + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/util/Jt808BitUtil.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/util/Jt808BitUtil.java new file mode 100644 index 0000000..bd0c92f --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/util/Jt808BitUtil.java @@ -0,0 +1,182 @@ +package com.viewsh.module.iot.gateway.codec.jt808.util; + +import java.util.Arrays; +import java.util.List; + +/** + * JT808 位操作工具类 + * + * @author lzh + */ +public class Jt808BitUtil { + + /** + * 把一个整形改为1位的byte数组 + */ + public byte[] integerTo1Bytes(int value) { + return new byte[]{(byte) (value & 0xFF)}; + } + + /** + * 把一个整形改为2位的byte数组 + */ + public byte[] integerTo2Bytes(int value) { + byte[] result = new byte[2]; + result[0] = (byte) ((value >>> 8) & 0xFF); + result[1] = (byte) (value & 0xFF); + return result; + } + + /** + * 把一个整形改为3位的byte数组 + */ + public byte[] integerTo3Bytes(int value) { + byte[] result = new byte[3]; + result[0] = (byte) ((value >>> 16) & 0xFF); + result[1] = (byte) ((value >>> 8) & 0xFF); + result[2] = (byte) (value & 0xFF); + return result; + } + + /** + * 把一个整形改为4位的byte数组 + */ + public byte[] integerTo4Bytes(int value) { + byte[] result = new byte[4]; + result[0] = (byte) ((value >>> 24) & 0xFF); + result[1] = (byte) ((value >>> 16) & 0xFF); + result[2] = (byte) ((value >>> 8) & 0xFF); + result[3] = (byte) (value & 0xFF); + return result; + } + + /** + * 把byte[]转化为整形 + */ + public int byteToInteger(byte[] value) { + if (value.length == 1) { + return oneByteToInteger(value[0]); + } else if (value.length == 2) { + return twoBytesToInteger(value); + } else if (value.length == 3) { + return threeBytesToInteger(value); + } else { + return fourBytesToInteger(value); + } + } + + /** + * 把一个byte转化为整形 + */ + public int oneByteToInteger(byte value) { + return (int) value & 0xFF; + } + + /** + * 把一个2位的数组转化为整形 + */ + public int twoBytesToInteger(byte[] value) { + int temp0 = value[0] & 0xFF; + int temp1 = value[1] & 0xFF; + return ((temp0 << 8) + temp1); + } + + /** + * 把一个3位的数组转化为整形 + */ + public int threeBytesToInteger(byte[] value) { + int temp0 = value[0] & 0xFF; + int temp1 = value[1] & 0xFF; + int temp2 = value[2] & 0xFF; + return ((temp0 << 16) + (temp1 << 8) + temp2); + } + + /** + * 把一个4位的数组转化为整形 + */ + public int fourBytesToInteger(byte[] value) { + int temp0 = value[0] & 0xFF; + int temp1 = value[1] & 0xFF; + int temp2 = value[2] & 0xFF; + int temp3 = value[3] & 0xFF; + return ((temp0 << 24) + (temp1 << 16) + (temp2 << 8) + temp3); + } + + /** + * 把一个4位的数组转化为长整形 + */ + public long fourBytesToLong(byte[] value) { + int temp0 = value[0] & 0xFF; + int temp1 = value[1] & 0xFF; + int temp2 = value[2] & 0xFF; + int temp3 = value[3] & 0xFF; + return (((long) temp0 << 24) + (temp1 << 16) + (temp2 << 8) + temp3); + } + + /** + * 合并字节数组 + */ + public byte[] concatAll(byte[] first, byte[]... rest) { + int totalLength = first.length; + for (byte[] array : rest) { + if (array != null) { + totalLength += array.length; + } + } + byte[] result = Arrays.copyOf(first, totalLength); + int offset = first.length; + for (byte[] array : rest) { + if (array != null) { + System.arraycopy(array, 0, result, offset, array.length); + offset += array.length; + } + } + return result; + } + + /** + * 合并字节数组 + */ + public byte[] concatAll(List rest) { + int totalLength = 0; + for (byte[] array : rest) { + if (array != null) { + totalLength += array.length; + } + } + byte[] result = new byte[totalLength]; + int offset = 0; + for (byte[] array : rest) { + if (array != null) { + System.arraycopy(array, 0, result, offset, array.length); + offset += array.length; + } + } + return result; + } + + /** + * 字节数组转浮点数 + */ + public float byte2Float(byte[] bs) { + return Float.intBitsToFloat( + (((bs[3] & 0xFF) << 24) + ((bs[2] & 0xFF) << 16) + ((bs[1] & 0xFF) << 8) + (bs[0] & 0xFF))); + } + + /** + * 计算JT808校验码 + */ + public int getCheckSum4JT808(byte[] bs, int start, int end) { + if (start < 0 || end > bs.length) { + throw new ArrayIndexOutOfBoundsException("getCheckSum4JT808 error : index out of bounds(start=" + start + + ",end=" + end + ",bytes length=" + bs.length + ")"); + } + int cs = 0; + for (int i = start; i < end; i++) { + cs ^= bs[i]; + } + return cs; + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/util/Jt808ProtocolUtil.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/util/Jt808ProtocolUtil.java new file mode 100644 index 0000000..55a439d --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/jt808/util/Jt808ProtocolUtil.java @@ -0,0 +1,140 @@ +package com.viewsh.module.iot.gateway.codec.jt808.util; + +import lombok.extern.slf4j.Slf4j; + +import java.io.ByteArrayOutputStream; + +/** + * JT808协议转义工具类 + *

+ * 0x7d01 <==> 0x7d
+ * 0x7d02 <==> 0x7e
+ * 
+ * + * @author lzh + */ +@Slf4j +public class Jt808ProtocolUtil { + + private final Jt808BitUtil bitOperator; + private final Jt808BcdUtil bcd8421Operater; + + public Jt808ProtocolUtil() { + this.bitOperator = new Jt808BitUtil(); + this.bcd8421Operater = new Jt808BcdUtil(); + } + + /** + * 接收消息时转义(反转义) + *
+     * 0x7d01 <==> 0x7d
+     * 0x7d02 <==> 0x7e
+     * 
+ */ + public byte[] doEscape4Receive(byte[] bs, int start, int end) throws Exception { + if (start < 0 || end > bs.length) { + throw new ArrayIndexOutOfBoundsException("doEscape4Receive error : index out of bounds(start=" + start + + ",end=" + end + ",bytes length=" + bs.length + ")"); + } + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + // 写入起始部分 + for (int i = 0; i < start; i++) { + baos.write(bs[i]); + } + // 处理需要反转义的部分 + for (int i = start; i < end - 1; i++) { + if (bs[i] == 0x7d && bs[i + 1] == 0x01) { + baos.write(0x7d); + i++; + } else if (bs[i] == 0x7d && bs[i + 1] == 0x02) { + baos.write(0x7e); + i++; + } else { + baos.write(bs[i]); + } + } + // 写入结束部分 + for (int i = end - 1; i < bs.length; i++) { + baos.write(bs[i]); + } + return baos.toByteArray(); + } + } + + /** + * 发送消息时转义 + *
+     * 0x7d <==> 0x7d01
+     * 0x7e <==> 0x7d02
+     * 
+ */ + public byte[] doEscape4Send(byte[] bs, int start, int end) throws Exception { + if (start < 0 || end > bs.length) { + throw new ArrayIndexOutOfBoundsException("doEscape4Send error : index out of bounds(start=" + start + + ",end=" + end + ",bytes length=" + bs.length + ")"); + } + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + // 写入起始部分 + for (int i = 0; i < start; i++) { + baos.write(bs[i]); + } + // 处理需要转义的部分 + for (int i = start; i < end; i++) { + if (bs[i] == 0x7e) { + baos.write(0x7d); + baos.write(0x02); + } else if (bs[i] == 0x7d) { + baos.write(0x7d); + baos.write(0x01); + } else { + baos.write(bs[i]); + } + } + // 写入结束部分 + for (int i = end; i < bs.length; i++) { + baos.write(bs[i]); + } + return baos.toByteArray(); + } + } + + /** + * 生成消息体属性 + */ + public int generateMsgBodyProps(int msgLen, int encryptionType, boolean isSubPackage, int reversed_14_15) { + // [ 0-9 ] 消息体长度 + // [10-12] 加密类型 + // [ 13 ] 是否有子包 + // [14-15] 保留位 + if (msgLen >= 1024) { + log.warn("The max value of msgLen is 1023, but {} .", msgLen); + } + int subPkg = isSubPackage ? 1 : 0; + return (msgLen & 0x3FF) + | ((encryptionType << 10) & 0x1C00) + | ((subPkg << 13) & 0x2000) + | ((reversed_14_15 << 14) & 0xC000); + } + + /** + * 生成消息头 + */ + public byte[] generateMsgHeader(String phone, int msgType, byte[] body, int msgBodyProps, int flowId) + throws Exception { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + // 1. 消息ID word(16) + baos.write(bitOperator.integerTo2Bytes(msgType)); + // 2. 消息体属性 word(16) + baos.write(bitOperator.integerTo2Bytes(msgBodyProps)); + // 3. 终端手机号 bcd[6] + baos.write(bcd8421Operater.string2Bcd(phone)); + // 4. 消息流水号 word(16) + baos.write(bitOperator.integerTo2Bytes(flowId)); + return baos.toByteArray(); + } + } + +} + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/people/IotPeopleCounterCodec.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/people/IotPeopleCounterCodec.java new file mode 100644 index 0000000..82ff117 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/people/IotPeopleCounterCodec.java @@ -0,0 +1,131 @@ +package com.viewsh.module.iot.gateway.codec.people; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.viewsh.framework.common.util.json.JsonUtils; +import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum; +import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.gateway.codec.IotDeviceMessageCodec; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +/** + * 客流计数器设备 编解码器 + *

+ * 适配设备上传的特定 JSON 格式 + */ +@Slf4j +@Component +public class IotPeopleCounterCodec implements IotDeviceMessageCodec { + + /** + * 编解码器类型,需要在设备管理中配置为该值 + */ + public static final String TYPE = "PEOPLE_COUNTER"; + + @Override + public String type() { + return TYPE; + } + + @Override + public IotDeviceMessage decode(byte[] bytes) { + // 1. 预处理:转换字符串并修复可能的中文引号问题 + String jsonStr = new String(bytes, StandardCharsets.UTF_8); + jsonStr = jsonStr.replace("“", "\"").replace("”", "\""); // 修复中文双引号 + + // 2. 解析 JSON + PeopleCounterPayload payload; + try { + payload = JsonUtils.parseObject(jsonStr, PeopleCounterPayload.class); + } catch (Exception e) { + log.error("[decode][解析设备数据失败: {}]", jsonStr, e); + throw new IllegalArgumentException("JSON 格式错误"); + } + Assert.notNull(payload, "消息内容不能为空"); + + // 3. 构建属性参数 Map (params) + Map params = MapUtil.newHashMap(); + // 3.1 基础属性 + params.put("rssi", payload.getRssi()); + params.put("model", payload.getModel()); + params.put("version", payload.getVersion()); + params.put("pair_status", StrUtil.trim(payload.getPair())); // 去除可能的空格 + + // 3.2 业务数据 (取 data 列表中时间最新的一条) + LocalDateTime reportTime = LocalDateTime.now(); + if (CollUtil.isNotEmpty(payload.getData())) { + // 尝试按时间排序取最新,或者直接取最后一条(视设备协议而定,这里假设时间字符串可排序) + PeopleCounterData latestData = payload.getData().stream() + .max(Comparator.comparing(PeopleCounterData::getTime)) + .orElse(payload.getData().get(payload.getData().size() - 1)); + + params.put("people_in", latestData.getIn()); + params.put("people_out", latestData.getOut()); + params.put("battery_rx", latestData.getRxBat()); + params.put("battery_tx", latestData.getTxBat()); + + // 解析上报时间(改为使用本地当前时间) +// if (StrUtil.isNotBlank(latestData.getTime())) { +// try { +// // 格式:20250501093000 -> yyyyMMddHHmmss +// reportTime = LocalDateTimeUtil.parse(latestData.getTime(), DatePattern.PURE_DATETIME_PATTERN); +// } catch (Exception e) { +// log.warn("[decode][时间格式解析失败: {}]", latestData.getTime()); +// } +// } + } + + // 4. 构建 IotDeviceMessage + return IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), params) + .setReportTime(LocalDateTime.now()); + } + + @Override + public byte[] encode(IotDeviceMessage message) { + // 如果需要下发指令给设备,可以在这里实现编码逻辑 + // 目前需求主要是上报解码,暂不支持下行或透传原样 + return new byte[0]; + } + + /** + * 设备上报的数据结构 + */ + @Data + public static class PeopleCounterPayload { + private String uuid; + private String model; + private String version; + private String pair; + private Integer num; + + // 处理 JSON 中的大写或特殊命名,这里假设 JsonUtils 使用 Jackson + @JsonProperty("RSSI") + private Integer rssi; + + private List data; + } + + @Data + public static class PeopleCounterData { + private String time; + private Integer in; + private Integer out; + private Integer rxBat; + private Integer txBat; + } +} + + diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/people/dto/PeopleCounterUploadResp.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/people/dto/PeopleCounterUploadResp.java new file mode 100644 index 0000000..bb09660 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/people/dto/PeopleCounterUploadResp.java @@ -0,0 +1,44 @@ +package com.viewsh.module.iot.gateway.codec.people.dto; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * 客流计数器设备上报响应 + * + * @author 芋道源码 + */ +@Data +public class PeopleCounterUploadResp { + + private Integer statusCode; // 0 正常 1 未绑定平台 2 解析 json 出错 + private String time; // yyyyMMddHHmmss + private String open; // 营业开始时间 + private String close; // 营业结束时间 + private Integer saveCycle; // 存储周期 + private Integer upCycle; // 上报周期 + private String license; // 0 正常 1 证书到期 + private String direction; // twoWay / onlyIn / onlyOut / exchange + private String upgradeUrl; // 升级地址(无升级可为空) + + /** + * 构建默认的客流计数器响应数据 + * + * @return 响应对象 + */ + public static PeopleCounterUploadResp createDefault() { + PeopleCounterUploadResp resp = new PeopleCounterUploadResp(); + resp.setStatusCode(0); + resp.setTime(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))); + resp.setOpen("0800"); + resp.setClose("2300"); + resp.setSaveCycle(1); + resp.setUpCycle(1); + resp.setLicense("0"); + resp.setDirection("twoWay"); + resp.setUpgradeUrl("http://op.foorir.com/upgrade/2023120.bin"); + return resp; + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/tcp/IotTcpJsonDeviceMessageCodec.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/tcp/IotTcpJsonDeviceMessageCodec.java index 8a2aef2..a1c5203 100644 --- a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/tcp/IotTcpJsonDeviceMessageCodec.java +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/tcp/IotTcpJsonDeviceMessageCodec.java @@ -95,16 +95,51 @@ public class IotTcpJsonDeviceMessageCodec implements IotDeviceMessageCodec { @SuppressWarnings("DataFlowIssue") public IotDeviceMessage decode(byte[] bytes) { String jsonStr = StrUtil.utf8Str(bytes).trim(); - TcpJsonMessage tcpJsonMessage = JsonUtils.parseObject(jsonStr, TcpJsonMessage.class); - Assert.notNull(tcpJsonMessage, "消息不能为空"); - Assert.notBlank(tcpJsonMessage.getMethod(), "消息方法不能为空"); - return IotDeviceMessage.of( - tcpJsonMessage.getId(), - tcpJsonMessage.getMethod(), - tcpJsonMessage.getParams(), - tcpJsonMessage.getData(), - tcpJsonMessage.getCode(), - tcpJsonMessage.getMsg()); + + // 快速验证是否为 JSON 格式(必须以 { 或 [ 开头) + if (StrUtil.isBlank(jsonStr)) { + throw new IllegalArgumentException("JSON 数据为空"); + } + + String trimmedJson = jsonStr.trim(); + if (!trimmedJson.startsWith("{") && !trimmedJson.startsWith("[")) { + throw new IllegalArgumentException( + String.format("不是有效的 JSON 格式,数据开头: %s", + jsonStr.length() > 20 ? jsonStr.substring(0, 20) : jsonStr)); + } + + try { + TcpJsonMessage tcpJsonMessage = JsonUtils.parseObject(jsonStr, TcpJsonMessage.class); + + // 检查解析结果 + if (tcpJsonMessage == null) { + throw new IllegalArgumentException( + String.format("JSON 解析返回 null,可能是空对象或格式错误,数据: %s", + jsonStr.length() > 100 ? jsonStr.substring(0, 100) + "..." : jsonStr)); + } + + // 检查必要字段 + if (StrUtil.isBlank(tcpJsonMessage.getMethod())) { + throw new IllegalArgumentException( + String.format("JSON 消息缺少 method 字段,数据: %s", + jsonStr.length() > 100 ? jsonStr.substring(0, 100) + "..." : jsonStr)); + } + + return IotDeviceMessage.of( + tcpJsonMessage.getId(), + tcpJsonMessage.getMethod(), + tcpJsonMessage.getParams(), + tcpJsonMessage.getData(), + tcpJsonMessage.getCode(), + tcpJsonMessage.getMsg()); + } catch (IllegalArgumentException e) { + // 重新抛出 IllegalArgumentException(已经是我们自己的错误信息) + throw e; + } catch (Exception e) { + throw new IllegalArgumentException( + String.format("JSON 解析失败,数据开头: %s,错误: %s", + jsonStr.length() > 50 ? jsonStr.substring(0, 50) : jsonStr, e.getMessage()), e); + } } } diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/config/IotGatewayConfiguration.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/config/IotGatewayConfiguration.java index c27b455..df8d34a 100644 --- a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/config/IotGatewayConfiguration.java +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/config/IotGatewayConfiguration.java @@ -12,10 +12,13 @@ import com.viewsh.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionMana import com.viewsh.module.iot.gateway.protocol.mqtt.router.IotMqttDownstreamHandler; import com.viewsh.module.iot.gateway.protocol.tcp.IotTcpDownstreamSubscriber; import com.viewsh.module.iot.gateway.protocol.tcp.IotTcpUpstreamProtocol; +import com.viewsh.module.iot.gateway.protocol.tcp.handler.ProtocolHandler; import com.viewsh.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; import com.viewsh.module.iot.gateway.service.device.IotDeviceService; import com.viewsh.module.iot.gateway.service.device.message.IotDeviceMessageService; import io.vertx.core.Vertx; + +import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -97,9 +100,10 @@ public class IotGatewayConfiguration { IotDeviceService deviceService, IotDeviceMessageService messageService, IotTcpConnectionManager connectionManager, + List protocolHandlers, Vertx tcpVertx) { return new IotTcpUpstreamProtocol(gatewayProperties.getProtocol().getTcp(), - deviceService, messageService, connectionManager, tcpVertx); + deviceService, messageService, connectionManager, protocolHandlers, tcpVertx); } @Bean diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpAbstractHandler.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpAbstractHandler.java index 05b1a5b..7adbbeb 100644 --- a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpAbstractHandler.java +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpAbstractHandler.java @@ -8,7 +8,10 @@ import com.viewsh.framework.common.exception.ServiceException; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.framework.common.util.json.JsonUtils; import com.viewsh.module.iot.core.util.IotDeviceAuthUtils; +import com.viewsh.module.iot.core.biz.dto.IotDeviceRespDTO; +import com.viewsh.module.iot.core.enums.IotAuthTypeEnum; import com.viewsh.module.iot.gateway.service.auth.IotDeviceTokenService; +import com.viewsh.module.iot.gateway.service.device.IotDeviceService; import io.vertx.core.Handler; import io.vertx.core.http.HttpHeaders; import io.vertx.ext.web.RoutingContext; @@ -20,6 +23,7 @@ import static com.viewsh.framework.common.exception.enums.GlobalErrorCodeConstan import static com.viewsh.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; +import static com.viewsh.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_NOT_EXISTS; /** * IoT 网关 HTTP 协议的处理器抽象基类:提供通用的前置处理(认证)、全局的异常捕获等 @@ -31,6 +35,7 @@ import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.in public abstract class IotHttpAbstractHandler implements Handler { private final IotDeviceTokenService deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class); + private final IotDeviceService deviceService = SpringUtil.getBean(IotDeviceService.class); @Override public final void handle(RoutingContext context) { @@ -40,29 +45,31 @@ public abstract class IotHttpAbstractHandler implements Handler // 2. 执行逻辑 CommonResult result = handle0(context); - writeResponse(context, result); + // 检查响应是否已经写入(某些子类可能会直接写入响应) + if (result != null && !context.response().ended()) { + writeResponse(context, result); + } } catch (ServiceException e) { - writeResponse(context, CommonResult.error(e.getCode(), e.getMessage())); + if (!context.response().ended()) { + writeResponse(context, CommonResult.error(e.getCode(), e.getMessage())); + } } catch (Exception e) { log.error("[handle][path({}) 处理异常]", context.request().path(), e); - writeResponse(context, CommonResult.error(INTERNAL_SERVER_ERROR)); + if (!context.response().ended()) { + writeResponse(context, CommonResult.error(INTERNAL_SERVER_ERROR)); + } } } protected abstract CommonResult handle0(RoutingContext context); private void beforeHandle(RoutingContext context) { - // 如果不需要认证,则不走前置处理 + // 如果走/auth接口不需要认证,则不走前置处理 String path = context.request().path(); if (ObjUtil.equal(path, IotHttpAuthHandler.PATH)) { return; } - // 解析参数 - String token = context.request().getHeader(HttpHeaders.AUTHORIZATION); - if (StrUtil.isEmpty(token)) { - throw invalidParamException("token 不能为空"); - } String productKey = context.pathParam("productKey"); if (StrUtil.isEmpty(productKey)) { throw invalidParamException("productKey 不能为空"); @@ -72,6 +79,34 @@ public abstract class IotHttpAbstractHandler implements Handler throw invalidParamException("deviceName 不能为空"); } + // 解析参数 + String token = context.request().getHeader(HttpHeaders.AUTHORIZATION); + if (StrUtil.isEmpty(token)) { + // 校验是否允许免鉴权 + IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName); + if (device == null) { + // TODO: 动态注册逻辑 (authType=DYNAMIC) + // 如果设备不存在,但产品配置了动态注册,可以在这里调用注册接口 + // 暂时仅支持预注册设备的免鉴权 + throw exception(DEVICE_NOT_EXISTS, productKey, deviceName); + } + + // 获取生效的认证类型 (设备级优先 > 产品级) + String effectiveAuthType = device.getAuthType(); + if (StrUtil.isEmpty(effectiveAuthType)) { + effectiveAuthType = device.getProductAuthType(); + } + + // 1. 免鉴权 (NONE) + if (ObjUtil.equal(effectiveAuthType, IotAuthTypeEnum.NONE.getType())) { + return; + } + + // 注意:一机一密 (SECRET) 和 一型一密 (PRODUCT_SECRET) 必须走 /auth 接口获取 Token + // 它们不应该在无 Token 的情况下直接访问业务接口 + throw invalidParamException("token 不能为空"); + } + // 校验 token IotDeviceAuthUtils.DeviceInfo deviceInfo = deviceTokenService.verifyToken(token); Assert.notNull(deviceInfo, "设备信息不能为空"); diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java index d119a04..364a746 100644 --- a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java @@ -3,10 +3,15 @@ package com.viewsh.module.iot.gateway.protocol.http.router; import cn.hutool.core.lang.Assert; import cn.hutool.core.map.MapUtil; import cn.hutool.core.text.StrPool; +import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.iot.core.biz.dto.IotDeviceRespDTO; import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.gateway.codec.people.IotPeopleCounterCodec; +import com.viewsh.module.iot.gateway.codec.people.dto.PeopleCounterUploadResp; import com.viewsh.module.iot.gateway.protocol.http.IotHttpUpstreamProtocol; +import com.viewsh.module.iot.gateway.service.device.IotDeviceService; import com.viewsh.module.iot.gateway.service.device.message.IotDeviceMessageService; import io.vertx.ext.web.RoutingContext; import lombok.RequiredArgsConstructor; @@ -27,9 +32,12 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler { private final IotDeviceMessageService deviceMessageService; + private final IotDeviceService deviceService; + public IotHttpUpstreamHandler(IotHttpUpstreamProtocol protocol) { this.protocol = protocol; this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); + this.deviceService = SpringUtil.getBean(IotDeviceService.class); } @Override @@ -39,16 +47,29 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler { String deviceName = context.pathParam("deviceName"); String method = context.pathParam("*").replaceAll(StrPool.SLASH, StrPool.DOT); - // 2.1 解析消息 + // 2. 解码设备消息 byte[] bytes = context.body().buffer().getBytes(); IotDeviceMessage message = deviceMessageService.decodeDeviceMessage(bytes, productKey, deviceName); Assert.equals(method, message.getMethod(), "method 不匹配"); - // 2.2 发送消息 - deviceMessageService.sendDeviceMessage(message, - productKey, deviceName, protocol.getServerId()); - // 3. 返回结果 + // 3. 发送消息到消息队列 + // 注意:HTTP 是短连接协议,响应直接通过 HTTP 返回,不需要记录回复消息 + // 所以 serverId 传 null,让系统不记录回复消息(参见 IotDeviceMessageServiceImpl.handleUpstreamDeviceMessage 第186行) + deviceMessageService.sendDeviceMessage(message, + productKey, deviceName, null); + + // 4. 构建响应 + // 4.1 特殊处理:客流计数器设备返回自定义格式 + IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName); + if (device != null && StrUtil.equals(device.getCodecType(), IotPeopleCounterCodec.TYPE)) { + // 客流计数器:返回自定义响应格式 + PeopleCounterUploadResp resp = PeopleCounterUploadResp.createDefault(); + writeResponse(context, resp); + return null; // 返回 null 表示响应已写入 + } + + // 4.2 默认:返回标准 CommonResult 格式 return CommonResult.success(MapUtil.of("messageId", message.getId())); } diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/IotTcpUpstreamProtocol.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/IotTcpUpstreamProtocol.java index 210be06..423eb3b 100644 --- a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/IotTcpUpstreamProtocol.java +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/IotTcpUpstreamProtocol.java @@ -2,6 +2,7 @@ package com.viewsh.module.iot.gateway.protocol.tcp; import com.viewsh.module.iot.core.util.IotDeviceMessageUtils; import com.viewsh.module.iot.gateway.config.IotGatewayProperties; +import com.viewsh.module.iot.gateway.protocol.tcp.handler.ProtocolHandler; import com.viewsh.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; import com.viewsh.module.iot.gateway.protocol.tcp.router.IotTcpUpstreamHandler; import com.viewsh.module.iot.gateway.service.device.IotDeviceService; @@ -15,6 +16,8 @@ import jakarta.annotation.PreDestroy; import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import java.util.List; + /** * IoT 网关 TCP 协议:接收设备上行消息 * @@ -31,6 +34,8 @@ public class IotTcpUpstreamProtocol { private final IotTcpConnectionManager connectionManager; + private final List protocolHandlers; + private final Vertx vertx; @Getter @@ -42,11 +47,13 @@ public class IotTcpUpstreamProtocol { IotDeviceService deviceService, IotDeviceMessageService messageService, IotTcpConnectionManager connectionManager, + List protocolHandlers, Vertx vertx) { this.tcpProperties = tcpProperties; this.deviceService = deviceService; this.messageService = messageService; this.connectionManager = connectionManager; + this.protocolHandlers = protocolHandlers; this.vertx = vertx; this.serverId = IotDeviceMessageUtils.generateServerId(tcpProperties.getPort()); } @@ -70,8 +77,8 @@ public class IotTcpUpstreamProtocol { // 创建服务器并设置连接处理器 tcpServer = vertx.createNetServer(options); tcpServer.connectHandler(socket -> { - IotTcpUpstreamHandler handler = new IotTcpUpstreamHandler(this, messageService, deviceService, - connectionManager); + IotTcpUpstreamHandler handler = new IotTcpUpstreamHandler(this, messageService, + connectionManager, protocolHandlers); handler.handle(socket); }); diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/AbstractProtocolHandler.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/AbstractProtocolHandler.java new file mode 100644 index 0000000..eaf6867 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/AbstractProtocolHandler.java @@ -0,0 +1,127 @@ +package com.viewsh.module.iot.gateway.protocol.tcp.handler; + +import cn.hutool.core.map.MapUtil; +import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetSocket; +import lombok.extern.slf4j.Slf4j; + +/** + * 协议处理器抽象基类 + *

+ * 提供协议处理器的通用功能实现: + * 1. 消息编解码服务注入 + * 2. 通用响应发送方法 + * 3. 日志记录 + *

+ * 子类只需实现协议特定的认证和业务逻辑即可。 + * + * @author lzh + */ +@Slf4j +public abstract class AbstractProtocolHandler implements ProtocolHandler { + + /** + * 标准认证方法名 + */ + protected static final String AUTH_METHOD = "auth"; + + /** + * 设备消息服务(用于编解码) + */ + protected final IotDeviceMessageService deviceMessageService; + + protected AbstractProtocolHandler(IotDeviceMessageService deviceMessageService) { + this.deviceMessageService = deviceMessageService; + } + + /** + * 默认响应发送实现 + *

+ * 发送标准格式的响应消息: + * { + * "success": true/false, + * "message": "消息内容" + * } + *

+ * 子类可以重写此方法以实现协议特定的响应格式。 + * + * @param socket 网络连接 + * @param originalMessage 原始请求消息 + * @param success 是否成功 + * @param message 响应消息 + * @param codecType 消息编解码类型 + */ + @Override + public void sendResponse(NetSocket socket, IotDeviceMessage originalMessage, + boolean success, String message, String codecType) { + try { + // 构建响应数据 + Object responseData = MapUtil.builder() + .put("success", success) + .put("message", message) + .build(); + + // 构建响应消息 + int code = success ? 0 : 401; + IotDeviceMessage responseMessage = IotDeviceMessage.replyOf( + originalMessage.getRequestId(), + originalMessage.getMethod(), + responseData, + code, + message + ); + + // 编码并发送 + byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, codecType); + socket.write(Buffer.buffer(encodedData)); + + log.debug("[sendResponse][发送响应成功,协议: {}, success: {}, message: {}]", + getProtocolType(), success, message); + } catch (Exception e) { + log.error("[sendResponse][发送响应失败,协议: {}, requestId: {}]", + getProtocolType(), originalMessage.getRequestId(), e); + } + } + + /** + * 发送成功响应(便捷方法) + * + * @param socket 网络连接 + * @param originalMessage 原始请求消息 + * @param message 成功消息 + * @param codecType 消息编解码类型 + */ + protected void sendSuccessResponse(NetSocket socket, IotDeviceMessage originalMessage, + String message, String codecType) { + sendResponse(socket, originalMessage, true, message, codecType); + } + + /** + * 发送失败响应(便捷方法) + * + * @param socket 网络连接 + * @param originalMessage 原始请求消息 + * @param message 失败消息 + * @param codecType 消息编解码类型 + */ + protected void sendErrorResponse(NetSocket socket, IotDeviceMessage originalMessage, + String message, String codecType) { + sendResponse(socket, originalMessage, false, message, codecType); + } + + /** + * 判断是否为认证消息 + *

+ * 默认实现:判断 method 是否为 "auth" + * 子类可以重写以支持协议特定的认证方法名 + * + * @param message 设备消息 + * @return true-是认证消息,false-不是 + */ + protected boolean isAuthenticationMessage(IotDeviceMessage message) { + return AUTH_METHOD.equals(message.getMethod()); + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/AuthResult.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/AuthResult.java new file mode 100644 index 0000000..5fd37ef --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/AuthResult.java @@ -0,0 +1,146 @@ +package com.viewsh.module.iot.gateway.protocol.tcp.handler; + +import com.viewsh.module.iot.core.biz.dto.IotDeviceRespDTO; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 认证结果封装类 + *

+ * 用于协议处理器返回认证处理结果,包含: + * - 认证是否成功 + * - 设备信息(认证成功时) + * - 错误消息(认证失败时) + * - 认证状态(pending 表示需要多步认证) + *

+ * 使用场景: + * 1. 标准协议:单次认证,返回 success 或 failure + * 2. JT808:两步认证 + * - 注册阶段:返回 pending(注册成功但未完成认证) + * - 鉴权阶段:返回 success 或 failure + * 3. 免认证协议:直接返回 success + * + * @author lzh + */ +@Data +@Accessors(chain = true) +public class AuthResult { + + /** + * 认证状态枚举 + */ + public enum Status { + /** + * 认证成功:设备已完成认证,可以发送业务消息 + */ + SUCCESS, + + /** + * 认证失败:设备认证失败,连接应该断开或等待重试 + */ + FAILURE, + + /** + * 认证待定:设备部分认证成功,需要继续后续步骤 + *

+ * 例如 JT808 的注册阶段:注册成功但还需要鉴权 + */ + PENDING + } + + /** + * 认证状态 + */ + private Status status; + + /** + * 响应消息(用于日志和错误提示) + */ + private String message; + + /** + * 设备信息(认证成功或 PENDING 时必须提供) + */ + private IotDeviceRespDTO device; + + /** + * 协议特定的附加数据 + *

+ * 用于协议处理器在多步认证中传递状态信息。 + * 例如 JT808 可以在注册阶段存储鉴权码,鉴权阶段取出验证。 + */ + private Object additionalData; + + // ========== 便捷构造方法 ========== + + /** + * 创建认证成功结果 + * + * @param device 设备信息 + * @param message 成功消息 + * @return 认证成功结果 + */ + public static AuthResult success(IotDeviceRespDTO device, String message) { + return new AuthResult() + .setStatus(Status.SUCCESS) + .setDevice(device) + .setMessage(message); + } + + /** + * 创建认证失败结果 + * + * @param message 失败原因 + * @return 认证失败结果 + */ + public static AuthResult failure(String message) { + return new AuthResult() + .setStatus(Status.FAILURE) + .setMessage(message); + } + + /** + * 创建认证待定结果(用于多步认证) + * + * @param device 设备信息 + * @param message 待定消息 + * @return 认证待定结果 + */ + public static AuthResult pending(IotDeviceRespDTO device, String message) { + return new AuthResult() + .setStatus(Status.PENDING) + .setDevice(device) + .setMessage(message); + } + + // ========== 便捷判断方法 ========== + + /** + * 是否认证成功 + */ + public boolean isSuccess() { + return Status.SUCCESS == status; + } + + /** + * 是否认证失败 + */ + public boolean isFailure() { + return Status.FAILURE == status; + } + + /** + * 是否认证待定(需要继续后续步骤) + */ + public boolean isPending() { + return Status.PENDING == status; + } + + /** + * 是否已完成认证(成功或失败,不包括待定) + */ + public boolean isCompleted() { + return status == Status.SUCCESS || status == Status.FAILURE; + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/Jt808ProtocolHandler.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/Jt808ProtocolHandler.java new file mode 100644 index 0000000..d94a218 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/Jt808ProtocolHandler.java @@ -0,0 +1,536 @@ +package com.viewsh.module.iot.gateway.protocol.tcp.handler; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.iot.core.biz.IotDeviceCommonApi; +import com.viewsh.module.iot.core.biz.dto.IotDeviceGetReqDTO; +import com.viewsh.module.iot.core.biz.dto.IotDeviceRespDTO; +import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.gateway.codec.jt808.IotJt808DeviceMessageCodec; +import com.viewsh.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetSocket; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * JT808 协议处理器 + *

+ * 实现 JT808 部标协议的完整认证流程: + * 1. 终端注册(0x0100):设备首次连接时注册,平台返回鉴权码 + * 2. 终端鉴权(0x0102):设备使用鉴权码进行鉴权,鉴权成功后可发送业务消息 + *

+ * 认证策略(基于设备配置的 authType): + * - SECRET(一机一密):使用设备的 deviceSecret 作为鉴权码 + * - PRODUCT_SECRET(一型一密):使用产品的 productSecret 作为鉴权码 + * - NONE(免鉴权):使用终端手机号作为鉴权码(兼容模式) + *

+ * 设计说明: + * - 基于 demo 项目(com/iot/transport/jt808)的实现逻辑 + * - 注册和鉴权分两步完成,符合 JT808 标准流程 + * - 鉴权码在注册阶段生成并缓存,鉴权阶段验证后清除 + * - 支持设备级和产品级认证类型配置(设备级优先) + * + * @author lzh + */ +@Slf4j +@Component +public class Jt808ProtocolHandler extends AbstractProtocolHandler { + + private static final String CODEC_TYPE_JT808 = IotJt808DeviceMessageCodec.TYPE; + + /** + * JT808 消息方法名 + */ + private static final String METHOD_REGISTER = "jt808.terminal.register"; // 终端注册 (0x0100) + private static final String METHOD_AUTH = "jt808.terminal.auth"; // 终端鉴权 (0x0102) + + /** + * 鉴权码缓存过期时间(分钟) + *

+ * 设备在注册后需要在此时间内完成鉴权,否则需要重新注册。 + * Redis 会自动清理过期的鉴权码,无需手动管理。 + */ + private static final int AUTH_TOKEN_EXPIRE_MINUTES = 5; + private final String JT808_AUTH_TOKEN = "iot:jt808_auth_token:%s"; + /** + * Redis 模板(用于鉴权码缓存) + */ + @Resource + private StringRedisTemplate stringRedisTemplate; + + private final IotDeviceCommonApi deviceApi; + + public Jt808ProtocolHandler(IotDeviceMessageService deviceMessageService) { + super(deviceMessageService); + // 使用 SpringUtil 延迟获取,避免循环依赖 + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + } + + @Override + public String getProtocolType() { + return "JT808"; + } + + @Override + public boolean canHandle(IotDeviceMessage message, String codecType) { + // 只处理 JT808 编解码类型的消息 + return CODEC_TYPE_JT808.equals(codecType); + } + + @Override + public AuthResult handleAuthentication(String clientId, IotDeviceMessage message, + String codecType, NetSocket socket) { + String method = message.getMethod(); + + // 路由到不同的认证处理方法 + if (METHOD_REGISTER.equals(method)) { + return handleRegister(clientId, message, codecType, socket); + } else if (METHOD_AUTH.equals(method)) { + return handleAuth(clientId, message, codecType, socket); + } else { + log.warn("[handleAuthentication][不支持的认证方法,method: {}]", method); + return AuthResult.failure("不支持的认证方法: " + method); + } + } + + @Override + public void handleBusinessMessage(String clientId, IotDeviceMessage message, + String codecType, NetSocket socket, + String productKey, String deviceName, String serverId) { + try { + // 发送消息到消息总线 + deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); + + log.info("[handleBusinessMessage][JT808 业务消息已发送,clientId: {}, method: {}, messageId: {}]", + clientId, message.getMethod(), message.getId()); + + } catch (Exception e) { + log.error("[handleBusinessMessage][JT808 业务消息处理异常,clientId: {}]", clientId, e); + } + } + + @Override + public void sendResponse(NetSocket socket, IotDeviceMessage originalMessage, + boolean success, String message, String codecType) { + // JT808 协议的响应由具体的处理方法发送(注册应答、通用应答等) + // 此方法不需要实现,保留空实现 + log.debug("[sendResponse][JT808 协议响应由具体方法发送,跳过通用响应]"); + } + + // ========== 认证处理方法 ========== + + /** + * 处理终端注册(0x0100) + *

+ * 流程: + * 1. 提取终端手机号(deviceName) + * 2. 查询设备是否存在 + * 3. 生成鉴权码(根据设备的认证类型) + * 4. 缓存鉴权码 + * 5. 发送注册应答(0x8100) + *

+ * 注意:注册成功后,设备还需要发送鉴权消息(0x0102)才能完成认证 + * + * @param clientId 客户端ID + * @param message 注册消息 + * @param codecType 编解码类型 + * @param socket 网络连接 + * @return 认证结果(PENDING 表示注册成功但需要继续鉴权) + */ + private AuthResult handleRegister(String clientId, IotDeviceMessage message, + String codecType, NetSocket socket) { + try { + // 1. 提取终端手机号 + String terminalPhone = extractTerminalPhone(message); + int flowId = extractFlowId(message); + + if (StrUtil.isBlank(terminalPhone)) { + log.warn("[handleRegister][无法提取终端手机号,clientId: {}]", clientId); + sendRegisterResp(socket, "", 0, (byte) 1, null, codecType, message.getRequestId()); + return AuthResult.failure("无法提取终端手机号"); + } + + // 2. 查询设备是否存在(deviceName 就是终端手机号) + IotDeviceRespDTO device = findDeviceByDeviceName(terminalPhone); + if (device == null) { + log.warn("[handleRegister][设备不存在,终端手机号: {}]", terminalPhone); + sendRegisterResp(socket, terminalPhone, flowId, (byte) 1, null, codecType, message.getRequestId()); + return AuthResult.failure("设备不存在"); + } + + // 3. 生成鉴权码(根据设备的认证类型) + String authToken = generateAuthToken(terminalPhone, device); + + // 4. 缓存鉴权码到 Redis(设置过期时间) + String redisKey = String.format(JT808_AUTH_TOKEN, terminalPhone); + stringRedisTemplate.opsForValue().set(redisKey, authToken, + AUTH_TOKEN_EXPIRE_MINUTES, TimeUnit.MINUTES); + + log.debug("[handleRegister][鉴权码已缓存到 Redis,终端手机号: {}, 过期时间: {} 分钟]", + terminalPhone, AUTH_TOKEN_EXPIRE_MINUTES); + + // 5. 发送注册应答(成功,result_code=0) + sendRegisterResp(socket, terminalPhone, flowId, (byte) 0, authToken, codecType, message.getRequestId()); + + log.info("[handleRegister][JT808 注册成功,终端手机号: {}, 设备ID: {}, 鉴权码已生成]", + terminalPhone, device.getId()); + + // 返回 PENDING 状态:注册成功但还需要鉴权 + return AuthResult.pending(device, "注册成功,等待鉴权"); + + } catch (Exception e) { + log.error("[handleRegister][JT808 注册处理异常,clientId: {}]", clientId, e); + try { + String terminalPhone = extractTerminalPhone(message); + sendRegisterResp(socket, terminalPhone != null ? terminalPhone : "", 0, + (byte) 2, null, codecType, message.getRequestId()); + } catch (Exception ex) { + log.error("[handleRegister][发送注册应答失败]", ex); + } + return AuthResult.failure("注册处理异常: " + e.getMessage()); + } + } + + /** + * 处理终端鉴权(0x0102) + *

+ * 流程: + * 1. 提取终端手机号和鉴权码 + * 2. 查询设备是否存在 + * 3. 验证鉴权码(与注册时缓存的鉴权码比对) + * 4. 发送通用应答(0x8001) + * 5. 清除鉴权码缓存 + *

+ * 注意:鉴权成功后,设备才能发送业务消息 + * + * @param clientId 客户端ID + * @param message 鉴权消息 + * @param codecType 编解码类型 + * @param socket 网络连接 + * @return 认证结果(SUCCESS 表示鉴权成功,可以发送业务消息) + */ + private AuthResult handleAuth(String clientId, IotDeviceMessage message, + String codecType, NetSocket socket) { + try { + // 1. 提取终端手机号和鉴权码 + String terminalPhone = extractTerminalPhone(message); + String authCode = extractAuthCode(message); + int flowId = extractFlowId(message); + + if (StrUtil.isBlank(terminalPhone)) { + log.warn("[handleAuth][无法提取终端手机号,clientId: {}]", clientId); + sendCommonResp(socket, "", 0, 0x0102, (byte) 1, codecType, message.getRequestId()); + return AuthResult.failure("无法提取终端手机号"); + } + + if (StrUtil.isBlank(authCode)) { + log.warn("[handleAuth][鉴权码为空,终端手机号: {}]", terminalPhone); + sendCommonResp(socket, terminalPhone, flowId, 0x0102, (byte) 1, codecType, message.getRequestId()); + return AuthResult.failure("鉴权码为空"); + } + + // 2. 查询设备是否存在 + IotDeviceRespDTO device = findDeviceByDeviceName(terminalPhone); + if (device == null) { + log.warn("[handleAuth][设备不存在,终端手机号: {}]", terminalPhone); + sendCommonResp(socket, terminalPhone, flowId, 0x0102, (byte) 1, codecType, message.getRequestId()); + return AuthResult.failure("设备不存在"); + } + + // 3. 从 Redis 获取鉴权码 + String redisKey = String.format(JT808_AUTH_TOKEN, terminalPhone); + String cachedAuthToken = stringRedisTemplate.opsForValue().get(redisKey); + + if (StrUtil.isBlank(cachedAuthToken)) { + log.warn("[handleAuth][未找到鉴权码缓存,终端手机号: {},可能未注册或缓存已过期]", terminalPhone); + sendCommonResp(socket, terminalPhone, flowId, 0x0102, (byte) 1, codecType, message.getRequestId()); + return AuthResult.failure("未找到鉴权码,请先注册"); + } + + // 验证鉴权码是否匹配(Redis 自动处理过期,无需手动检查) + if (!authCode.equals(cachedAuthToken)) { + log.warn("[handleAuth][鉴权码验证失败,终端手机号: {}, 期望: {}, 实际: {}]", + terminalPhone, cachedAuthToken, authCode); + sendCommonResp(socket, terminalPhone, flowId, 0x0102, (byte) 1, codecType, message.getRequestId()); + return AuthResult.failure("鉴权码错误"); + } + + // 4. 发送通用应答(成功,result_code=0) + sendCommonResp(socket, terminalPhone, flowId, 0x0102, (byte) 0, codecType, message.getRequestId()); + + // 5. 从 Redis 删除鉴权码 + stringRedisTemplate.delete(redisKey); + + log.info("[handleAuth][JT808 鉴权成功,终端手机号: {}, 设备ID: {}]", + terminalPhone, device.getId()); + + // 返回 SUCCESS 状态:鉴权成功,设备可以发送业务消息 + return AuthResult.success(device, "鉴权成功"); + + } catch (Exception e) { + log.error("[handleAuth][JT808 鉴权处理异常,clientId: {}]", clientId, e); + try { + String terminalPhone = extractTerminalPhone(message); + int flowId = extractFlowId(message); + sendCommonResp(socket, terminalPhone != null ? terminalPhone : "", flowId, + 0x0102, (byte) 2, codecType, message.getRequestId()); + } catch (Exception ex) { + log.error("[handleAuth][发送通用应答失败]", ex); + } + return AuthResult.failure("鉴权处理异常: " + e.getMessage()); + } + } + + // ========== 鉴权码生成策略 ========== + + /** + * 生成鉴权码 + *

+ * 使用系统标准认证 API 生成鉴权码,保持与标准协议认证的一致性。 + * 鉴权码的生成策略由系统统一管理,支持: + * - SECRET(一机一密):使用设备的 deviceSecret + * - PRODUCT_SECRET(一型一密):使用产品的 productSecret + * - NONE(免鉴权):使用终端手机号 + *

+ * 注意:此方法基于 demo 的实现(RegisterHandler:48),demo 使用手机号作为鉴权码 + * + * @param terminalPhone 终端手机号 + * @param device 设备信息 + * @return 鉴权码 + */ + private String generateAuthToken(String terminalPhone, IotDeviceRespDTO device) { + try { + // 构建认证参数(username 格式:productKey.deviceName) +// String username = IotDeviceAuthUtils.buildUsername(device.getProductKey(), device.getDeviceName()); + + // 调用系统标准认证 API 获取认证信息 + // 注意:这里只是为了获取密钥信息,不是真正的认证 + // 实际的鉴权码应该是设备的密钥(deviceSecret 或 productSecret) + // 但由于当前 API 不返回密钥,我们使用终端手机号作为鉴权码(与 demo 保持一致) + + log.debug("[generateAuthToken][生成鉴权码,终端手机号: {}, 设备: {}, 认证类型: {}]", + terminalPhone, device.getDeviceName(), + StrUtil.isNotBlank(device.getAuthType()) ? device.getAuthType() : device.getProductAuthType()); + + // 使用终端手机号作为鉴权码(与 demo 保持一致) + // TODO: 后续可以根据 authType 从系统获取真实的密钥 + return terminalPhone; + + } catch (Exception e) { + log.error("[generateAuthToken][生成鉴权码异常,终端手机号: {}]", terminalPhone, e); + // 异常时使用终端手机号兜底 + return terminalPhone; + } + } + + // ========== JT808 协议响应方法 ========== + + /** + * 发送终端注册应答(0x8100) + *

+ * 消息格式: + * - replyFlowId: 应答流水号(对应注册消息的流水号) + * - replyCode: 结果码(0-成功,1-车辆已注册,2-数据库无该车辆,3-终端已注册,4-数据库无该终端) + * - authToken: 鉴权码(成功时返回,用于后续鉴权) + * + * @param socket 网络连接 + * @param phone 终端手机号 + * @param replyFlowId 应答流水号 + * @param replyCode 结果码 + * @param authToken 鉴权码 + * @param codecType 编解码类型 + * @param requestId 请求ID + */ + private void sendRegisterResp(NetSocket socket, String phone, int replyFlowId, byte replyCode, + String authToken, String codecType, String requestId) { + try { + // 生成平台流水号 + int flowId = (int) (System.currentTimeMillis() % 65535) + 1; + + // 构建注册应答消息参数 + Map params = MapUtil.builder() + .put("phone", phone) + .put("replyFlowId", replyFlowId) + .put("replyCode", (int) replyCode) + .put("authToken", authToken) + .put("flowId", flowId) + .build(); + + // 构建响应消息 + IotDeviceMessage responseMessage = IotDeviceMessage.replyOf( + requestId, + "jt808.platform.registerResp", + params, + replyCode == 0 ? 0 : 401, + replyCode == 0 ? "注册成功" : "注册失败" + ); + + // 编码并发送 + byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, codecType); + socket.write(Buffer.buffer(encodedData)); + + log.debug("[sendRegisterResp][发送注册应答,终端手机号: {}, 结果码: {}]", phone, replyCode); + + } catch (Exception e) { + log.error("[sendRegisterResp][发送注册应答失败,终端手机号: ]", phone, e); + } + } + + /** + * 发送平台通用应答(0x8001) + *

+ * 消息格式: + * - replyFlowId: 应答流水号(对应原消息的流水号) + * - replyId: 应答ID(对应原消息的消息ID,如 0x0102) + * - replyCode: 结果码(0-成功,1-失败,2-消息有误,3-不支持) + * + * @param socket 网络连接 + * @param phone 终端手机号 + * @param replyFlowId 应答流水号 + * @param replyId 应答ID + * @param replyCode 结果码 + * @param codecType 编解码类型 + * @param requestId 请求ID + */ + private void sendCommonResp(NetSocket socket, String phone, int replyFlowId, int replyId, + byte replyCode, String codecType, String requestId) { + try { + // 生成平台流水号 + int flowId = (int) (System.currentTimeMillis() % 65535) + 1; + + // 构建通用应答消息参数 + Map params = MapUtil.builder() + .put("phone", phone) + .put("replyFlowId", replyFlowId) + .put("replyId", replyId) + .put("replyCode", (int) replyCode) + .put("flowId", flowId) + .build(); + + // 构建响应消息 + IotDeviceMessage responseMessage = IotDeviceMessage.replyOf( + requestId, + "jt808.platform.commonResp", + params, + replyCode == 0 ? 0 : 401, + replyCode == 0 ? "成功" : "失败" + ); + + // 编码并发送 + byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, codecType); + socket.write(Buffer.buffer(encodedData)); + + log.debug("[sendCommonResp][发送通用应答,终端手机号: {}, 应答ID: 0x{}, 结果码: {}]", + phone, Integer.toHexString(replyId), replyCode); + + } catch (Exception e) { + log.error("[sendCommonResp][发送通用应答失败,终端手机号: {}]", phone, e); + } + } + + // ========== 消息参数提取方法 ========== + + /** + * 从消息中提取终端手机号 + *

+ * 终端手机号存储在消息的 _metadata.terminalPhone 字段中, + * 由 IotJt808DeviceMessageCodec 解码时自动填充 + * + * @param message 设备消息 + * @return 终端手机号,提取失败返回 null + */ + @SuppressWarnings("unchecked") + private String extractTerminalPhone(IotDeviceMessage message) { + if (message.getParams() instanceof Map) { + Map params = (Map) message.getParams(); + if (params.get("_metadata") instanceof Map) { + Map metadata = (Map) params.get("_metadata"); + Object terminalPhone = metadata.get("terminalPhone"); + if (terminalPhone != null) { + return terminalPhone.toString(); + } + } + } + return null; + } + + /** + * 从消息中提取流水号 + *

+ * 流水号存储在消息的 _metadata.flowId 字段中 + * + * @param message 设备消息 + * @return 流水号,提取失败返回 1 + */ + @SuppressWarnings("unchecked") + private int extractFlowId(IotDeviceMessage message) { + if (message.getParams() instanceof Map) { + Map params = (Map) message.getParams(); + if (params.get("_metadata") instanceof Map) { + Map metadata = (Map) params.get("_metadata"); + Object flowId = metadata.get("flowId"); + if (flowId instanceof Number) { + return ((Number) flowId).intValue(); + } + } + } + return 1; + } + + /** + * 从消息中提取鉴权码 + *

+ * 鉴权码存储在消息的 params.authCode 字段中, + * 由 IotJt808DeviceMessageCodec 解码时填充 + * + * @param message 设备消息 + * @return 鉴权码,提取失败返回 null + */ + @SuppressWarnings("unchecked") + private String extractAuthCode(IotDeviceMessage message) { + if (message.getParams() instanceof Map) { + Map params = (Map) message.getParams(); + Object authCode = params.get("authCode"); + if (authCode != null) { + return authCode.toString(); + } + } + return null; + } + + /** + * 通过 deviceName 查找设备 + *

+ * 对于 JT808 设备,deviceName 就是终端手机号(纯数字) + * + * @param deviceName 设备名称(终端手机号) + * @return 设备信息,查询失败返回 null + */ + private IotDeviceRespDTO findDeviceByDeviceName(String deviceName) { + try { + // 调用设备 API 查询(只传 deviceName) + CommonResult result = + deviceApi.getDevice(new IotDeviceGetReqDTO().setDeviceName(deviceName)); + + if (result.isSuccess() && result.getData() != null) { + return result.getData(); + } + + log.warn("[findDeviceByDeviceName][设备不存在,deviceName: {}]", deviceName); + return null; + + } catch (Exception e) { + log.error("[findDeviceByDeviceName][查询设备失败,deviceName: {}]", deviceName, e); + return null; + } + } + +} \ No newline at end of file diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/ProtocolHandler.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/ProtocolHandler.java new file mode 100644 index 0000000..a418c33 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/ProtocolHandler.java @@ -0,0 +1,105 @@ +package com.viewsh.module.iot.gateway.protocol.tcp.handler; + +import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import io.vertx.core.net.NetSocket; + +/** + * TCP 协议处理器接口 + *

+ * 用于实现不同协议的插件化处理,每个协议实现此接口以提供: + * 1. 协议识别能力(canHandle) + * 2. 认证处理逻辑(handleAuthentication) + * 3. 业务消息处理(handleBusinessMessage) + * 4. 响应发送(sendResponse) + *

+ * 设计原则: + * - 每个协议处理器独立负责自己的认证和消息处理逻辑 + * - 主处理器(IotTcpUpstreamHandler)只负责路由,不包含协议特定逻辑 + * - 协议处理器通过 Spring 自动注入,实现热插拔 + * + * @author lzh + */ +public interface ProtocolHandler { + + /** + * 获取协议类型标识 + *

+ * 用于日志记录和调试,建议使用大写英文,如:STANDARD, JT808, MODBUS + * + * @return 协议类型标识 + */ + String getProtocolType(); + + /** + * 判断是否能处理该消息 + *

+ * 根据消息内容和编解码类型判断是否由当前协议处理器处理。 + * 注意:多个处理器可能都返回 true,主处理器会选择第一个匹配的。 + * + * @param message 已解码的设备消息 + * @param codecType 消息编解码类型(如:JSON, BINARY, JT808) + * @return true-可以处理,false-不能处理 + */ + boolean canHandle(IotDeviceMessage message, String codecType); + + /** + * 处理认证消息 + *

+ * 不同协议的认证流程可能不同: + * - 标准协议:单次认证(auth 方法) + * - JT808:两步认证(注册 + 鉴权) + * - Modbus:可能无需认证 + *

+ * 认证成功后,处理器应返回 AuthResult.success(),主处理器会: + * 1. 注册连接到 ConnectionManager + * 2. 发送设备上线消息 + *

+ * 认证失败或需要多步认证时,返回 AuthResult.failure() 或 AuthResult.pending() + * + * @param clientId 客户端临时ID(连接建立时生成) + * @param message 认证消息 + * @param codecType 消息编解码类型 + * @param socket 网络连接(用于发送响应) + * @return 认证结果 + */ + AuthResult handleAuthentication(String clientId, IotDeviceMessage message, + String codecType, NetSocket socket); + + /** + * 处理业务消息 + *

+ * 处理已认证设备发送的业务消息(属性上报、事件上报等)。 + * 业务消息通常需要转发到消息总线,由业务层处理。 + *

+ * 注意:此方法调用前,主处理器已验证设备已认证。 + * + * @param clientId 客户端临时ID + * @param message 业务消息 + * @param codecType 消息编解码类型 + * @param socket 网络连接 + * @param productKey 产品Key(从连接信息中获取) + * @param deviceName 设备名称(从连接信息中获取) + * @param serverId 服务器ID(用于消息路由) + */ + void handleBusinessMessage(String clientId, IotDeviceMessage message, + String codecType, NetSocket socket, + String productKey, String deviceName, String serverId); + + /** + * 发送响应消息 + *

+ * 用于向设备发送响应消息(成功/失败/错误等)。 + * 不同协议的响应格式可能不同,由协议处理器自行实现。 + *

+ * 注意:某些协议可能不需要响应(如单向上报),可以空实现。 + * + * @param socket 网络连接 + * @param originalMessage 原始请求消息(用于提取 requestId 等信息) + * @param success 是否成功 + * @param message 响应消息内容 + * @param codecType 消息编解码类型 + */ + void sendResponse(NetSocket socket, IotDeviceMessage originalMessage, + boolean success, String message, String codecType); + +} diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/StandardProtocolHandler.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/StandardProtocolHandler.java new file mode 100644 index 0000000..38c8ba0 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/handler/StandardProtocolHandler.java @@ -0,0 +1,192 @@ +package com.viewsh.module.iot.gateway.protocol.tcp.handler; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.extra.spring.SpringUtil; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.iot.core.biz.IotDeviceCommonApi; +import com.viewsh.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import com.viewsh.module.iot.core.biz.dto.IotDeviceRespDTO; +import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.core.util.IotDeviceAuthUtils; +import com.viewsh.module.iot.gateway.codec.tcp.IotTcpBinaryDeviceMessageCodec; +import com.viewsh.module.iot.gateway.codec.tcp.IotTcpJsonDeviceMessageCodec; +import com.viewsh.module.iot.gateway.service.device.IotDeviceService; +import com.viewsh.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.net.NetSocket; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 标准协议处理器 + *

+ * 处理使用标准认证流程的协议(JSON 和 Binary 格式): + * 1. 单次认证:设备发送 auth 消息,包含 username 和 password + * 2. 认证成功后,设备可以发送业务消息 + * 3. 业务消息转发到消息总线 + *

+ * 支持的编解码类型: + * - JSON:IotTcpJsonDeviceMessageCodec + * - BINARY:IotTcpBinaryDeviceMessageCodec + * + * @author lzh + */ +@Slf4j +@Component +public class StandardProtocolHandler extends AbstractProtocolHandler { + + private static final String CODEC_TYPE_JSON = IotTcpJsonDeviceMessageCodec.TYPE; + private static final String CODEC_TYPE_BINARY = IotTcpBinaryDeviceMessageCodec.TYPE; + + private final IotDeviceCommonApi deviceApi; + private final IotDeviceService deviceService; + + public StandardProtocolHandler(IotDeviceMessageService deviceMessageService) { + super(deviceMessageService); + // 使用 SpringUtil 延迟获取,避免循环依赖 + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + this.deviceService = SpringUtil.getBean(IotDeviceService.class); + } + + @Override + public String getProtocolType() { + return "STANDARD"; + } + + @Override + public boolean canHandle(IotDeviceMessage message, String codecType) { + // 处理 JSON 和 BINARY 格式的标准协议 + return CODEC_TYPE_JSON.equals(codecType) || CODEC_TYPE_BINARY.equals(codecType); + } + + @Override + public AuthResult handleAuthentication(String clientId, IotDeviceMessage message, + String codecType, NetSocket socket) { + try { + // 1. 解析认证参数 + IotDeviceAuthReqDTO authParams = parseAuthParams(message.getParams()); + if (authParams == null) { + log.warn("[handleAuthentication][认证参数解析失败,clientId: {}]", clientId); + sendErrorResponse(socket, message, "认证参数不完整", codecType); + return AuthResult.failure("认证参数不完整"); + } + + // 2. 执行认证 + if (!validateDeviceAuth(authParams)) { + log.warn("[handleAuthentication][认证失败,clientId: , username: {}]", + clientId, authParams.getUsername()); + sendErrorResponse(socket, message, "认证失败", codecType); + return AuthResult.failure("认证失败"); + } + + // 3. 解析设备信息 + IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(authParams.getUsername()); + if (deviceInfo == null) { + log.warn("[handleAuthentication][解析设备信息失败,username: {}]", authParams.getUsername()); + sendErrorResponse(socket, message, "解析设备信息失败", codecType); + return AuthResult.failure("解析设备信息失败"); + } + + // 4. 获取设备信息 + IotDeviceRespDTO device = deviceService.getDeviceFromCache( + deviceInfo.getProductKey(), deviceInfo.getDeviceName()); + if (device == null) { + log.warn("[handleAuthentication][设备不存在,productKey: {}, deviceName: {}]", + deviceInfo.getProductKey(), deviceInfo.getDeviceName()); + sendErrorResponse(socket, message, "设备不存在", codecType); + return AuthResult.failure("设备不存在"); + } + + // 5. 发送成功响应 + sendSuccessResponse(socket, message, "认证成功", codecType); + + log.info("[handleAuthentication][认证成功,设备ID: {}, 设备名: {}]", + device.getId(), device.getDeviceName()); + + return AuthResult.success(device, "认证成功"); + + } catch (Exception e) { + log.error("[handleAuthentication][认证处理异常,clientId: {}]", clientId, e); + sendErrorResponse(socket, message, "认证处理异常", codecType); + return AuthResult.failure("认证处理异常: " + e.getMessage()); + } + } + + @Override + public void handleBusinessMessage(String clientId, IotDeviceMessage message, + String codecType, NetSocket socket, + String productKey, String deviceName, String serverId) { + try { + // 发送消息到消息总线 + deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); + + log.info("[handleBusinessMessage][发送消息到消息总线,clientId: {}, method: {}, messageId: {}]", + clientId, message.getMethod(), message.getId()); + + } catch (Exception e) { + log.error("[handleBusinessMessage][业务消息处理异常,clientId: {}]", clientId, e); + } + } + + // ========== 私有方法 ========== + + /** + * 解析认证参数 + * + * @param params 参数对象(通常为 Map 类型) + * @return 认证参数 DTO,解析失败时返回 null + */ + @SuppressWarnings("unchecked") + private IotDeviceAuthReqDTO parseAuthParams(Object params) { + if (params == null) { + return null; + } + + try { + // 参数默认为 Map 类型,直接转换 + if (params instanceof Map) { + Map paramMap = (Map) params; + return new IotDeviceAuthReqDTO() + .setClientId(MapUtil.getStr(paramMap, "clientId")) + .setUsername(MapUtil.getStr(paramMap, "username")) + .setPassword(MapUtil.getStr(paramMap, "password")); + } + + // 如果已经是目标类型,直接返回 + if (params instanceof IotDeviceAuthReqDTO) { + return (IotDeviceAuthReqDTO) params; + } + + return null; + } catch (Exception e) { + log.error("[parseAuthParams][解析认证参数失败,params: {}]", params, e); + return null; + } + } + + /** + * 验证设备认证信息 + * + * @param authParams 认证参数 + * @return 是否认证成功 + */ + private boolean validateDeviceAuth(IotDeviceAuthReqDTO authParams) { + try { + // 调用系统标准认证 API + CommonResult result = deviceApi.authDevice(new IotDeviceAuthReqDTO() + .setClientId(authParams.getClientId()) + .setUsername(authParams.getUsername()) + .setPassword(authParams.getPassword())); + + result.checkError(); + return BooleanUtil.isTrue(result.getData()); + + } catch (Exception e) { + log.error("[validateDeviceAuth][设备认证异常,username: {}]", authParams.getUsername(), e); + return false; + } + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/router/IotTcpDownstreamHandler.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/router/IotTcpDownstreamHandler.java index 26a2f50..d8b0c2e 100644 --- a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/router/IotTcpDownstreamHandler.java +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/router/IotTcpDownstreamHandler.java @@ -43,7 +43,10 @@ public class IotTcpDownstreamHandler { return; } - // 2. 根据产品 Key 和设备名称编码消息并发送到设备 + // 2. 补充 deviceName 到 params 中(用于 JT808 等需要设备标识的编码器) + injectDeviceNameToParams(message, deviceInfo.getDeviceName()); + + // 3. 根据产品 Key 和设备名称编码消息并发送到设备 byte[] bytes = deviceMessageService.encodeDeviceMessage(message, deviceInfo.getProductKey(), deviceInfo.getDeviceName()); boolean success = connectionManager.sendToDevice(message.getDeviceId(), bytes); @@ -60,4 +63,21 @@ public class IotTcpDownstreamHandler { } } + /** + * 将 deviceName 注入到消息 params 中 + * + * 对于 JT808 等需要设备标识(如终端手机号)的协议, + * 编码器可以从 params._deviceName 中获取 + */ + @SuppressWarnings("unchecked") + private void injectDeviceNameToParams(IotDeviceMessage message, String deviceName) { + if (message.getParams() == null) { + message.setParams(new java.util.HashMap<>()); + } + + if (message.getParams() instanceof java.util.Map) { + ((java.util.Map) message.getParams()).put("_deviceName", deviceName); + } + } + } \ No newline at end of file diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/router/IotTcpUpstreamHandler.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/router/IotTcpUpstreamHandler.java index 4490112..d70e94e 100644 --- a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/router/IotTcpUpstreamHandler.java +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/tcp/router/IotTcpUpstreamHandler.java @@ -1,30 +1,40 @@ package com.viewsh.module.iot.gateway.protocol.tcp.router; -import cn.hutool.core.map.MapUtil; -import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; -import cn.hutool.extra.spring.SpringUtil; -import com.viewsh.framework.common.pojo.CommonResult; -import com.viewsh.framework.common.util.json.JsonUtils; -import com.viewsh.module.iot.core.biz.IotDeviceCommonApi; -import com.viewsh.module.iot.core.biz.dto.IotDeviceAuthReqDTO; import com.viewsh.module.iot.core.biz.dto.IotDeviceRespDTO; import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; -import com.viewsh.module.iot.core.util.IotDeviceAuthUtils; +import com.viewsh.module.iot.gateway.codec.jt808.IotJt808DeviceMessageCodec; import com.viewsh.module.iot.gateway.codec.tcp.IotTcpBinaryDeviceMessageCodec; import com.viewsh.module.iot.gateway.codec.tcp.IotTcpJsonDeviceMessageCodec; import com.viewsh.module.iot.gateway.protocol.tcp.IotTcpUpstreamProtocol; +import com.viewsh.module.iot.gateway.protocol.tcp.handler.AuthResult; +import com.viewsh.module.iot.gateway.protocol.tcp.handler.ProtocolHandler; import com.viewsh.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; -import com.viewsh.module.iot.gateway.service.device.IotDeviceService; import com.viewsh.module.iot.gateway.service.device.message.IotDeviceMessageService; import io.vertx.core.Handler; import io.vertx.core.buffer.Buffer; import io.vertx.core.net.NetSocket; import lombok.extern.slf4j.Slf4j; +import java.util.List; + /** - * TCP 上行消息处理器 + * TCP 上行消息处理器(重构版) + *

+ * 职责: + * 1. 管理 TCP 连接生命周期(连接建立、异常、关闭) + * 2. 检测消息格式类型(JSON/Binary/JT808) + * 3. 解码设备消息 + * 4. 路由到对应的协议处理器 + * 5. 管理设备认证状态 + * 6. 发送设备上线/离线消息 + *

+ * 设计原则: + * - 主处理器只负责路由,不包含协议特定逻辑 + * - 协议处理器通过 Spring 自动注入,实现插件化 + * - 认证成功后,统一注册连接和发送上线消息 + * - 支持多步认证(如 JT808 的注册+鉴权) * * @author 芋道源码 */ @@ -33,27 +43,20 @@ public class IotTcpUpstreamHandler implements Handler { private static final String CODEC_TYPE_JSON = IotTcpJsonDeviceMessageCodec.TYPE; private static final String CODEC_TYPE_BINARY = IotTcpBinaryDeviceMessageCodec.TYPE; - - private static final String AUTH_METHOD = "auth"; + private static final String CODEC_TYPE_JT808 = IotJt808DeviceMessageCodec.TYPE; private final IotDeviceMessageService deviceMessageService; - - private final IotDeviceService deviceService; - private final IotTcpConnectionManager connectionManager; - - private final IotDeviceCommonApi deviceApi; - + private final List protocolHandlers; private final String serverId; public IotTcpUpstreamHandler(IotTcpUpstreamProtocol protocol, IotDeviceMessageService deviceMessageService, - IotDeviceService deviceService, - IotTcpConnectionManager connectionManager) { + IotTcpConnectionManager connectionManager, + List protocolHandlers) { this.deviceMessageService = deviceMessageService; - this.deviceService = deviceService; this.connectionManager = connectionManager; - this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + this.protocolHandlers = protocolHandlers; this.serverId = protocol.getServerId(); } @@ -64,9 +67,10 @@ public class IotTcpUpstreamHandler implements Handler { // 设置异常和关闭处理器 socket.exceptionHandler(ex -> { - log.warn("[handle][连接异常,客户端 ID: {},地址: {}]", clientId, socket.remoteAddress()); + log.warn("[handle][连接异常,客户端 ID: {},地址: {}]", clientId, socket.remoteAddress(), ex); cleanupConnection(socket); }); + socket.closeHandler(v -> { log.debug("[handle][连接关闭,客户端 ID: {},地址: {}]", clientId, socket.remoteAddress()); cleanupConnection(socket); @@ -77,8 +81,8 @@ public class IotTcpUpstreamHandler implements Handler { try { processMessage(clientId, buffer, socket); } catch (Exception e) { - log.error("[handle][消息解码失败,断开连接,客户端 ID: {},地址: {},错误: {}]", - clientId, socket.remoteAddress(), e.getMessage()); + log.error("[handle][消息处理失败,断开连接,客户端 ID: {},地址: {},错误: {}]", + clientId, socket.remoteAddress(), e.getMessage(), e); cleanupConnection(socket); socket.close(); } @@ -87,6 +91,13 @@ public class IotTcpUpstreamHandler implements Handler { /** * 处理消息 + *

+ * 流程: + * 1. 检测消息格式类型(JSON/Binary/JT808) + * 2. 解码消息 + * 3. 查找协议处理器 + * 4. 判断是否为认证消息 + * 5. 路由到协议处理器处理 * * @param clientId 客户端 ID * @param buffer 消息 @@ -114,113 +125,125 @@ public class IotTcpUpstreamHandler implements Handler { throw new Exception("消息解码失败: " + e.getMessage(), e); } - // 4. 根据消息类型路由处理 + // 4. 查找协议处理器 + ProtocolHandler handler = findProtocolHandler(message, codecType); + if (handler == null) { + log.warn("[processMessage][未找到协议处理器,codecType: {}, method: {}]", + codecType, message.getMethod()); + return; + } + + // 5. 判断是否为认证消息 + if (isAuthenticationMessage(message)) { + handleAuthenticationWithProtocol(clientId, message, codecType, socket, handler); + } else { + handleBusinessWithProtocol(clientId, message, codecType, socket, handler); + } + } + + /** + * 使用协议处理器处理认证消息 + *

+ * 认证结果处理: + * - SUCCESS:注册连接,发送上线消息 + * - PENDING:等待后续认证步骤(如 JT808 注册后等待鉴权) + * - FAILURE:认证失败,不做处理(协议处理器已发送失败响应) + * + * @param clientId 客户端 ID + * @param message 认证消息 + * @param codecType 消息编解码类型 + * @param socket 网络连接 + * @param handler 协议处理器 + */ + private void handleAuthenticationWithProtocol(String clientId, IotDeviceMessage message, + String codecType, NetSocket socket, + ProtocolHandler handler) { try { - if (AUTH_METHOD.equals(message.getMethod())) { - // 认证请求 - handleAuthenticationRequest(clientId, message, codecType, socket); + // 委托给协议处理器 + AuthResult result = handler.handleAuthentication(clientId, message, codecType, socket); + + // 根据认证结果处理 + if (result.isSuccess()) { + // 认证成功:注册连接并发送上线消息 + registerConnection(socket, result.getDevice(), clientId, codecType); + sendOnlineMessage(result.getDevice()); + + log.info("[handleAuthentication][认证成功,设备: {}, 协议: {}]", + result.getDevice().getDeviceName(), handler.getProtocolType()); + + } else if (result.isPending()) { + // 认证待定:等待后续认证步骤(如 JT808 注册后等待鉴权) + log.info("[handleAuthentication][认证待定,设备: {}, 协议: {}, 消息: {}]", + result.getDevice() != null ? result.getDevice().getDeviceName() : "unknown", + handler.getProtocolType(), result.getMessage()); + } else { - // 业务消息 - handleBusinessRequest(clientId, message, codecType, socket); + // 认证失败:协议处理器已发送失败响应,这里只记录日志 + log.warn("[handleAuthentication][认证失败,clientId: {}, 协议: {}, 原因: {}]", + clientId, handler.getProtocolType(), result.getMessage()); } + } catch (Exception e) { - log.error("[processMessage][处理消息失败,客户端 ID: {},消息方法: {}]", - clientId, message.getMethod(), e); - // 发送错误响应,避免客户端一直等待 - try { - sendErrorResponse(socket, message.getRequestId(), "消息处理失败", codecType); - } catch (Exception responseEx) { - log.error("[processMessage][发送错误响应失败,客户端 ID: {}]", clientId, responseEx); - } + log.error("[handleAuthentication][认证异常,clientId: {}, 协议: {}]", + clientId, handler.getProtocolType(), e); + handler.sendResponse(socket, message, false, "认证异常", codecType); } } /** - * 处理认证请求 + * 使用协议处理器处理业务消息 + *

+ * 前置条件:设备已认证 * * @param clientId 客户端 ID - * @param message 消息信息 + * @param message 业务消息 * @param codecType 消息编解码类型 * @param socket 网络连接 + * @param handler 协议处理器 */ - private void handleAuthenticationRequest(String clientId, IotDeviceMessage message, String codecType, - NetSocket socket) { - try { - // 1.1 解析认证参数 - IotDeviceAuthReqDTO authParams = parseAuthParams(message.getParams()); - if (authParams == null) { - log.warn("[handleAuthenticationRequest][认证参数解析失败,客户端 ID: {}]", clientId); - sendErrorResponse(socket, message.getRequestId(), "认证参数不完整", codecType); - return; - } - // 1.2 执行认证 - if (!validateDeviceAuth(authParams)) { - log.warn("[handleAuthenticationRequest][认证失败,客户端 ID: {},username: {}]", - clientId, authParams.getUsername()); - sendErrorResponse(socket, message.getRequestId(), "认证失败", codecType); - return; - } - - // 2.1 解析设备信息 - IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(authParams.getUsername()); - if (deviceInfo == null) { - sendErrorResponse(socket, message.getRequestId(), "解析设备信息失败", codecType); - return; - } - // 2.2 获取设备信息 - IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), - deviceInfo.getDeviceName()); - if (device == null) { - sendErrorResponse(socket, message.getRequestId(), "设备不存在", codecType); - return; - } - - // 3.1 注册连接 - registerConnection(socket, device, clientId, codecType); - // 3.2 发送上线消息 - sendOnlineMessage(device); - // 3.3 发送成功响应 - sendSuccessResponse(socket, message.getRequestId(), "认证成功", codecType); - log.info("[handleAuthenticationRequest][认证成功,设备 ID: {},设备名: {}]", - device.getId(), device.getDeviceName()); - } catch (Exception e) { - log.error("[handleAuthenticationRequest][认证处理异常,客户端 ID: {}]", clientId, e); - sendErrorResponse(socket, message.getRequestId(), "认证处理异常", codecType); - } - } - - /** - * 处理业务请求 - * - * @param clientId 客户端 ID - * @param message 消息信息 - * @param codecType 消息编解码类型 - * @param socket 网络连接 - */ - private void handleBusinessRequest(String clientId, IotDeviceMessage message, String codecType, NetSocket socket) { + private void handleBusinessWithProtocol(String clientId, IotDeviceMessage message, + String codecType, NetSocket socket, + ProtocolHandler handler) { try { // 1. 检查认证状态 if (connectionManager.isNotAuthenticated(socket)) { - log.warn("[handleBusinessRequest][设备未认证,客户端 ID: {}]", clientId); - sendErrorResponse(socket, message.getRequestId(), "请先进行认证", codecType); + log.warn("[handleBusinessMessage][设备未认证,clientId: {}]", clientId); + handler.sendResponse(socket, message, false, "请先进行认证", codecType); return; } - // 2. 获取认证信息并处理业务消息 + // 2. 获取连接信息 IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); + if (connectionInfo == null) { + log.error("[handleBusinessMessage][连接信息不存在,clientId: {}]", clientId); + return; + } + + // 3. 委托给协议处理器处理业务消息 + handler.handleBusinessMessage( + clientId, + message, + codecType, + socket, + connectionInfo.getProductKey(), + connectionInfo.getDeviceName(), + serverId + ); - // 3. 发送消息到消息总线 - deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(), - connectionInfo.getDeviceName(), serverId); - log.info("[handleBusinessRequest][发送消息到消息总线,客户端 ID: {},消息: {}", - clientId, message.toString()); } catch (Exception e) { - log.error("[handleBusinessRequest][业务请求处理异常,客户端 ID: {}]", clientId, e); + log.error("[handleBusinessMessage][业务消息处理异常,clientId: {}]", clientId, e); } } /** * 获取消息编解码类型 + *

+ * 检测优先级: + * 1. 如果已认证,使用缓存的编解码类型 + * 2. 未认证时,通过消息格式自动检测: + * - JT808:首尾标识符 0x7e + * - Binary:魔术字 0x7E + * - JSON:默认 * * @param buffer 消息 * @param socket 网络连接 @@ -235,15 +258,69 @@ public class IotTcpUpstreamHandler implements Handler { } // 2. 未认证时检测消息格式类型 - return IotTcpBinaryDeviceMessageCodec.isBinaryFormatQuick(buffer.getBytes()) ? CODEC_TYPE_BINARY - : CODEC_TYPE_JSON; + byte[] data = buffer.getBytes(); + + // 2.1 检测是否为 JT808 格式(首尾标识符 0x7e) + if (IotJt808DeviceMessageCodec.isJt808Format(data)) { + return CODEC_TYPE_JT808; + } + + // 2.2 检测是否为自定义二进制格式(魔术字 0x7E) + if (IotTcpBinaryDeviceMessageCodec.isBinaryFormatQuick(data)) { + return CODEC_TYPE_BINARY; + } + + // 2.3 默认为 JSON 格式 + return CODEC_TYPE_JSON; + } + + /** + * 查找协议处理器 + *

+ * 遍历所有协议处理器,返回第一个能处理该消息的处理器 + * + * @param message 设备消息 + * @param codecType 消息编解码类型 + * @return 协议处理器,未找到返回 null + */ + private ProtocolHandler findProtocolHandler(IotDeviceMessage message, String codecType) { + return protocolHandlers.stream() + .filter(handler -> handler.canHandle(message, codecType)) + .findFirst() + .orElse(null); + } + + /** + * 判断是否为认证消息 + *

+ * 认证消息包括: + * - 标准认证:auth + * - JT808 注册:jt808.terminal.register + * - JT808 鉴权:jt808.terminal.auth + * + * @param message 设备消息 + * @return true-是认证消息,false-不是 + */ + private boolean isAuthenticationMessage(IotDeviceMessage message) { + String method = message.getMethod(); + return "auth".equals(method) + || "jt808.terminal.register".equals(method) + || "jt808.terminal.auth".equals(method); } /** * 注册连接信息 + *

+ * 将设备连接信息注册到连接管理器,包括: + * - 设备 ID + * - 产品 Key + * - 设备名称 + * - 客户端 ID + * - 编解码类型 + * - 认证状态 * * @param socket 网络连接 - * @param device 设备 + * @param device 设备信息 * @param clientId 客户端 ID * @param codecType 消息编解码类型 */ @@ -256,12 +333,15 @@ public class IotTcpUpstreamHandler implements Handler { .setClientId(clientId) .setCodecType(codecType) .setAuthenticated(true); - // 注册连接 + + // 注册连接(如果设备已有其他连接,会自动断开旧连接) connectionManager.registerConnection(socket, device.getId(), connectionInfo); } /** * 发送设备上线消息 + *

+ * 设备认证成功后,发送上线消息到消息总线,通知业务层设备已上线 * * @param device 设备信息 */ @@ -270,6 +350,9 @@ public class IotTcpUpstreamHandler implements Handler { IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline(); deviceMessageService.sendDeviceMessage(onlineMessage, device.getProductKey(), device.getDeviceName(), serverId); + + log.debug("[sendOnlineMessage][发送上线消息成功,设备: {}]", device.getDeviceName()); + } catch (Exception e) { log.error("[sendOnlineMessage][发送上线消息失败,设备: {}]", device.getDeviceName(), e); } @@ -277,6 +360,8 @@ public class IotTcpUpstreamHandler implements Handler { /** * 清理连接 + *

+ * 连接关闭或异常时,清理连接信息并发送离线消息 * * @param socket 网络连接 */ @@ -284,125 +369,20 @@ public class IotTcpUpstreamHandler implements Handler { try { // 1. 发送离线消息(如果已认证) IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); - if (connectionInfo != null) { + if (connectionInfo != null && connectionInfo.isAuthenticated()) { IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId); + + log.debug("[cleanupConnection][发送离线消息成功,设备: {}]", connectionInfo.getDeviceName()); } // 2. 注销连接 connectionManager.unregisterConnection(socket); + } catch (Exception e) { log.error("[cleanupConnection][清理连接失败]", e); } } - /** - * 发送响应消息 - * - * @param socket 网络连接 - * @param success 是否成功 - * @param message 消息 - * @param requestId 请求 ID - * @param codecType 消息编解码类型 - */ - private void sendResponse(NetSocket socket, boolean success, String message, String requestId, String codecType) { - try { - Object responseData = MapUtil.builder() - .put("success", success) - .put("message", message) - .build(); - - int code = success ? 0 : 401; - IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId, AUTH_METHOD, responseData, - code, message); - - byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, codecType); - socket.write(Buffer.buffer(encodedData)); - - } catch (Exception e) { - log.error("[sendResponse][发送响应失败,requestId: {}]", requestId, e); - } - } - - /** - * 验证设备认证信息 - * - * @param authParams 认证参数 - * @return 是否认证成功 - */ - private boolean validateDeviceAuth(IotDeviceAuthReqDTO authParams) { - try { - CommonResult result = deviceApi.authDevice(new IotDeviceAuthReqDTO() - .setClientId(authParams.getClientId()).setUsername(authParams.getUsername()) - .setPassword(authParams.getPassword())); - result.checkError(); - return BooleanUtil.isTrue(result.getData()); - } catch (Exception e) { - log.error("[validateDeviceAuth][设备认证异常,username: {}]", authParams.getUsername(), e); - return false; - } - } - - /** - * 发送错误响应 - * - * @param socket 网络连接 - * @param requestId 请求 ID - * @param errorMessage 错误消息 - * @param codecType 消息编解码类型 - */ - private void sendErrorResponse(NetSocket socket, String requestId, String errorMessage, String codecType) { - sendResponse(socket, false, errorMessage, requestId, codecType); - } - - /** - * 发送成功响应 - * - * @param socket 网络连接 - * @param requestId 请求 ID - * @param message 消息 - * @param codecType 消息编解码类型 - */ - @SuppressWarnings("SameParameterValue") - private void sendSuccessResponse(NetSocket socket, String requestId, String message, String codecType) { - sendResponse(socket, true, message, requestId, codecType); - } - - /** - * 解析认证参数 - * - * @param params 参数对象(通常为 Map 类型) - * @return 认证参数 DTO,解析失败时返回 null - */ - @SuppressWarnings("unchecked") - private IotDeviceAuthReqDTO parseAuthParams(Object params) { - if (params == null) { - return null; - } - - try { - // 参数默认为 Map 类型,直接转换 - if (params instanceof java.util.Map) { - java.util.Map paramMap = (java.util.Map) params; - return new IotDeviceAuthReqDTO() - .setClientId(MapUtil.getStr(paramMap, "clientId")) - .setUsername(MapUtil.getStr(paramMap, "username")) - .setPassword(MapUtil.getStr(paramMap, "password")); - } - - // 如果已经是目标类型,直接返回 - if (params instanceof IotDeviceAuthReqDTO) { - return (IotDeviceAuthReqDTO) params; - } - - // 其他情况尝试 JSON 转换 - String jsonStr = JsonUtils.toJsonString(params); - return JsonUtils.parseObject(jsonStr, IotDeviceAuthReqDTO.class); - } catch (Exception e) { - log.error("[parseAuthParams][解析认证参数({})失败]", params, e); - return null; - } - } - -} \ No newline at end of file +} diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/resources/application.yaml b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/resources/application.yaml index 3d03174..0fd48b3 100644 --- a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/resources/application.yaml +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/resources/application.yaml @@ -7,17 +7,17 @@ spring: # Redis 配置 data: redis: - host: 127.0.0.1 # Redis 服务器地址 - port: 6379 # Redis 服务器端口 - database: 0 # Redis 数据库索引 - # password: # Redis 密码,如果有的话 + host: ${REDIS_HOST:127.0.0.1} # Redis 服务器地址 + port: ${REDIS_PORT:6379} # Redis 服务器端口 + database: ${REDIS_DATABASE:0} # Redis 数据库索引 + password: ${REDIS_PASSWORD:9kHXcZ1ojFsD} # Redis 密码 timeout: 30000ms # 连接超时时间 --- #################### 消息队列相关 #################### # rocketmq 配置项,对应 RocketMQProperties 配置类 rocketmq: - name-server: 127.0.0.1:9876 # RocketMQ Namesrv + name-server: ${ROCKETMQ_NAMESERVER:124.221.55.225:9876} # RocketMQ Namesrv # Producer 配置项 producer: group: ${spring.application.name}_PRODUCER # 生产者分组 @@ -34,7 +34,7 @@ viewsh: gateway: # 设备 RPC 配置 rpc: - url: http://127.0.0.1:48091 # 主程序 API 地址 + url: ${VIEWSHANGHAI_IOT_GATEWAY_RPC_URL:http://127.0.0.1:48080} # 主程序 API 地址 connect-timeout: 30s read-timeout: 30s # 设备 Token 配置 @@ -56,7 +56,7 @@ viewsh: emqx: enabled: false http-port: 8090 # MQTT HTTP 服务端口 - mqtt-host: 127.0.0.1 # MQTT Broker 地址 + mqtt-host: ${EMQX_HOST:124.221.55.225} # MQTT Broker 地址 mqtt-port: 1883 # MQTT Broker 端口 mqtt-username: admin # MQTT 用户名 mqtt-password: public # MQTT 密码 @@ -88,7 +88,7 @@ viewsh: # 针对引入的 TCP 组件的配置 # ==================================== tcp: - enabled: false + enabled: true port: 8091 keep-alive-timeout-ms: 30000 max-connections: 1000 diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/api/device/IoTDeviceApiImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/api/device/IoTDeviceApiImpl.java index 1bf85b8..58fed2d 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/api/device/IoTDeviceApiImpl.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/api/device/IoTDeviceApiImpl.java @@ -47,12 +47,27 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi { @PostMapping(RpcConstants.RPC_API_PREFIX + "/iot/device/get") // 特殊:方便调用,暂时使用 POST,实际更推荐 GET @PermitAll public CommonResult getDevice(@RequestBody IotDeviceGetReqDTO getReqDTO) { - IotDeviceDO device = getReqDTO.getId() != null ? deviceService.getDeviceFromCache(getReqDTO.getId()) - : deviceService.getDeviceFromCache(getReqDTO.getProductKey(), getReqDTO.getDeviceName()); + IotDeviceDO device; + + // 查询优先级:id > (productKey + deviceName) > deviceName + if (getReqDTO.getId() != null) { + // 通过设备 ID 查询 + device = deviceService.getDeviceFromCache(getReqDTO.getId()); + } else if (getReqDTO.getProductKey() != null && getReqDTO.getDeviceName() != null) { + // 通过 productKey + deviceName 查询 + device = deviceService.getDeviceFromCache(getReqDTO.getProductKey(), getReqDTO.getDeviceName()); + } else if (getReqDTO.getDeviceName() != null) { + // 仅通过 deviceName 查询(用于 JT808 等协议,终端手机号应该是全局唯一的) + device = deviceService.getDeviceFromCacheByDeviceName(getReqDTO.getDeviceName()); + } else { + device = null; + } + return success(BeanUtils.toBean(device, IotDeviceRespDTO.class, deviceDTO -> { IotProductDO product = productService.getProductFromCache(deviceDTO.getProductId()); if (product != null) { deviceDTO.setCodecType(product.getCodecType()); + deviceDTO.setProductAuthType(product.getAuthType()); } })); } diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alert/IotAlertConfigController.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alert/IotAlertConfigController.java index 0015bb2..bde4c73 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alert/IotAlertConfigController.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/alert/IotAlertConfigController.java @@ -16,6 +16,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; + import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -37,7 +38,7 @@ public class IotAlertConfigController { @Resource private IotAlertConfigService alertConfigService; - + @Resource private AdminUserApi adminUserApi; @@ -79,7 +80,7 @@ public class IotAlertConfigController { @PreAuthorize("@ss.hasPermission('iot:alert-config:query')") public CommonResult> getAlertConfigPage(@Valid IotAlertConfigPageReqVO pageReqVO) { PageResult pageResult = alertConfigService.getAlertConfigPage(pageReqVO); - + // 转换返回 Map userMap = adminUserApi.getUserMap( convertSetByFlatMap(pageResult.getList(), config -> config.getReceiveUserIds().stream())); diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/IotDevicePropertyController.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/IotDevicePropertyController.java index caea990..c95d803 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/IotDevicePropertyController.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/device/IotDevicePropertyController.java @@ -6,10 +6,10 @@ import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.module.iot.controller.admin.device.vo.property.IotDevicePropertyDetailRespVO; import com.viewsh.module.iot.controller.admin.device.vo.property.IotDevicePropertyHistoryListReqVO; import com.viewsh.module.iot.controller.admin.device.vo.property.IotDevicePropertyRespVO; +import com.viewsh.module.iot.dal.dataobject.thingmodel.model.ThingModelProperty; import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO; import com.viewsh.module.iot.dal.dataobject.device.IotDevicePropertyDO; import com.viewsh.module.iot.dal.dataobject.thingmodel.IotThingModelDO; -import com.viewsh.module.iot.dal.dataobject.thingmodel.model.ThingModelProperty; import com.viewsh.module.iot.enums.thingmodel.IotThingModelTypeEnum; import com.viewsh.module.iot.service.device.IotDeviceService; import com.viewsh.module.iot.service.device.property.IotDevicePropertyService; diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/ota/IotOtaTaskController.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/ota/IotOtaTaskController.java index ed148f1..aa6dca3 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/ota/IotOtaTaskController.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/admin/ota/IotOtaTaskController.java @@ -3,9 +3,9 @@ package com.viewsh.module.iot.controller.admin.ota; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.framework.common.pojo.PageResult; import com.viewsh.framework.common.util.object.BeanUtils; -import com.viewsh.module.iot.controller.admin.ota.vo.task.IotOtaTaskCreateReqVO; import com.viewsh.module.iot.controller.admin.ota.vo.task.IotOtaTaskPageReqVO; import com.viewsh.module.iot.controller.admin.ota.vo.task.IotOtaTaskRespVO; +import com.viewsh.module.iot.controller.admin.ota.vo.task.IotOtaTaskCreateReqVO; import com.viewsh.module.iot.dal.dataobject.ota.IotOtaTaskDO; import com.viewsh.module.iot.service.ota.IotOtaTaskService; import io.swagger.v3.oas.annotations.Operation; diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/device/IotDeviceDO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/device/IotDeviceDO.java index e24de8e..2eca5bc 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/device/IotDeviceDO.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/device/IotDeviceDO.java @@ -2,17 +2,14 @@ package com.viewsh.module.iot.dal.dataobject.device; import com.viewsh.framework.mybatis.core.type.LongSetTypeHandler; import com.viewsh.framework.tenant.core.db.TenantBaseDO; -import com.viewsh.module.iot.core.enums.IotDeviceStateEnum; import com.viewsh.module.iot.dal.dataobject.ota.IotOtaFirmwareDO; import com.viewsh.module.iot.dal.dataobject.product.IotProductDO; +import com.viewsh.module.iot.core.enums.IotDeviceStateEnum; import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -127,9 +124,11 @@ public class IotDeviceDO extends TenantBaseDO { */ private String deviceSecret; /** - * 认证类型(如一机一密、动态注册) + * 认证类型 + *

+ * 如果为空,则继承产品的 authType + * 枚举 {@link com.viewsh.module.iot.core.enums.IotAuthTypeEnum} */ - // TODO @haohao:是不是要枚举哈 private String authType; /** diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/device/IotDeviceGroupDO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/device/IotDeviceGroupDO.java index 7ba7e2f..3f91e73 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/device/IotDeviceGroupDO.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/device/IotDeviceGroupDO.java @@ -4,10 +4,7 @@ import com.viewsh.framework.mybatis.core.dataobject.BaseDO; import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; /** * IoT 设备分组 DO diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/product/IotProductCategoryDO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/product/IotProductCategoryDO.java index 908249b..853f931 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/product/IotProductCategoryDO.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/product/IotProductCategoryDO.java @@ -4,10 +4,7 @@ import com.viewsh.framework.mybatis.core.dataobject.BaseDO; import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; /** * IoT 产品分类 DO diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/product/IotProductDO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/product/IotProductDO.java index 4ce4530..5e30272 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/product/IotProductDO.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/product/IotProductDO.java @@ -4,10 +4,7 @@ import com.viewsh.framework.tenant.core.db.TenantBaseDO; import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; /** * IoT 产品 DO @@ -87,4 +84,17 @@ public class IotProductDO extends TenantBaseDO { */ private String codecType; + /** + * 认证类型(认证策略) + *

+ * 默认认证方式,可被设备级配置覆盖 + * 枚举 {@link com.viewsh.module.iot.core.enums.IotAuthTypeEnum} + */ + private String authType; + + /** + * 产品密钥 (一型一密认证需要) + */ + private String productSecret; + } \ No newline at end of file diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceService.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceService.java index ae2bef0..623fe64 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceService.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceService.java @@ -131,6 +131,19 @@ public interface IotDeviceService { */ IotDeviceDO getDeviceFromCache(String productKey, String deviceName); + /** + * 【缓存】根据设备名称获得设备信息(仅通过设备名称查询) + *

+ * 注意:该方法会忽略租户信息,所以调用时,需要确认会不会有跨租户访问的风险!!! + *

+ * 此方法主要用于 JT808 等协议,设备在注册时只知道终端手机号(deviceName), + * 不知道 productKey。对于 JT808 协议,终端手机号应该是全局唯一的。 + * + * @param deviceName 设备名称(如 JT808 的终端手机号) + * @return 设备信息,未找到返回 null + */ + IotDeviceDO getDeviceFromCacheByDeviceName(String deviceName); + /** * 获得设备分页 * diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceServiceImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceServiceImpl.java index 61fff09..ad7ef73 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceServiceImpl.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceServiceImpl.java @@ -13,6 +13,7 @@ import com.viewsh.framework.tenant.core.aop.TenantIgnore; import com.viewsh.framework.tenant.core.util.TenantUtils; import com.viewsh.module.iot.controller.admin.device.vo.device.*; import com.viewsh.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import com.viewsh.module.iot.core.enums.IotAuthTypeEnum; import com.viewsh.module.iot.core.enums.IotDeviceStateEnum; import com.viewsh.module.iot.core.util.IotDeviceAuthUtils; import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO; @@ -258,6 +259,13 @@ public class IotDeviceServiceImpl implements IotDeviceService { return deviceMapper.selectByProductKeyAndDeviceName(productKey, deviceName); } + @Override + @Cacheable(value = RedisKeyConstants.DEVICE, key = "'deviceName_' + #deviceName", unless = "#result == null") + @TenantIgnore // 忽略租户信息,用于 JT808 等协议,终端手机号应该是全局唯一的 + public IotDeviceDO getDeviceFromCacheByDeviceName(String deviceName) { + return deviceMapper.selectByDeviceName(deviceName); + } + @Override public PageResult getDevicePage(IotDevicePageReqVO pageReqVO) { return deviceMapper.selectPage(pageReqVO); @@ -398,9 +406,31 @@ public class IotDeviceServiceImpl implements IotDeviceService { @Override public IotDeviceAuthInfoRespVO getDeviceAuthInfo(Long id) { IotDeviceDO device = validateDeviceExists(id); + + // 获取生效的认证类型 + String effectiveAuthType = device.getAuthType(); + String targetSecret = device.getDeviceSecret(); + + if (StrUtil.isEmpty(effectiveAuthType)) { + IotProductDO product = productService.getProductFromCache(device.getProductId()); + if (product != null) { + effectiveAuthType = product.getAuthType(); + // 如果是产品级一型一密,需要获取 ProductSecret + if (IotAuthTypeEnum.PRODUCT_SECRET.getType().equals(effectiveAuthType)) { + targetSecret = product.getProductSecret(); + } + } + } else if (IotAuthTypeEnum.PRODUCT_SECRET.getType().equals(effectiveAuthType)) { + // 如果设备级强制设为一型一密(罕见但逻辑上兼容) + IotProductDO product = productService.getProductFromCache(device.getProductId()); + if (product != null) { + targetSecret = product.getProductSecret(); + } + } + // 使用 IotDeviceAuthUtils 生成认证信息 IotDeviceAuthUtils.AuthInfo authInfo = IotDeviceAuthUtils.getAuthInfo( - device.getProductKey(), device.getDeviceName(), device.getDeviceSecret()); + device.getProductKey(), device.getDeviceName(), targetSecret); return BeanUtils.toBean(authInfo, IotDeviceAuthInfoRespVO.class); } @@ -455,13 +485,68 @@ public class IotDeviceServiceImpl implements IotDeviceService { String deviceName = deviceInfo.getDeviceName(); String productKey = deviceInfo.getProductKey(); IotDeviceDO device = getSelf().getDeviceFromCache(productKey, deviceName); + + // 1.1 处理动态注册 (Dynamic Registration) + if (device == null) { + // 获取产品信息 + IotProductDO product = productService.getProductByProductKey(productKey); + if (product != null && IotAuthTypeEnum.DYNAMIC.getType().equals(product.getAuthType())) { + // 自动创建设备 + IotDeviceSaveReqVO createReq = new IotDeviceSaveReqVO() + .setProductId(product.getId()) + .setDeviceName(deviceName); + try { + // TODO: 考虑并发问题,这里简单处理 + getSelf().createDevice(createReq); + // 重新获取设备 + device = getSelf().getDeviceFromCache(productKey, deviceName); + log.info("[authDevice][动态注册设备成功: {}/{}]", productKey, deviceName); + } catch (Exception e) { + log.error("[authDevice][动态注册设备失败: {}/{}]", productKey, deviceName, e); + return false; + } + } + } + if (device == null) { log.warn("[authDevice][设备({}/{}) 不存在]", productKey, deviceName); return false; } // 2. 校验密码 - IotDeviceAuthUtils.AuthInfo authInfo = IotDeviceAuthUtils.getAuthInfo(productKey, deviceName, device.getDeviceSecret()); + // 2.1 获取生效的认证类型 + String effectiveAuthType = device.getAuthType(); + if (StrUtil.isEmpty(effectiveAuthType)) { + IotProductDO product = productService.getProductFromCache(device.getProductId()); + if (product != null) { + effectiveAuthType = product.getAuthType(); + } + } + + // 2.2 根据类型校验 + String targetSecret = device.getDeviceSecret(); // 默认为一机一密 + + if (IotAuthTypeEnum.PRODUCT_SECRET.getType().equals(effectiveAuthType)) { + // 一型一密:使用 ProductSecret + IotProductDO product = productService.getProductFromCache(device.getProductId()); + if (product == null || StrUtil.isEmpty(product.getProductSecret())) { + log.error("[authDevice][一型一密认证失败,ProductSecret 为空: {}]", productKey); + return false; + } + targetSecret = product.getProductSecret(); + } else if (IotAuthTypeEnum.NONE.getType().equals(effectiveAuthType)) { + // 免鉴权:直接通过 + return true; + } else if (IotAuthTypeEnum.DYNAMIC.getType().equals(effectiveAuthType)) { + // 动态注册后,通常转为一机一密或一型一密,这里假设动态注册使用一型一密校验 + // 或者是免鉴权。具体策略视业务而定。这里暂定为一型一密。 + IotProductDO product = productService.getProductFromCache(device.getProductId()); + if (product != null) { + targetSecret = product.getProductSecret(); + } + } + + IotDeviceAuthUtils.AuthInfo authInfo = IotDeviceAuthUtils.getAuthInfo(productKey, deviceName, targetSecret); if (ObjUtil.notEqual(authInfo.getPassword(), authReqDTO.getPassword())) { log.error("[authDevice][设备({}/{}) 密码不正确]", productKey, deviceName); return false; diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/message/IotDeviceMessageServiceImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/message/IotDeviceMessageServiceImpl.java index b671bef..8ee6e2e 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/message/IotDeviceMessageServiceImpl.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/message/IotDeviceMessageServiceImpl.java @@ -169,10 +169,13 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { getSelf().createDeviceLogAsync(message); // 3. 回复消息。前提:非 _reply 消息,并且非禁用回复的消息 + // 条件1:防止对"回复消息"再次回复,避免无限循环 + // 条件2:某些特定的method不需要回复(如设备状态变更、OTA进度上报) + // 条件3(新增):HTTP短连接场景,因为已经在请求中直接响应了,不需要再通过消息总线发送回复 if (IotDeviceMessageUtils.isReplyMessage(message) || IotDeviceMessageMethodEnum.isReplyDisabled(message.getMethod()) || StrUtil.isEmpty(message.getServerId())) { - return; + return; // serverId 为空,不记录回复消息 } try { IotDeviceMessage replyMessage = IotDeviceMessage.replyOf(message.getRequestId(), message.getMethod(), replyData, diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/product/IotProductServiceImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/product/IotProductServiceImpl.java index 624bff5..6c38057 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/product/IotProductServiceImpl.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/product/IotProductServiceImpl.java @@ -1,6 +1,7 @@ package com.viewsh.module.iot.service.product; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.IdUtil; import com.viewsh.framework.common.pojo.PageResult; import com.viewsh.framework.common.util.object.BeanUtils; import com.viewsh.framework.tenant.core.aop.TenantIgnore; @@ -53,7 +54,8 @@ public class IotProductServiceImpl implements IotProductService { // 2. 插入 IotProductDO product = BeanUtils.toBean(createReqVO, IotProductDO.class) - .setStatus(IotProductStatusEnum.UNPUBLISHED.getStatus()); + .setStatus(IotProductStatusEnum.UNPUBLISHED.getStatus()) + .setProductSecret(IdUtil.fastSimpleUUID()); // 生成 ProductSecret productMapper.insert(product); return product.getId(); } diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/resources/mapper/device/IotDeviceMessageMapper.xml b/viewsh-module-iot/viewsh-module-iot-server/src/main/resources/mapper/device/IotDeviceMessageMapper.xml index 5f5224d..7c79f7a 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/resources/mapper/device/IotDeviceMessageMapper.xml +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/resources/mapper/device/IotDeviceMessageMapper.xml @@ -38,7 +38,7 @@ USING device_message TAGS (#{deviceId}) VALUES ( - NOW, #{id}, #{reportTime}, #{tenantId}, #{serverId}, + #{reportTime}, #{id}, #{reportTime}, #{tenantId}, #{serverId}, #{upstream}, #{reply}, #{identifier}, #{requestId}, #{method}, #{params}, #{data}, #{code}, #{msg} )