diff --git a/RuoYi-Vue/docs/mqtt_removal_plan.md b/RuoYi-Vue/docs/mqtt_removal_plan.md new file mode 100644 index 000000000..b59775096 --- /dev/null +++ b/RuoYi-Vue/docs/mqtt_removal_plan.md @@ -0,0 +1,276 @@ +# 从项目移除 MQTT——文件级改造清单 + +> **适用范围**:RuoYi-Vue(`railwaysystem`)全部模块 +> **方案定位**:方案 A(推荐)——只拆除"MQTT 传输 + 订阅回调 + 调度 + 留痕",保留数据层、协议构造层与 HTTP 能力 +> **红线约束**:报到机**人脸采集**与**门锁入住** HTTP 链路必须完整保留 +> **编制时间**:2026-11-10 + +--- + +## 目录 + +- [0. 关键前提](#0-关键前提) +- [1. 严禁删除、严禁修改的保留项](#1-严禁删除严禁修改的保留项) + - [1.1 报到机 HTTP 链路(红线)](#11-报到机-http-链路红线) + - [1.2 人脸能力(HTTP,非 MQTT)](#12-人脸能力http非-mqtt) + - [1.3 门锁主数据与 DB 层](#13-门锁主数据与-db-层) + - [1.4 状态机与协议构造层](#14-状态机与协议构造层) + - [1.5 HTTP 开放接口](#15-http-开放接口) + - [1.6 容易误删的隐形依赖](#16-容易误删的隐形依赖) +- [2. 需删除的文件(36 个)](#2-需删除的文件36-个) +- [3. 需修改的文件(7 个)](#3-需修改的文件7-个) +- [4. 改造后行为说明](#4-改造后行为说明) +- [5. 方案 B(彻底清理,仅供评估)](#5-方案-b彻底清理仅供评估) +- [6. 执行顺序建议](#6-执行顺序建议) + +--- + +## 0. 关键前提 + +**不能直接删除 `ruoyi-iot` 模块。** 该模块除 MQTT 外还承载三类被强依赖的能力: + +| 能力 | 被谁依赖 | 说明 | +|---|---|---| +| 报到机 HTTP(人脸采集 / 门锁入住) | `ruoyi-admin` | 控制器直接 `import com.ruoyi.iot.lock.*`(`LockDeviceMapper`、`ILockAuthService`、`LockAuth`) | +| 人脸特征提取(HTTP 调外部服务) | `ruoyi-train` | `TrainStudentServiceImpl` 使用 `FaceExtractClient`、`LockFace`、`LockAuthMapper` | +| 门锁主数据 + HTTP 开放接口 | `ruoyi-admin`、`ruoyi-train` | 设备/网关/分组/授权/开门记录 CRUD、`/openapi/**` | + +**结论**:采用方案 A——仅移除 MQTT 传输侧,保留数据层、协议构造层、开放接口。授权/设备/网关三个业务服务**无需改动**。 + +--- + +## 1. 严禁删除、严禁修改的保留项 + +### 1.1 报到机 HTTP 链路(红线) + +| 文件 | 说明 | +|---|---| +| `ruoyi-admin/.../controller/api/TrainRegisterApiController.java` | 人脸采集、门锁入住(`callStayCheckin`)、报到重置;**方案 A 下零改动** | +| `ruoyi-admin/.../controller/api/TrainFaceApiController.java` | 被报到机控制器复用(`deleteEmployee` 解绑人脸) | +| `ruoyi-train/.../integration/lock/client/TrainLockStayApiClient.java` | HTTP 门锁入住/退宿开放平台客户端 | +| `ruoyi-train/.../integration/lock/dto/LockStayCheckinResult.java` | 入住结果 DTO | +| `ruoyi-train/.../integration/lock/dto/LockAuthBridgeResult.java` | 授权桥接结果 DTO | + +> `TrainRegisterApiController` 依赖的 `ILockAuthService`、`LockDeviceMapper` 在本方案中均保留,因此该文件**不需要任何改动**。 + +### 1.2 人脸能力(HTTP,非 MQTT) + +``` +ruoyi-iot/.../lock/remote/FaceExtractClient.java +ruoyi-iot/.../config/FaceExtractProperties.java +ruoyi-iot/.../lock/domain/LockFace.java +ruoyi-iot/.../lock/mapper/LockFaceMapper.java (+ resources/mapper/lock/LockFaceMapper.xml) +ruoyi-iot/.../lock/service/ILockFaceService.java +ruoyi-iot/.../lock/service/impl/LockFaceServiceImpl.java +ruoyi-iot/.../lock/controller/LockFaceController.java +ruoyi-train/.../service/impl/TrainStudentServiceImpl.java +ruoyi-train/.../mapper/TrainStudentMapper.java +``` + +### 1.3 门锁主数据与 DB 层 + +``` +lock/domain/LockDevice.java LockGateway.java LockGroup.java LockAuth.java + LockAccessLog.java ExecutionRecord.java LockServiceConfig.java +lock/mapper/LockDeviceMapper.java LockGatewayMapper.java LockGroupMapper.java + LockAuthMapper.java LockAccessLogMapper.java LockServiceConfigMapper.java +resources/mapper/lock/Lock{Device,Gateway,Group,Auth,AccessLog,ServiceConfig}Mapper.xml +lock/service/ILock{Auth,Device,Gateway,Group,AccessLog,ServiceConfig}Service.java + impl +lock/controller/Lock{Auth,Device,Gateway,Group,AccessLog,ServiceConfig}Controller.java +``` + +> 三个业务实现类(`LockAuthServiceImpl`、`LockDeviceServiceImpl`、`LockGatewayServiceImpl`)只间接依赖命令服务,本方案中**不改**。 + +### 1.4 状态机与协议构造层 + +``` +lock/security/RealIotSecurityGate.java (无任何 MQTT 引用,被 train 授权桥接直接用) +lock/security/ExecutionStates.java +lock/service/LockExecutionTransactions.java +lock/LockConstants.java +lock/protocol/YdlinkCodec.java YdlinkFrameDTO.java YdlinkOp.java + YdlinkTopicBuilder.java YdlinkTopicDTO.java YdlinkTopicType.java + LockAuthPayloadFactory.java +``` + +**原因**:上述协议类被 `LockAuthServiceImpl`、`LockDeviceServiceImpl`、`LockGatewayServiceImpl` 用于生成 `lock_command.payload/topic` 并落库(outbox)。删除会连带 4 个业务类和大量测试改动,违背最小改动原则。 + +### 1.5 HTTP 开放接口 + +``` +ruoyi-iot/.../openapi/**(OpenAuthApiController、OpenFaceApiController、OpenQueryApiController、 + OpenApiAuthFilter、OpenApiProperties、Open*Request) +``` + +### 1.6 容易误删的隐形依赖 + +| 文件 | 为什么不能删 | +|---|---| +| `ruoyi-iot/.../lock/task/LockScheduleConfig.java` | 全项目**唯一**的 `@EnableScheduling`;`ruoyi-train` 的 `DmsOperationPoller`(`@Scheduled`)依赖它,删除会静默停掉 DMS 轮询 | + +--- + +## 2. 需删除的文件(36 个) + +### 2.1 MQTT 连接与传输核心(13) + +| 文件 | 删除理由 | +|---|---| +| `config/IotMqttConfig.java` | MQTT 客户端 Bean 装配与生命周期初始化 | +| `config/IotMqttProperties.java` | `iot.mqtt.*` 全部配置项 | +| `core/MqttClientManager.java` | 连接、重连、健康检查、订阅恢复、发布 | +| `core/MqttCallbackHandler.java` | Paho 回调入口 | +| `core/MqttConnectionCallback.java` | 连接状态回调接口 | +| `core/MqttMessageDispatcher.java` | 上行消息分发 | +| `core/IMqttMessageListener.java` | 业务监听器 SPI | +| `domain/MqttMessageDTO.java` | 消息载体 | +| `domain/MqttPublishRequest.java` | 发布请求 DTO | +| `event/MqttMessageEvent.java` | 消息事件(无其他监听者) | +| `service/IMqttService.java` | 发布/订阅抽象 | +| `service/impl/MqttServiceImpl.java` | 唯一实现 | +| `controller/IotMqttController.java` | `/iot/mqtt/status|publish|subscribe` 运维接口 | + +### 2.2 订阅与上行回调处理(6) + +| 文件 | 删除理由 | +|---|---| +| `lock/mqtt/LockTopicSubscriber.java` | 启动订阅 5 个 `ydlink` 主题(`ApplicationRunner`) | +| `lock/mqtt/LockMessageListener.java` | 消息路由 + 报文留痕 | +| `lock/mqtt/handler/GatewayOnlineHandler.java` | 上下线回调 | +| `lock/mqtt/handler/CommandRespHandler.java` | 三类回执处理 | +| `lock/mqtt/handler/AccessRecordHandler.java` | 开门记录回调 | +| `lock/mqtt/handler/LockSignalHandler.java` | 信号/电量回调 | + +### 2.3 MQTT 相关调度任务(3) + +| 文件 | 删除理由 | +|---|---| +| `lock/task/LockCommandRetryTask.java` | 依赖 `retryPending → dispatch → publish`,无传输后无意义 | +| `lock/task/GatewayOfflineCheckTask.java` | 依据 MQTT 心跳判定离线,移除后会误判全员离线 | +| `lock/task/LockDeviceOfflineCheckTask.java` | 同上 | + +### 2.4 报文留痕(6) + +``` +lock/domain/LockMqttLog.java +lock/mapper/LockMqttLogMapper.java +resources/mapper/lock/LockMqttLogMapper.xml +lock/service/ILockMqttLogService.java +lock/service/impl/LockMqttLogServiceImpl.java +lock/controller/LockMqttLogController.java +``` + +> DB 表 `lock_mqtt_log` 在仓库内无 DDL,可保留空表或手工 `DROP`(不在本次代码范围)。 + +### 2.5 QA 模拟(1) + +``` +ruoyi-admin/.../controller/tool/QaMockMqttApiController.java +``` + +### 2.6 测试(3) + +``` +ruoyi-iot/src/test/.../lock/mqtt/handler/CommandRespHandlerMockTest.java +ruoyi-iot/src/test/.../lock/security/Round5sExecutionIntegrationTest.java (依赖 MqttClientManager/IMqttService) +ruoyi-iot/src/test/.../lock/security/Round5sEgressIsolationTest.java (以传输层断言为主) +``` + +--- + +## 3. 需修改的文件(7 个) + +### 3.1 `ruoyi-iot/.../lock/service/impl/LockCommandServiceImpl.java`(唯一业务改动) + +| 点位 | 改动 | 目的 | +|---|---|---| +| 第 18 行 | 删除 `import ...IMqttService` | 解除传输依赖 | +| 第 37 行 | 删除 `@Lazy @Autowired IMqttService mqttService` | 同上 | +| 留痕块(约 90-115 行) | 删除 `mqttLogMapper` 字段与 `writeMqttLog` 逻辑 | 留痕层已删除 | +| `dispatch()` 169-174 行 | 去掉 `isConnected()` 判断,改为**直接**置 `STATUS_FAIL` + `errorMsg="TRANSPORT_DISABLED"` 并返回 `null` | 保留断连语义,避免上游状态悬挂 | +| `dispatch()` 180-203 行 | 删除 `publishCommand()` 与 `AWAITING_ACK` 迁移分支,改为 `return false` | 移除发布逻辑 | +| `retryPending()` 206-210 行 | 改为 `return 0`(或随 RetryTask 一并删除) | 无补偿下发 | + +> **替代方案**:若完全不想碰业务文件,可保留 `IMqttService`/`MqttServiceImpl` 作为空实现,但 MQTT 依赖仍在,不满足"移除"目标。二者取其一。 + +### 3.2 `ruoyi-iot/pom.xml` + +- 删除 `org.eclipse.paho:org.eclipse.paho.client.mqttv3` 依赖(第 22-26 行) +- `` 由"iot设备接入(MQTT)"改为"iot设备数据与开放接口" + +### 3.3 根 `pom.xml` + +- 删除 `paho.version` 属性 +- 删除 `dependencyManagement` 中 Paho 条目(第 148-153 行) + +### 3.4 `ruoyi-admin/src/main/resources/application.yml` + +- **整段删除** `iot.mqtt`(第 195-259 行:enabled / mock / broker-url / username / topics / will 等) +- **保留** `iot:` 下其他节点:`hmac`、`openapi`、`face`、`lock_endpoint`、`api_key`、`avatar_profile`、`face_api_key` + +### 3.5 三个环境变体配置 + +``` +application_localhost.yml +application_localhost_24.yml +application_localhost_230.yml +``` + +同样只删 `mqtt:` 子树,保留其余 `iot.*`。 + +### 3.6 `ruoyi-iot/src/test/.../lock/security/RealIotSecurityGateTest.java` + +- 删除使用 `new MqttClientManager(p, null).publish(...)` 的断言(约第 38 行) +- 保留其余门禁策略断言(该类本身保留) + +### 3.7 文档(可选) + +`docs/mqtt_usage_and_status.md` 标注"已移除",或移入归档目录。 + +--- + +## 4. 改造后行为说明 + +| 场景 | 改造后行为 | +|---|---| +| 应用启动 | 不再创建 MQTT 连接、不再订阅主题、无 `iot-mqtt-health` 线程、无连接失败日志 | +| 业务触发授权/开关锁 | 仍生成 `lock_command` 记录,但状态直接落 `FAIL / TRANSPORT_DISABLED`,不产生任何网络请求 | +| 报到机人脸采集 | 完全不变(HTTP + 本地文件 + 人脸服务 HTTP) | +| 报到机门锁入住 | 完全不变(`TrainLockStayApiClient` HTTP) | +| 管理端门锁/网关/分组/人脸页面 | 查询与维护功能保留;"下发/远程控制"类操作返回失败提示 | +| DMS 轮询任务 | 正常(`LockScheduleConfig` 保留) | + +--- + +## 5. 方案 B(彻底清理,仅供评估) + +若要求连同 `lock_command` outbox 一并移除,需追加修改: + +- `LockAuthServiceImpl`、`LockDeviceServiceImpl`、`LockGatewayServiceImpl`:删除命令构造与 `commandService.send(...)` +- `LockCommandServiceImpl`、`ILockCommandService`、`LockCommand`、`LockCommandMapper(+XML)`、`LockCommandController`:整体删除 +- `RealIotSecurityGate`:删除 `requireDispatchAllowed` / payload 校验分支 +- `ruoyi-train/.../TrainIotLockAuthBridgeServiceImpl`:改为仅维护授权记录,不再生成命令 +- 相关测试:`Round5v4KingbaseIntegrationTest`、`Round5v4BrowserFixture`、`TrainIotLockAuthBridgeMockTest` 需重写 +- `protocol/**` 可随之删除 + +**风险**:触及 10+ 业务文件与 5+ 测试,且与保留链路相交(`LockAuthMapper` 仍在授权桥接中使用),回归成本高。 + +--- + +## 6. 执行顺序建议 + +1. 先改 `LockCommandServiceImpl`(去 `IMqttService`、去留痕)——此时传输层仍存在但不被引用 +2. 再删 2.2 订阅/回调、2.3 调度、2.4 留痕、2.1 传输核心、2.5 QA +3. 删依赖(3.2、3.3)与配置(3.4、3.5) +4. 清理测试(2.6、3.6) +5. 验证: + ```bash + mvn -o -pl ruoyi-iot,ruoyi-train,ruoyi-admin -am test-compile + ``` + 启动冒烟后回归三条 HTTP 链路:**人脸上传 → 门锁入住 → 报到重置** + +> **再次提醒**:`LockScheduleConfig`、`protocol/**`、`RealIotSecurityGate`、`openapi/**`、`FaceExtractClient`、`Lock*Mapper` 六类文件**不在删除范围**;删除会直接破坏保留功能或导致其它模块编译失败。 + +--- + +*文档结束。* diff --git a/RuoYi-Vue/docs/mqtt_usage_and_status.md b/RuoYi-Vue/docs/mqtt_usage_and_status.md new file mode 100644 index 000000000..acd111ca5 --- /dev/null +++ b/RuoYi-Vue/docs/mqtt_usage_and_status.md @@ -0,0 +1,259 @@ +# MQTT 使用情况与生效状态分析 + +> **分析对象**:RuoYi-Vue(`railwaysystem`)全部模块 +> **分析时间**:2026-11-10 +> **分析方式**:源码静态梳理 + 运行时探测(进程、端口、Broker 连通性、日志),**未修改任何代码** + +--- + +## 目录 + +- [1. 结论摘要](#1-结论摘要) +- [2. MQTT 功能清单](#2-mqtt-功能清单) + - [2.1 连接与消息通道](#21-连接与消息通道) + - [2.2 上行:设备 → 平台(5 个订阅主题)](#22-上行设备--平台5-个订阅主题) + - [2.3 下行:平台 → 设备](#23-下行平台--设备) + - [2.4 运维与辅助](#24-运维与辅助) +- [3. 相关代码文件清单](#3-相关代码文件清单) +- [4. 生效状态分析](#4-生效状态分析) + - [4.1 配置层](#41-配置层) + - [4.2 运行时证据](#42-运行时证据) + - [4.3 日志证据](#43-日志证据) + - [4.4 断连时的代码行为](#44-断连时的代码行为) + - [4.5 功能可用性对照](#45-功能可用性对照) +- [5. 判断依据与恢复方向](#5-判断依据与恢复方向) + +--- + +## 1. 结论摘要 + +| 维度 | 结论 | +|---|---| +| 配置状态 | **已启用**:`enabled=true`、`mock=false`、`protocol-verified=true`、`real-device-write-enabled=true` | +| 运行状态 | **当前未生效(连接断开)**:应用进程对 Broker 处于 `SYN_SENT`,健康检查每约 30s 重连并持续失败 | +| 消息收发 | **上行不可用**(未订阅,收不到任何上行报文);**下行不可用**(命令派发前即判失败) | +| 代码可用性 | **链路完好**:2026-11-09 日志中 27 次连接成功,并有真实上行报文处理记录 | +| 根因定位 | **环境问题,非代码缺陷**:配置的 Broker `192.168.1.202:1883` 网络不可达;本机 `127.0.0.1:1883` 实际运行着 mosquitto,但配置未指向 | + +> 一句话结论:**MQTT 代码与配置都已就绪,但当前连不上配置的 Broker,所有门禁上下行功能处于停摆状态。** + +--- + +## 2. MQTT 功能清单 + +### 2.1 连接与消息通道 + +| # | 功能点 | 说明 | 关键代码 | +|---|---|---|---| +| 1 | 客户端建立与重连 | 启动异步连接;失败后由 `iot-mqtt-health` 线程每 30s 兜底重连 | `MqttClientManager#connect / startHealthExecutor` | +| 2 | 启动自动订阅 | 5 个上行主题,QoS 1;重连后 `resubscribe()` 恢复订阅 | `MqttClientManager#resubscribe` | +| 3 | 消息分发 | 按主题分发至业务监听器,并发布 `MqttMessageEvent`;单监听器异常隔离 | `MqttMessageDispatcher#dispatch` | +| 4 | 报文留痕 | 收/发报文写入 `lock_mqtt_log`,payload 脱敏为 `[redacted]` | `LockMqttLogServiceImpl` | + +### 2.2 上行:设备 → 平台(5 个订阅主题) + +| # | 功能点 | 主题 | 处理结果 | +|---|---|---|---| +| 5 | 网关上下线 | `ydlink/+/thing/onoff_line` | 更新网关在线状态 | +| 6 | 属性设置响应 | `ydlink/+/thing/property/set_response` | 命令状态机推进 | +| 7 | 远程开关锁响应 | `ydlink/+/thing/action/execute_response` | 命令状态机推进 | +| 8 | 授权批量下发回执 | `ydlink/+/thing/data/batch_report` | 授权命令确认 / 拒绝 | +| 9 | 开门记录上报 | `ydlink/+/thing/event/trigger` | 写入开门记录 | +| 10 | 信号/电量上报 | `ydlink/+/thing/event/trigger`(op=RSSI) | 更新设备信号值 | + +### 2.3 下行:平台 → 设备 + +| # | 功能点 | 触发入口 | 说明 | +|---|---|---|---| +| 11 | 门锁授权下发 / 撤销 | 学员入住、退宿、换宿 | 事务提交后派发(outbox 模式) | +| 12 | 远程开锁 / 关锁 | 管理端门锁操作 | 同上 | +| 13 | 网关属性设置(校时、网络参数) | 管理端网关管理 | 同上 | +| 14 | 命令补偿重发 | `LockCommandRetryTask`(每 60s) | **`retry-task-enabled=false`,当前不执行** | +| 15 | 派发安全门禁 | `RealIotSecurityGate` | 环境隔离、SN 白名单、协议审批、真实写入总开关;不通过则 `POLICY_BLOCKED_NOT_ATTEMPTED` | + +### 2.4 运维与辅助 + +| # | 功能点 | 说明 | +|---|---|---| +| 16 | `/iot/mqtt/status|publish|subscribe` | 仅 `mock=true` 或 `qa-api-enabled=true` 时加载,**当前不加载** | +| 17 | `/qa/mock/**` 模拟接口 | 同上,**当前不可用** | +| 18 | 网关离线判定 | `GatewayOfflineCheckTask`、`LockDeviceOfflineCheckTask`(定时任务,遗嘱消息已关闭) | + +> **不属于 MQTT 的部分**:报到机人脸上传后的"门锁入住"走 HTTP 开放平台(`TrainLockStayApiClient#callStayCheckin`),不受本次断连影响。 + +--- + +## 3. 相关代码文件清单 + +### 3.1 连接核心与配置(`ruoyi-iot`) + +``` +com/ruoyi/iot/config/IotMqttProperties.java +com/ruoyi/iot/config/IotMqttConfig.java +com/ruoyi/iot/core/MqttClientManager.java +com/ruoyi/iot/core/MqttCallbackHandler.java +com/ruoyi/iot/core/MqttConnectionCallback.java +com/ruoyi/iot/core/MqttMessageDispatcher.java +com/ruoyi/iot/core/IMqttMessageListener.java +com/ruoyi/iot/domain/MqttMessageDTO.java +com/ruoyi/iot/domain/MqttPublishRequest.java +com/ruoyi/iot/event/MqttMessageEvent.java +com/ruoyi/iot/service/IMqttService.java +com/ruoyi/iot/service/impl/MqttServiceImpl.java +com/ruoyi/iot/controller/IotMqttController.java +``` + +### 3.2 门禁协议与消息处理 + +``` +com/ruoyi/iot/lock/mqtt/LockTopicSubscriber.java +com/ruoyi/iot/lock/mqtt/LockMessageListener.java +com/ruoyi/iot/lock/mqtt/handler/GatewayOnlineHandler.java +com/ruoyi/iot/lock/mqtt/handler/CommandRespHandler.java +com/ruoyi/iot/lock/mqtt/handler/AccessRecordHandler.java +com/ruoyi/iot/lock/mqtt/handler/LockSignalHandler.java +com/ruoyi/iot/lock/protocol/YdlinkCodec.java +com/ruoyi/iot/lock/protocol/YdlinkFrameDTO.java +com/ruoyi/iot/lock/protocol/YdlinkOp.java +com/ruoyi/iot/lock/protocol/YdlinkTopicBuilder.java +com/ruoyi/iot/lock/protocol/YdlinkTopicDTO.java +com/ruoyi/iot/lock/protocol/YdlinkTopicType.java +``` + +### 3.3 门禁业务 + +``` +com/ruoyi/iot/lock/security/RealIotSecurityGate.java +com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java +com/ruoyi/iot/lock/service/impl/LockAuthServiceImpl.java +com/ruoyi/iot/lock/service/impl/LockDeviceServiceImpl.java +com/ruoyi/iot/lock/service/impl/LockGatewayServiceImpl.java +com/ruoyi/iot/lock/service/impl/LockMqttLogServiceImpl.java +com/ruoyi/iot/lock/service/ILockMqttLogService.java +com/ruoyi/iot/lock/domain/LockMqttLog.java +com/ruoyi/iot/lock/mapper/LockMqttLogMapper.java +com/ruoyi/iot/lock/controller/LockMqttLogController.java +com/ruoyi/iot/lock/task/LockCommandRetryTask.java +com/ruoyi/iot/lock/task/GatewayOfflineCheckTask.java +com/ruoyi/iot/lock/task/LockDeviceOfflineCheckTask.java +``` + +### 3.4 业务桥接(`ruoyi-train`) + +``` +com/ruoyi/train/integration/lock/service/impl/TrainIotLockAuthBridgeServiceImpl.java +com/ruoyi/train/integration/lock/service/ITrainLockAuthBridgeService.java +com/ruoyi/train/integration/lock/dto/LockAuthBridgeResult.java +``` + +> 同目录 `client/TrainLockStayApiClient.java`、`dto/LockStayCheckinResult.java` 为 **HTTP** 门锁开放平台调用,不走 MQTT。 + +### 3.5 QA 与测试 + +``` +ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/QaMockMqttApiController.java +ruoyi-iot/src/test/java/...(MqttClientManagerTest、MqttMessageDispatcherTest、LockMessageListenerTest 等) +``` + +### 3.6 配置文件 + +``` +ruoyi-admin/src/main/resources/application.yml # iot.mqtt.*(195-259 行,当前生效) +ruoyi-admin/src/main/resources/application_localhost.yml # broker 指向 202/24 +ruoyi-admin/src/main/resources/application_localhost_24.yml +ruoyi-admin/src/main/resources/application_localhost_230.yml +``` + +> 激活 profile 为 `druid`,其 `application-druid.yml` 无 `iot.mqtt` 覆盖项,最终以 `application.yml` 为准。 + +--- + +## 4. 生效状态分析 + +### 4.1 配置层 + +```yaml +mqtt: + enabled: true # 创建连接 + mock: false # 真实收发 + protocol-verified: true # 允许真实下发 + real-device-write-enabled: true + environment-id: railway-iot-01 + retry-task-enabled: false # 补偿任务关闭 + broker-url: tcp://192.168.1.202:1883 + username: admin3 + password: admin3 + automatic-reconnect: true + health-check-interval: 30000 + topics: 5 个上行主题(QoS 1) +``` + +### 4.2 运行时证据 + +| 检查项 | 结果 | 含义 | +|---|---|---| +| 应用进程 | `java.exe` PID 26688 监听 `0.0.0.0:8096` | 应用正在运行 | +| MQTT socket | 对 `192.168.1.202:1883` 处于 **`SYN_SENT`** | TCP 连接未建立,正在重试 | +| Broker 连通性 | `Test-NetConnection 192.168.1.202:1883` → TCP 失败、Ping 超时 | Broker 不可达 | +| 本机 1883 | `127.0.0.1:1883` 由 `mosquitto.exe`(PID 6032)监听 | 本地有可用 Broker,配置未指向 | + +### 4.3 日志证据 + +| 日志 | 证据 | +|---|---| +| `logs/sys-error.log` | `[iot-mqtt-health] ERROR MqttClientManager - MQTT 连接失败, broker=tcp://192.168.1.202:1883`,累计 **448 次**,间隔约 30s;今日 13:52:50 仍有新记录 | +| `logs/sys-info.2026-11-09.log` | `MQTT 连接成功` **27 次**;含真实上行处理(网关状态更新、开门记录落库) | +| `logs/sys-info.log`(今日) | 无任何 MQTT 行 —— 今日连接从未成功,失败仅写 error 日志 | + +### 4.4 断连时的代码行为 + +```java +// LockCommandServiceImpl#dispatch +if (!mqttService.isConnected()) { + log.warn("[TRACE-DISPATCH] cid={} isConnected=false -> TRANSPORT_DISCONNECTED", commandId); + command.setCommandStatus(LockConstants.STATUS_FAIL); + command.setErrorMsg("TRANSPORT_DISCONNECTED_NOT_ATTEMPTED"); + commandMapper.updateCommand(command); + return null; +} +``` + +断连时命令**在发送前即被标记失败**,不会进入 `AWAITING_ACK`,因此不存在"平台显示已下发、设备未收到"的假成功。 + +### 4.5 功能可用性对照 + +| 功能 | 状态 | 依据 | +|---|---|---| +| MQTT 连接 | ❌ 未连接 | `SYN_SENT` + 448 次失败日志 + Broker 不可达 | +| 上行 5 个主题 | ❌ 无订阅 | 订阅在 `connect()` 成功后执行,当前未连接 | +| 网关状态 / 开门记录 / 回执处理 | ❌ 停止 | 依赖上行报文 | +| 授权下发 / 远程开关锁 / 网关设置 | ❌ 直接失败 | `isConnected=false` → `STATUS_FAIL` | +| 命令补偿重发 | ❌ 不执行 | `retry-task-enabled=false` | +| `/iot/mqtt/status`、`/qa/mock/**` | ❌ 未加载 | `@ConditionalOnProperty` 条件不满足,**无法用接口自证状态** | +| 报到机人脸采集与门锁入住(HTTP) | ✅ 正常 | 走 HTTP 开放平台,与 MQTT 无关 | + +--- + +## 5. 判断依据与恢复方向 + +### 5.1 判断依据 + +1. **配置齐备**:开关、Mock、协议审批、真实写入、白名单均已就位 → 具备生效条件。 +2. **运行证据否证**:应用存活但 TCP 未连通,健康检查持续失败 → 连接未建立。 +3. **产物证据缺失**:无订阅即无上行;命令侧有明确的失败状态码 → 收发均不可用。 +4. **历史反证代码无缺陷**:曾出现 27 次连接成功与真实报文处理 → 问题在环境与配置指向,而非实现。 + +### 5.2 恢复方向(无需改代码) + +1. 排查 `192.168.1.202:1883`:主机连通性、防火墙、Broker 进程、网段隔离; +2. 若现场已有本机 Broker,将 `iot.mqtt.broker-url` 改为 `tcp://127.0.0.1:1883`,并核对 `username/password`; +3. 恢复后按序验证: + - 日志出现 `MQTT 连接成功` + - 5 个主题订阅成功 + - 收到网关 `onoff_line` 上行 + - 对白名单内 SN 下发一次授权,确认回执进入 `CONFIRMED` +4. 如需离线补偿能力,再评估开启 `retry-task-enabled=true`(会周期性重发至真实设备,属生产变更,需审批)。 + +--- + +*文档结束。如需补充时序图或按模块拆分,可在此基础上扩展。* diff --git a/RuoYi-Vue/pom.xml b/RuoYi-Vue/pom.xml index a5a04056a..d153638a5 100644 --- a/RuoYi-Vue/pom.xml +++ b/RuoYi-Vue/pom.xml @@ -32,7 +32,6 @@ 2.3.1 3.1.0 9.0.0 - 1.2.5 @@ -145,13 +144,6 @@ ${kingbase.version} - - - org.eclipse.paho - org.eclipse.paho.client.mqttv3 - ${paho.version} - - com.ruoyi diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/QaMockMqttApiController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/QaMockMqttApiController.java deleted file mode 100644 index 8c9331138..000000000 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/QaMockMqttApiController.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.ruoyi.web.controller.tool; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Date; -import java.util.List; -import java.util.Map; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.security.access.prepost.PreAuthorize; -import com.alibaba.fastjson2.JSON; -import com.ruoyi.common.core.domain.AjaxResult; -import com.ruoyi.iot.domain.MqttMessageDTO; -import com.ruoyi.iot.lock.protocol.YdlinkFrameDTO; -import com.ruoyi.iot.service.IMqttService; - -/** - * 独立 QA 测试环境专属:Mock MQTT 消息检查与设备回执驱动 Controller - * 仅在配置 train.iot.mock-mode=true 时加载,且必须经过管理员权限校验,禁止生产泄露与匿名调用 - */ -@RestController -@RequestMapping("/qa/mock") -@ConditionalOnProperty(name = {"iot.mqtt.mock", "iot.qa-api-enabled"}, havingValue = "true") -@PreAuthorize("@ss.hasRole('admin') or @ss.hasPermi('iot:command:edit')") -public class QaMockMqttApiController -{ - @Autowired - private IMqttService mqttService; - - @Autowired - private com.ruoyi.iot.lock.service.ILockCommandService commandService; - - /** - * 获取所有拦截到的下行指令 - */ - @GetMapping("/published") - public AjaxResult getPublishedMessages() - { - return AjaxResult.success(mqttService.getMockPublishedMessages()); - } - - /** - * 清空已拦截的下行指令 - */ - @PostMapping("/clear") - public AjaxResult clearPublishedMessages() - { - mqttService.getMockPublishedMessages().clear(); - return AjaxResult.success("已清空"); - } - - /** - * 模拟注入网关与门锁回执报文 - * - * @param body 参数字典包含: - * groupCode: 分组编号 (如 GRP_FUSION_TEST) - * commandNo: 命令流水号 seq - * deviceSn: 门锁SN (如 LOCK-ZY-101) - * op: 操作类型 (add / delete) - * code: 结果码 (0=门锁成功, 10=网关已接收, 20=门锁执行失败) - * msg: 说明消息 - */ - @PostMapping("/send-ack") - public AjaxResult sendAck(@RequestBody Map body) - { - String groupCode = (String) body.getOrDefault("groupCode", "GRP_FUSION_TEST"); - String commandNo = (String) body.get("commandNo"); - String deviceSn = (String) body.getOrDefault("deviceSn", "LOCK-ZY-101"); - String op = (String) body.getOrDefault("op", "add"); - String code = String.valueOf(body.getOrDefault("code", "0")); - String msg = (String) body.getOrDefault("msg", "mock execution ok"); - - if (commandNo == null || commandNo.isEmpty()) - { - return AjaxResult.error("commandNo (seq) 不能为空"); - } - - String topic = "ydlink/" + groupCode + "/thing/data/batch_report"; - - YdlinkFrameDTO frame = new YdlinkFrameDTO(); - frame.setDevId(groupCode); - frame.setSeq(commandNo); - com.ruoyi.iot.lock.domain.LockCommand command = commandService.selectCommandList(new com.ruoyi.iot.lock.domain.LockCommand()) - .stream().filter(c -> commandNo.equals(c.getCommandNo())).findFirst().orElseThrow(); - com.ruoyi.iot.lock.protocol.YdlinkFrameDTO original = com.ruoyi.iot.lock.protocol.YdlinkCodec.fromJson(command.getPayload()); - if (original == null) return AjaxResult.error("Command payload is invalid"); - groupCode = original.getDevId(); deviceSn = command.getDeviceSn(); - op = String.valueOf(original.getParams().get(0)); - topic = command.getTopic() + "_report"; - frame.setDevId(groupCode); frame.setOp(original.getOp()); - frame.setAt(String.valueOf(System.currentTimeMillis() / 1000L)); - - List records = new ArrayList<>(); - records.add(Arrays.asList(op, deviceSn, code, "person_mock", msg)); - frame.setParams(records); - - String payload = JSON.toJSONString(frame); - mqttService.mockReceive(topic, payload); - - return AjaxResult.success("Mock 回执注入成功", Map.of( - "topic", topic, - "seq", commandNo, - "code", code, - "payload", payload - )); - } -} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml index 65ff76b6e..7d18bc123 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml @@ -176,7 +176,7 @@ iot: #base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>) # 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高) face: - # 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权 + # 必须与 iot.lock-security.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权 environment-id: railway-iot-01 endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData #endpoint: http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData @@ -192,14 +192,14 @@ iot: avatar_profile: http://192.168.1.106:8096 face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU - mqtt: - # 开关,false 时不创建任何连接 + # 门禁写操作安全策略(原 MQTT 配置中与传输无关的策略项,MQTT 已整体移除) + lock-security: + # 策略总开关,false 时禁止任何真实设备写操作 enabled: true mock: false - protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开) - retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false) + protocol-verified: true # 真实协议证据已审批 environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock) - real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开) + real-device-write-enabled: true # 真实写操作总开关(同时约束人脸 HTTP 设备授权) device-whitelist: [ '807104B5', '80745D79','80745D95','80745D6F', '80745DA3','80745D78','80745D89','80745D93', @@ -218,43 +218,3 @@ iot: '80745DA9','80745D91','80745D6B','80745DA6', '80745D81','80745D9E','80745D83','80745DA0', 'E8F7AFF69483'] - # MQTT服务器地址,ssl 场景使用 ssl://host:8883 - broker-url: tcp://192.168.1.202:1883 - #broker-url: tcp://192.168.1.230:1883 - # 客户端ID,同一服务器下必须唯一;为空时自动生成 - client-id: - username: admin3 - password: admin3 - # false 时服务端保留离线消息,需配合固定 client-id - clean-session: true - connection-timeout: 30 - keep-alive-interval: 60 - # 断线自动重连 - automatic-reconnect: true - max-reconnect-delay: 30000 - # 连接健康检查周期(毫秒),0 表示不做定时重连 - health-check-interval: 30000 - # 发布消息等待服务端确认时间(毫秒),0 表示不等待 - publish-timeout: 3000 - # 消息持久化目录,为空使用内存 - persistence-directory: - default-qos: 1 - default-retained: false - # 只订阅上行主题(下行由平台发布,不消费自己发出的指令) - topics: - - topic: ydlink/+/thing/onoff_line # 网关上下线 - qos: 1 - - topic: ydlink/+/thing/property/set_response # 属性设置响应 - qos: 1 - - topic: ydlink/+/thing/action/execute_response # 远程开关锁响应 - qos: 1 - - topic: ydlink/+/thing/data/batch_report # 授权下发结果 - qos: 1 - - topic: ydlink/+/thing/event/trigger # 开门记录上报 - qos: 1 - # 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定) - # will: - # topic: ydlink/status - # payload: offline - # qos: 1 - # retained: true diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application_230.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application_230.yml index 71e641af7..f9ad7b2a5 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/resources/application_230.yml +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application_230.yml @@ -176,7 +176,7 @@ iot: #base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>) # 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高) face: - # 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权 + # 必须与 iot.lock-security.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权 environment-id: railway-iot-01 #endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData endpoint: http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData @@ -191,14 +191,13 @@ iot: enabled: true api-key: please-change-me path-prefix: /openapi/iot - mqtt: - # 开关,false 时不创建任何连接 + # 门禁写操作安全策略(原 MQTT 配置中与传输无关的策略项,MQTT 已整体移除) + lock-security: enabled: true mock: false - protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开) - retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false) - environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock) - real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开) + protocol-verified: true + environment-id: railway-iot-01 + real-device-write-enabled: true device-whitelist: [ '807104B5', '80745D79','80745D95','80745D6F', '80745DA3','80745D78','80745D89','80745D93', @@ -217,43 +216,3 @@ iot: '80745DA9','80745D91','80745D6B','80745DA6', '80745D81','80745D9E','80745D83','80745DA0', 'E8F7AFF69483'] - # MQTT服务器地址,ssl 场景使用 ssl://host:8883 - #broker-url: tcp://192.168.1.202:1883 - broker-url: tcp://192.168.1.230:1883 - # 客户端ID,同一服务器下必须唯一;为空时自动生成 - client-id: - username: admin3 - password: admin3 - # false 时服务端保留离线消息,需配合固定 client-id - clean-session: true - connection-timeout: 30 - keep-alive-interval: 60 - # 断线自动重连 - automatic-reconnect: true - max-reconnect-delay: 30000 - # 连接健康检查周期(毫秒),0 表示不做定时重连 - health-check-interval: 30000 - # 发布消息等待服务端确认时间(毫秒),0 表示不等待 - publish-timeout: 3000 - # 消息持久化目录,为空使用内存 - persistence-directory: - default-qos: 1 - default-retained: false - # 只订阅上行主题(下行由平台发布,不消费自己发出的指令) - topics: - - topic: ydlink/+/thing/onoff_line # 网关上下线 - qos: 1 - - topic: ydlink/+/thing/property/set_response # 属性设置响应 - qos: 1 - - topic: ydlink/+/thing/action/execute_response # 远程开关锁响应 - qos: 1 - - topic: ydlink/+/thing/data/batch_report # 授权下发结果 - qos: 1 - - topic: ydlink/+/thing/event/trigger # 开门记录上报 - qos: 1 - # 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定) - # will: - # topic: ydlink/status - # payload: offline - # qos: 1 - # retained: true diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application_24.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application_24.yml index 2e21def13..fb2fb0bba 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/resources/application_24.yml +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application_24.yml @@ -176,7 +176,7 @@ iot: #base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>) # 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高) face: - # 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权 + # 必须与 iot.lock-security.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权 environment-id: railway-iot-01 #endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData endpoint: http://192.168.106.24:8802/bdws_api/Third/Api/Easen/getFaceFeatureData @@ -191,14 +191,13 @@ iot: avatar_profile: http://192.168.106.24:8096 face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU - mqtt: - # 开关,false 时不创建任何连接 + # 门禁写操作安全策略(原 MQTT 配置中与传输无关的策略项,MQTT 已整体移除) + lock-security: enabled: true mock: false - protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开) - retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false) - environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock) - real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开) + protocol-verified: true + environment-id: railway-iot-01 + real-device-write-enabled: true device-whitelist: [ '807104B5', '80745D79','80745D95','80745D6F', '80745DA3','80745D78','80745D89','80745D93', @@ -217,43 +216,3 @@ iot: '80745DA9','80745D91','80745D6B','80745DA6', '80745D81','80745D9E','80745D83','80745DA0', 'E8F7AFF69483'] - # MQTT服务器地址,ssl 场景使用 ssl://host:8883 - #broker-url: tcp://192.168.1.202:1883 - broker-url: tcp://192.168.106.24:1883 - # 客户端ID,同一服务器下必须唯一;为空时自动生成 - client-id: - username: admin3 - password: admin3 - # false 时服务端保留离线消息,需配合固定 client-id - clean-session: true - connection-timeout: 30 - keep-alive-interval: 60 - # 断线自动重连 - automatic-reconnect: true - max-reconnect-delay: 30000 - # 连接健康检查周期(毫秒),0 表示不做定时重连 - health-check-interval: 30000 - # 发布消息等待服务端确认时间(毫秒),0 表示不等待 - publish-timeout: 3000 - # 消息持久化目录,为空使用内存 - persistence-directory: - default-qos: 1 - default-retained: false - # 只订阅上行主题(下行由平台发布,不消费自己发出的指令) - topics: - - topic: ydlink/+/thing/onoff_line # 网关上下线 - qos: 1 - - topic: ydlink/+/thing/property/set_response # 属性设置响应 - qos: 1 - - topic: ydlink/+/thing/action/execute_response # 远程开关锁响应 - qos: 1 - - topic: ydlink/+/thing/data/batch_report # 授权下发结果 - qos: 1 - - topic: ydlink/+/thing/event/trigger # 开门记录上报 - qos: 1 - # 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定) - # will: - # topic: ydlink/status - # payload: offline - # qos: 1 - # retained: true diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application_localhost.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application_localhost.yml index 16a3b8f98..c9982649e 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/resources/application_localhost.yml +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application_localhost.yml @@ -176,7 +176,7 @@ iot: #base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>) # 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高) face: - # 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权 + # 必须与 iot.lock-security.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权 environment-id: railway-iot-01 endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData #endpoint: http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData @@ -192,14 +192,13 @@ iot: avatar_profile: http://192.168.1.106:8096 face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU - mqtt: - # 开关,false 时不创建任何连接 + # 门禁写操作安全策略(原 MQTT 配置中与传输无关的策略项,MQTT 已整体移除) + lock-security: enabled: true mock: false - protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开) - retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false) - environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock) - real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开) + protocol-verified: true + environment-id: railway-iot-01 + real-device-write-enabled: true device-whitelist: [ '807104B5', '80745D79','80745D95','80745D6F', '80745DA3','80745D78','80745D89','80745D93', @@ -218,43 +217,3 @@ iot: '80745DA9','80745D91','80745D6B','80745DA6', '80745D81','80745D9E','80745D83','80745DA0', 'E8F7AFF69483'] - # MQTT服务器地址,ssl 场景使用 ssl://host:8883 - broker-url: tcp://192.168.1.202:1883 - #broker-url: tcp://192.168.1.230:1883 - # 客户端ID,同一服务器下必须唯一;为空时自动生成 - client-id: - username: admin3 - password: admin3 - # false 时服务端保留离线消息,需配合固定 client-id - clean-session: true - connection-timeout: 30 - keep-alive-interval: 60 - # 断线自动重连 - automatic-reconnect: true - max-reconnect-delay: 30000 - # 连接健康检查周期(毫秒),0 表示不做定时重连 - health-check-interval: 30000 - # 发布消息等待服务端确认时间(毫秒),0 表示不等待 - publish-timeout: 3000 - # 消息持久化目录,为空使用内存 - persistence-directory: - default-qos: 1 - default-retained: false - # 只订阅上行主题(下行由平台发布,不消费自己发出的指令) - topics: - - topic: ydlink/+/thing/onoff_line # 网关上下线 - qos: 1 - - topic: ydlink/+/thing/property/set_response # 属性设置响应 - qos: 1 - - topic: ydlink/+/thing/action/execute_response # 远程开关锁响应 - qos: 1 - - topic: ydlink/+/thing/data/batch_report # 授权下发结果 - qos: 1 - - topic: ydlink/+/thing/event/trigger # 开门记录上报 - qos: 1 - # 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定) - # will: - # topic: ydlink/status - # payload: offline - # qos: 1 - # retained: true diff --git a/RuoYi-Vue/ruoyi-iot/pom.xml b/RuoYi-Vue/ruoyi-iot/pom.xml index a7e0a016c..f7ae6dc50 100644 --- a/RuoYi-Vue/ruoyi-iot/pom.xml +++ b/RuoYi-Vue/ruoyi-iot/pom.xml @@ -12,19 +12,12 @@ ruoyi-iot - iot设备接入(MQTT) + iot设备数据与开放接口 com.h2databaseh2test - - - - org.eclipse.paho - org.eclipse.paho.client.mqttv3 - - com.ruoyi diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotLockSecurityProperties.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotLockSecurityProperties.java new file mode 100644 index 000000000..7c703348c --- /dev/null +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotLockSecurityProperties.java @@ -0,0 +1,97 @@ +package com.ruoyi.iot.config; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 门禁写操作安全策略配置(原 MQTT 配置中与传输无关的策略项,随 MQTT 移除独立保留)。 + *

+ * 用于 {@code RealIotSecurityGate} 的环境隔离、设备白名单、真实写入总开关与协议审批校验, + * 同时服务于人脸服务 HTTP 调用链路({@code RealIotSecurityGate#requireFaceIdentity})。 + * + * @author ruoyi + */ +@Component +@ConfigurationProperties(prefix = "iot.lock-security") +public class IotLockSecurityProperties +{ + /** 策略总开关,false 时禁止任何真实设备写操作 */ + private boolean enabled = false; + + /** 是否为模拟模式(模拟模式下不校验真实环境与白名单) */ + private boolean mock = false; + + /** 真实硬件写入总开关(默认关闭) */ + private boolean realDeviceWriteEnabled = false; + + /** 真实设备 SN 白名单(仅白名单内设备允许写操作) */ + private List deviceWhitelist = new ArrayList(); + + /** 真实下发协议证据是否已审批 */ + private boolean protocolVerified = false; + + /** 真实环境标识,非模拟模式下不得包含 "mock" */ + private String environmentId = "local-mock"; + + public boolean isEnabled() + { + return enabled; + } + + public void setEnabled(boolean enabled) + { + this.enabled = enabled; + } + + public boolean isMock() + { + return mock; + } + + public void setMock(boolean mock) + { + this.mock = mock; + } + + public boolean isRealDeviceWriteEnabled() + { + return realDeviceWriteEnabled; + } + + public void setRealDeviceWriteEnabled(boolean realDeviceWriteEnabled) + { + this.realDeviceWriteEnabled = realDeviceWriteEnabled; + } + + public List getDeviceWhitelist() + { + return deviceWhitelist; + } + + public void setDeviceWhitelist(List deviceWhitelist) + { + this.deviceWhitelist = deviceWhitelist; + } + + public boolean isProtocolVerified() + { + return protocolVerified; + } + + public void setProtocolVerified(boolean protocolVerified) + { + this.protocolVerified = protocolVerified; + } + + public String getEnvironmentId() + { + return environmentId; + } + + public void setEnvironmentId(String environmentId) + { + this.environmentId = environmentId; + } +} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttConfig.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttConfig.java deleted file mode 100644 index 98ccc95ee..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttConfig.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.ruoyi.iot.config; - -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import com.ruoyi.iot.core.IMqttMessageListener; -import com.ruoyi.iot.core.MqttClientManager; -import com.ruoyi.iot.core.MqttMessageDispatcher; -import com.ruoyi.iot.service.IMqttService; -import com.ruoyi.iot.service.impl.MqttServiceImpl; - -/** - * IOT MQTT自动装配 - *

- * Bean 始终注册,是否真正连接由 iot.mqtt.enabled 决定: - * 关闭时 IMqttService 仍可注入,发布命令会落入“失败待重试”,保证依赖方无需条件装配。 - * - * @author ruoyi - */ -@Configuration -@EnableConfigurationProperties(IotMqttProperties.class) -public class IotMqttConfig -{ - /** - * 消息分发器,收集所有 {@link IMqttMessageListener} 实现 - */ - @Bean - public MqttMessageDispatcher mqttMessageDispatcher(ObjectProvider listenerProvider, - ApplicationEventPublisher eventPublisher) - { - return new MqttMessageDispatcher(listenerProvider, eventPublisher); - } - - /** - * 客户端管理器,随容器启动建立连接、销毁时释放资源 - */ - @Bean(initMethod = "start", destroyMethod = "stop") - public MqttClientManager mqttClientManager(IotMqttProperties properties, MqttMessageDispatcher dispatcher, - com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate) - { - return new MqttClientManager(properties, dispatcher, securityGate); - } - - /** - * MQTT服务 - */ - @Bean - public IMqttService mqttService(MqttClientManager clientManager) - { - return new MqttServiceImpl(clientManager); - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttProperties.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttProperties.java deleted file mode 100644 index a82914b38..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/config/IotMqttProperties.java +++ /dev/null @@ -1,404 +0,0 @@ -package com.ruoyi.iot.config; - -import java.io.Serializable; -import java.util.ArrayList; -import java.util.List; -import org.springframework.boot.context.properties.ConfigurationProperties; - -/** - * IOT MQTT连接配置(对应 iot.mqtt.* 配置项) - * - * @author ruoyi - */ -@ConfigurationProperties(prefix = "iot.mqtt") -public class IotMqttProperties implements Serializable -{ - private static final long serialVersionUID = 1L; - - /** 是否启用MQTT客户端 */ - private boolean enabled = false; - - /** 是否启用 Mock MQTT 拦截模式 (QA 环境) */ - private boolean mock = false; - - /** MQTT服务器地址,如 tcp://127.0.0.1:1883、ssl://host:8883 */ - private String brokerUrl = "tcp://192.168.1.202:1883"; - - /** 客户端ID,同一服务器下必须唯一;为空时自动生成 */ - private String clientId; - - /** 用户名 */ - private String username; - - /** 密码 */ - private String password; - - /** 是否清除会话(false 时服务端保留离线消息,需配合固定 clientId) */ - private boolean cleanSession = true; - - /** 连接超时时间(秒) */ - private int connectionTimeout = 30; - - /** 心跳间隔(秒) */ - private int keepAliveInterval = 60; - - /** 是否开启Paho内置自动重连 */ - private boolean automaticReconnect = false; - - /** Paho内置重连的最大退避时间(毫秒) */ - private int maxReconnectDelay = 30000; - - /** 连接健康检查周期(毫秒),小于等于0表示不启用守护重连 */ - private long healthCheckInterval = 30000; - - /** 消息发布等待确认时间(毫秒),0 表示不等待 */ - private long publishTimeout = 3000; - - /** 消息持久化目录,为空时使用内存持久化 */ - private String persistenceDirectory; - - /** 默认消息服务质量 */ - private int defaultQos = 1; - - /** 默认是否保留消息 */ - private boolean defaultRetained = false; - - /** 启动时自动订阅的主题 */ - private List topics = new ArrayList(); - - /** 遗嘱消息 */ - private MqttWill will; - - /** 真实硬件下行写入总开关(硬性安全门禁,默认为 false,禁止发控制指令至真实硬件) */ - private boolean realDeviceWriteEnabled = false; - - /** 真实设备 SN 白名单(仅在白名单内的设备才允许下发控制指令) */ - private List deviceWhitelist = new ArrayList(); - - /** Compatibility property only. Provenance/version isolation is mandatory and cannot be disabled. */ - private boolean replayProtectionEnabled = true; - - /** Default off; only current-origin READY outbox entries can be recovered when explicitly enabled. */ - private boolean retryTaskEnabled = false; - - private String environmentId = "local-mock"; - private boolean protocolVerified = false; - public String getEnvironmentId() { return environmentId; } - public void setEnvironmentId(String value) { environmentId = value; } - public boolean isProtocolVerified() { return protocolVerified; } - public void setProtocolVerified(boolean value) { protocolVerified = value; } - - public boolean isEnabled() - { - return enabled; - } - - public void setEnabled(boolean enabled) - { - this.enabled = enabled; - } - - public boolean isMock() - { - return mock; - } - - public void setMock(boolean mock) - { - this.mock = mock; - } - - public String getBrokerUrl() - { - return brokerUrl; - } - - public void setBrokerUrl(String brokerUrl) - { - this.brokerUrl = brokerUrl; - } - - public String getClientId() - { - return clientId; - } - - public void setClientId(String clientId) - { - this.clientId = clientId; - } - - public String getUsername() - { - return username; - } - - public void setUsername(String username) - { - this.username = username; - } - - public String getPassword() - { - return password; - } - - public void setPassword(String password) - { - this.password = password; - } - - public boolean isCleanSession() - { - return cleanSession; - } - - public void setCleanSession(boolean cleanSession) - { - this.cleanSession = cleanSession; - } - - public int getConnectionTimeout() - { - return connectionTimeout; - } - - public void setConnectionTimeout(int connectionTimeout) - { - this.connectionTimeout = connectionTimeout; - } - - public int getKeepAliveInterval() - { - return keepAliveInterval; - } - - public void setKeepAliveInterval(int keepAliveInterval) - { - this.keepAliveInterval = keepAliveInterval; - } - - public boolean isAutomaticReconnect() - { - return automaticReconnect; - } - - public void setAutomaticReconnect(boolean automaticReconnect) - { - this.automaticReconnect = automaticReconnect; - } - - public int getMaxReconnectDelay() - { - return maxReconnectDelay; - } - - public void setMaxReconnectDelay(int maxReconnectDelay) - { - this.maxReconnectDelay = maxReconnectDelay; - } - - public long getHealthCheckInterval() - { - return healthCheckInterval; - } - - public void setHealthCheckInterval(long healthCheckInterval) - { - this.healthCheckInterval = healthCheckInterval; - } - - public long getPublishTimeout() - { - return publishTimeout; - } - - public void setPublishTimeout(long publishTimeout) - { - this.publishTimeout = publishTimeout; - } - - public String getPersistenceDirectory() - { - return persistenceDirectory; - } - - public void setPersistenceDirectory(String persistenceDirectory) - { - this.persistenceDirectory = persistenceDirectory; - } - - public int getDefaultQos() - { - return defaultQos; - } - - public void setDefaultQos(int defaultQos) - { - this.defaultQos = defaultQos; - } - - public boolean isDefaultRetained() - { - return defaultRetained; - } - - public void setDefaultRetained(boolean defaultRetained) - { - this.defaultRetained = defaultRetained; - } - - public List getTopics() - { - return topics; - } - - public void setTopics(List topics) - { - this.topics = topics; - } - - public MqttWill getWill() - { - return will; - } - - public void setWill(MqttWill will) - { - this.will = will; - } - - public boolean isRealDeviceWriteEnabled() - { - return realDeviceWriteEnabled; - } - - public void setRealDeviceWriteEnabled(boolean realDeviceWriteEnabled) - { - this.realDeviceWriteEnabled = realDeviceWriteEnabled; - } - - public List getDeviceWhitelist() - { - return deviceWhitelist; - } - - public void setDeviceWhitelist(List deviceWhitelist) - { - this.deviceWhitelist = deviceWhitelist; - } - - public boolean isReplayProtectionEnabled() - { - return replayProtectionEnabled; - } - - public void setReplayProtectionEnabled(boolean replayProtectionEnabled) - { - this.replayProtectionEnabled = replayProtectionEnabled; - } - - public boolean isRetryTaskEnabled() - { - return retryTaskEnabled; - } - - public void setRetryTaskEnabled(boolean retryTaskEnabled) - { - this.retryTaskEnabled = retryTaskEnabled; - } - - /** - * 订阅主题配置 - */ - public static class MqttTopic implements Serializable - { - private static final long serialVersionUID = 1L; - - /** 主题,支持 +/# 通配符 */ - private String topic; - - /** 服务质量 */ - private int qos = 1; - - public String getTopic() - { - return topic; - } - - public void setTopic(String topic) - { - this.topic = topic; - } - - public int getQos() - { - return qos; - } - - public void setQos(int qos) - { - this.qos = qos; - } - } - - /** - * 遗嘱消息配置 - */ - public static class MqttWill implements Serializable - { - private static final long serialVersionUID = 1L; - - /** 主题 */ - private String topic; - - /** 消息内容 */ - private String payload; - - /** 服务质量 */ - private int qos = 1; - - /** 是否保留 */ - private boolean retained = true; - - public String getTopic() - { - return topic; - } - - public void setTopic(String topic) - { - this.topic = topic; - } - - public String getPayload() - { - return payload; - } - - public void setPayload(String payload) - { - this.payload = payload; - } - - public int getQos() - { - return qos; - } - - public void setQos(int qos) - { - this.qos = qos; - } - - public boolean isRetained() - { - return retained; - } - - public void setRetained(boolean retained) - { - this.retained = retained; - } - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/controller/IotMqttController.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/controller/IotMqttController.java deleted file mode 100644 index 180b1d25b..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/controller/IotMqttController.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.ruoyi.iot.controller; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import com.ruoyi.common.core.domain.AjaxResult; -import com.ruoyi.common.utils.StringUtils; -import com.ruoyi.iot.core.MqttClientManager; -import com.ruoyi.iot.domain.MqttPublishRequest; -import com.ruoyi.iot.service.IMqttService; - -/** - * MQTT运维接口 - *

- * 用于查看连接状态、发布消息、动态调整订阅与手动重连。 - * 接口受登录认证保护;如需菜单级权限请自行补充 @PreAuthorize 与菜单数据。 - * - * @author ruoyi - */ -@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(name = {"iot.mqtt.mock", "iot.qa-api-enabled"}, havingValue = "true") -@org.springframework.security.access.prepost.PreAuthorize("@ss.hasPermi('iot:mqtt:manage')") -@RestController -@RequestMapping("/iot/mqtt") -public class IotMqttController -{ - @Autowired - private IMqttService mqttService; - - @Autowired - private MqttClientManager mqttClientManager; - - /** - * 连接状态 - */ - @GetMapping("/status") - public AjaxResult status() - { - AjaxResult ajax = AjaxResult.success(); - ajax.put("connected", mqttService.isConnected()); - ajax.put("clientId", mqttClientManager.getClientId()); - ajax.put("topics", mqttService.getSubscriptions()); - return ajax; - } - - /** - * 发布消息 - */ - @PostMapping("/publish") - public AjaxResult publish(@RequestBody MqttPublishRequest request) - { - if (request == null || StringUtils.isEmpty(request.getTopic())) - { - return AjaxResult.error("发布主题不能为空"); - } - mqttService.publish(request.getTopic(), request.getPayload(), request.getQos(), request.getRetained()); - return AjaxResult.success(); - } - - /** - * 订阅主题 - */ - @PostMapping("/subscribe") - public AjaxResult subscribe(@RequestBody MqttPublishRequest request) - { - if (request == null || StringUtils.isEmpty(request.getTopic())) - { - return AjaxResult.error("订阅主题不能为空"); - } - mqttService.subscribe(request.getTopic(), request.getQos()); - return AjaxResult.success(); - } - - /** - * 取消订阅 - */ - @DeleteMapping("/unsubscribe") - public AjaxResult unsubscribe(@RequestParam String topic) - { - if (StringUtils.isEmpty(topic)) - { - return AjaxResult.error("主题不能为空"); - } - mqttService.unsubscribe(topic); - return AjaxResult.success(); - } - - /** - * 手动重连 - */ - @PostMapping("/reconnect") - public AjaxResult reconnect() - { - mqttService.reconnect(); - return AjaxResult.success(mqttService.isConnected() ? "重连成功" : "重连未成功,请检查服务端状态"); - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/IMqttMessageListener.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/IMqttMessageListener.java deleted file mode 100644 index 0a318dde4..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/IMqttMessageListener.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.ruoyi.iot.core; - -import com.ruoyi.iot.domain.MqttMessageDTO; - -/** - * MQTT消息监听器 - *

- * 业务模块实现该接口并注册为 Spring Bean 即可收到设备上报的消息, - * 与具体协议实现解耦。 - * - * @author ruoyi - */ -public interface IMqttMessageListener -{ - /** - * 是否处理该主题的消息(默认全部接收) - * - * @param topic 消息主题 - * @return true 表示由 {@link #onMessage(MqttMessageDTO)} 处理 - */ - default boolean supports(String topic) - { - return true; - } - - /** - * 接收到消息 - * - * @param message 消息内容 - */ - void onMessage(MqttMessageDTO message); -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttCallbackHandler.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttCallbackHandler.java deleted file mode 100644 index b9f1ddd96..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttCallbackHandler.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.ruoyi.iot.core; - -import java.nio.charset.StandardCharsets; -import java.util.Date; -import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; -import org.eclipse.paho.client.mqttv3.MqttCallbackExtended; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import com.ruoyi.iot.domain.MqttMessageDTO; - -/** - * MQTT回调处理器 - *

- * 使用 {@link MqttCallbackExtended} 以便感知自动重连成功事件,及时恢复订阅。 - * - * @author ruoyi - */ -public class MqttCallbackHandler implements MqttCallbackExtended -{ - private static final Logger log = LoggerFactory.getLogger(MqttCallbackHandler.class); - - private final MqttConnectionCallback connectionCallback; - - private final MqttMessageDispatcher dispatcher; - - public MqttCallbackHandler(MqttConnectionCallback connectionCallback, MqttMessageDispatcher dispatcher) - { - this.connectionCallback = connectionCallback; - this.dispatcher = dispatcher; - } - - /** - * 连接建立(首次连接或自动重连) - */ - @Override - public void connectComplete(boolean reconnect, String serverURI) - { - log.info("MQTT 连接建立, serverURI={}, reconnect={}", serverURI, reconnect); - connectionCallback.onConnectComplete(reconnect, serverURI); - } - - /** - * 连接断开 - */ - @Override - public void connectionLost(Throwable cause) - { - log.error("MQTT 连接断开", cause); - connectionCallback.onConnectionLost(cause); - } - - /** - * 收到订阅的消息 - */ - @Override - public void messageArrived(String topic, org.eclipse.paho.client.mqttv3.MqttMessage message) - { - try - { - MqttMessageDTO dto = new MqttMessageDTO(); - dto.setTopic(topic); - dto.setPayloadBytes(message.getPayload()); - dto.setPayload(new String(message.getPayload(), StandardCharsets.UTF_8)); - dto.setQos(message.getQos()); - dto.setRetained(message.isRetained()); - dto.setDuplicate(message.isDuplicate()); - dto.setMessageId(message.getId()); - dto.setTimestamp(new Date()); - dispatcher.dispatch(dto); - } - catch (Exception e) - { - log.error("MQTT 消息转换失败, topic={}", topic, e); - } - } - - /** - * 消息投递完成(QoS>0 的发布收到确认) - */ - @Override - public void deliveryComplete(IMqttDeliveryToken token) - { - try - { - log.debug("MQTT 消息投递完成, messageId={}", token.getMessageId()); - } - catch (Exception e) - { - log.debug("MQTT 消息投递确认信息不完整"); - } - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttClientManager.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttClientManager.java deleted file mode 100644 index 069dc940a..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttClientManager.java +++ /dev/null @@ -1,559 +0,0 @@ -package com.ruoyi.iot.core; - -import java.nio.charset.StandardCharsets; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import org.eclipse.paho.client.mqttv3.MqttClient; -import org.eclipse.paho.client.mqttv3.MqttClientPersistence; -import org.eclipse.paho.client.mqttv3.MqttConnectOptions; -import org.eclipse.paho.client.mqttv3.MqttException; -import org.eclipse.paho.client.mqttv3.MqttMessage; -import org.eclipse.paho.client.mqttv3.MqttPersistenceException; -import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; -import org.eclipse.paho.client.mqttv3.persist.MqttDefaultFilePersistence; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import com.alibaba.fastjson2.JSON; -import com.ruoyi.common.utils.StringUtils; -import com.ruoyi.common.utils.uuid.IdUtils; -import com.ruoyi.iot.config.IotMqttProperties; -import com.ruoyi.iot.config.IotMqttProperties.MqttTopic; - -/** - * MQTT客户端管理器 - *

- * 负责:连接服务器、订阅主题、发布消息、断线重连、资源释放。 - * 重连由三层保障:Paho 内置自动重连、断线回调即时重连、健康检查定时重连。 - * - * @author ruoyi - */ -public class MqttClientManager implements MqttConnectionCallback -{ - private static final Logger log = LoggerFactory.getLogger(MqttClientManager.class); - - private final IotMqttProperties properties; - - private final MqttMessageDispatcher dispatcher; - - private final MqttCallbackHandler callbackHandler; - - /** 已订阅主题与对应的服务质量 */ - private final Map subscriptions = new ConcurrentHashMap(); - - /** 连接互斥锁,避免并发重复建连 */ - private final Object connectLock = new Object(); - - private volatile MqttClient client; - private com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate; - - private volatile String runtimeClientId; - - private volatile ScheduledExecutorService healthExecutor; - - /** Mock 模式下记录的已下发报文 */ - private final java.util.List> mockPublishedMessages = new java.util.concurrent.CopyOnWriteArrayList<>(); - - public MqttClientManager(IotMqttProperties properties, MqttMessageDispatcher dispatcher, - com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate) { - this(properties, dispatcher); this.securityGate = securityGate; - } - - public MqttClientManager(IotMqttProperties properties, MqttMessageDispatcher dispatcher) - { - this.properties = properties; - this.dispatcher = dispatcher; - this.callbackHandler = new MqttCallbackHandler(this, dispatcher); - } - - /** - * 启动(由 Spring 的 initMethod 调用) - *

- * 首次连接异步执行:broker 不可达时 Paho 会阻塞到 connectionTimeout, - * 放到后台线程可以避免拖慢应用启动,失败后由重连机制继续尝试。 - */ - public void start() - { - if (properties.isMock()) - { - log.info("[MOCK-MQTT] 运行于 Mock 拦截模式(iot.mqtt.mock=true),禁用真实 Broker 外部连接"); - return; - } - if (!properties.isEnabled()) - { - log.info("MQTT 未启用(iot.mqtt.enabled=false),跳过连接"); - return; - } - startHealthExecutor(); - ScheduledExecutorService executor = this.healthExecutor; - if (executor != null && !executor.isShutdown()) - { - executor.submit(() -> { - try - { - connect(); - } - catch (Exception e) - { - log.error("MQTT 首次连接失败, broker={}, 将由重连机制继续尝试", properties.getBrokerUrl(), e); - } - }); - return; - } - try - { - connect(); - } - catch (Exception e) - { - log.error("MQTT 首次连接失败, broker={}", properties.getBrokerUrl(), e); - } - } - - /** - * 停止(由 Spring 的 destroyMethod 调用) - */ - public void stop() - { - ScheduledExecutorService executor = this.healthExecutor; - if (executor != null) - { - executor.shutdownNow(); - this.healthExecutor = null; - } - disconnect(); - } - - /** - * 建立连接;已连接时直接返回 - */ - public void connect() - { - if (properties.isMock() || !properties.isEnabled()) throw new SecurityException("Real MQTT networking is disabled"); - synchronized (connectLock) - { - MqttClient current = this.client; - if (current != null && current.isConnected()) - { - return; - } - if (current != null) - { - closeQuietly(current); - this.client = null; - } - try - { - MqttConnectOptions options = buildConnectOptions(); - MqttClient mqttClient = new MqttClient(properties.getBrokerUrl(), resolveClientId(), createPersistence()); - mqttClient.setCallback(callbackHandler); - mqttClient.setTimeToWait(properties.getPublishTimeout()); - mqttClient.connect(options); - this.client = mqttClient; - log.info("MQTT 连接成功, broker={}, clientId={}", properties.getBrokerUrl(), runtimeClientId); - resubscribe(); - } - catch (MqttException e) - { - log.error("MQTT 连接失败, broker={}, clientId={}", properties.getBrokerUrl(), runtimeClientId, e); - throw new IllegalStateException("MQTT连接失败:" + e.getMessage(), e); - } - } - } - - /** - * 断开连接并释放资源 - */ - public void disconnect() - { - synchronized (connectLock) - { - MqttClient current = this.client; - if (current != null) - { - closeQuietly(current); - this.client = null; - } - subscriptions.clear(); - } - } - - /** - * 手动重连 - */ - public void reconnect() - { - if (properties.isMock() || !properties.isEnabled()) throw new SecurityException("Real MQTT reconnect is disabled"); - synchronized (connectLock) - { - MqttClient current = this.client; - if (current != null) - { - closeQuietly(current); - this.client = null; - } - } - connect(); - } - - /** - * 是否已连接 - */ - public boolean isConnected() - { - if (properties.isMock()) - { - return true; - } - MqttClient current = this.client; - return current != null && current.isConnected(); - } - - /** - * 订阅主题 - * - * @param topic 主题,支持 +/# 通配符 - * @param qos 服务质量,为空时取默认配置 - */ - public void subscribe(String topic, Integer qos) - { - if (StringUtils.isEmpty(topic)) - { - throw new IllegalArgumentException("订阅主题不能为空"); - } - int realQos = qos == null ? properties.getDefaultQos() : qos; - try - { - requireClient().subscribe(topic, realQos); - subscriptions.put(topic, realQos); - log.info("MQTT 订阅成功, topic={}, qos={}", topic, realQos); - } - catch (MqttException e) - { - log.error("MQTT 订阅失败, topic={}", topic, e); - throw new IllegalStateException("MQTT订阅失败:" + e.getMessage(), e); - } - } - - /** - * 取消订阅 - * - * @param topic 主题 - */ - public void unsubscribe(String topic) - { - if (StringUtils.isEmpty(topic)) - { - return; - } - try - { - requireClient().unsubscribe(topic); - subscriptions.remove(topic); - log.info("MQTT 取消订阅成功, topic={}", topic); - } - catch (MqttException e) - { - log.error("MQTT 取消订阅失败, topic={}", topic, e); - throw new IllegalStateException("MQTT取消订阅失败:" + e.getMessage(), e); - } - } - - /** - * 发布消息 - * - * @param topic 主题 - * @param payload 消息内容,支持字符串/字节数组/对象 - * @param qos 服务质量,为空时取默认配置 - * @param retained 是否保留,为空时取默认配置 - */ - public void publish(String topic, Object payload, Integer qos, Boolean retained) - { - if (!properties.isMock()) throw new SecurityException("Raw MQTT publication is disabled outside Mock mode"); - if (StringUtils.isEmpty(topic)) throw new IllegalArgumentException("Topic required"); - java.util.Map message = new java.util.LinkedHashMap<>(); - message.put("topic", topic); message.put("payload", payload); message.put("qos", qos); - message.put("time", new java.util.Date()); mockPublishedMessages.add(message); - } - - public void publishCommand(com.ruoyi.iot.lock.domain.LockCommand command) - { - if (securityGate == null) throw new SecurityException("Command security policy is unavailable"); - securityGate.requirePersistedDispatch(command); - if (properties.isMock()) { publish(command.getTopic(), command.getPayload(), 0, false); return; } - MqttMessage message = new MqttMessage(toBytes(command.getPayload())); - // At-most-once control transport. Unknown outcomes require review, never a hidden QoS retry. - message.setQos(0); message.setRetained(false); - try { requireClient().publish(command.getTopic(), message); } - catch (MqttException error) { throw new IllegalStateException("MQTT outcome unknown; manual reconciliation required"); } - } - - /** - * 当前已订阅的主题 - */ - public Map getSubscriptions() - { - return new LinkedHashMap(subscriptions); - } - - /** - * 当前客户端ID - */ - public String getClientId() - { - return runtimeClientId; - } - - /** - * 连接建立回调:重连成功后恢复订阅 - */ - @Override - public void onConnectComplete(boolean reconnect, String serverUri) - { - if (reconnect) - { - log.info("MQTT 重连成功,开始恢复订阅"); - resubscribe(); - } - } - - /** - * 连接断开回调:异步发起一次重连 - */ - @Override - public void onConnectionLost(Throwable cause) - { - submitReconnect(); - } - - /** - * 恢复订阅(配置中的主题 + 运行期动态订阅的主题) - */ - private void resubscribe() - { - MqttClient current = this.client; - if (current == null || !current.isConnected()) - { - return; - } - Map targets = new LinkedHashMap(); - if (properties.getTopics() != null) - { - for (MqttTopic item : properties.getTopics()) - { - if (StringUtils.isNotEmpty(item.getTopic())) - { - targets.put(item.getTopic(), item.getQos()); - } - } - } - targets.putAll(subscriptions); - if (targets.isEmpty()) - { - return; - } - String[] topics = targets.keySet().toArray(new String[0]); - int[] qos = new int[targets.size()]; - int index = 0; - for (Integer value : targets.values()) - { - qos[index++] = value == null ? properties.getDefaultQos() : value; - } - try - { - current.subscribe(topics, qos); - subscriptions.putAll(targets); - log.info("MQTT 订阅恢复成功, topics={}", targets.keySet()); - } - catch (MqttException e) - { - log.error("MQTT 订阅恢复失败", e); - } - } - - /** - * 启动健康检查,用于兜底重连 - */ - private void startHealthExecutor() - { - ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(runnable -> { - Thread thread = new Thread(runnable, "iot-mqtt-health"); - thread.setDaemon(true); - return thread; - }); - this.healthExecutor = executor; - long interval = properties.getHealthCheckInterval(); - if (interval <= 0) - { - return; - } - executor.scheduleWithFixedDelay(() -> { - if (isConnected()) - { - return; - } - log.warn("MQTT 连接不可用,尝试重连, broker={}", properties.getBrokerUrl()); - try - { - connect(); - } - catch (Exception e) - { - log.warn("MQTT 重连失败, broker={}", properties.getBrokerUrl()); - } - }, interval, interval, TimeUnit.MILLISECONDS); - } - - /** - * 提交一次异步重连任务,避免阻塞 Paho 回调线程 - */ - private void submitReconnect() - { - ScheduledExecutorService executor = this.healthExecutor; - if (executor == null || executor.isShutdown()) - { - return; - } - executor.submit(() -> { - try - { - Thread.sleep(1000L); - connect(); - } - catch (Exception e) - { - log.warn("MQTT 连接断开后重连失败, broker={}", properties.getBrokerUrl()); - } - }); - } - - /** - * 构建连接参数 - */ - private MqttConnectOptions buildConnectOptions() - { - MqttConnectOptions options = new MqttConnectOptions(); - if (StringUtils.isNotEmpty(properties.getUsername())) - { - options.setUserName(properties.getUsername()); - } - if (StringUtils.isNotEmpty(properties.getPassword())) - { - options.setPassword(properties.getPassword().toCharArray()); - } - options.setCleanSession(true); - options.setConnectionTimeout(properties.getConnectionTimeout()); - options.setKeepAliveInterval(properties.getKeepAliveInterval()); - options.setAutomaticReconnect(false); - options.setMaxReconnectDelay(properties.getMaxReconnectDelay()); - if (properties.getWill() != null) { - throw new SecurityException("MQTT will messages are disabled: they bypass the command outbox"); - } - return options; - } - - /** - * 消息持久化策略,未配置目录时使用内存 - */ - private MqttClientPersistence createPersistence() - { - if (StringUtils.isNotEmpty(properties.getPersistenceDirectory())) - { - throw new SecurityException("Persisted MQTT packet replay is disabled; recovery belongs to the command outbox"); - } - return new MemoryPersistence(); - } - - /** - * 客户端ID,为空时生成一次并固定,重连时保持不变 - */ - private String resolveClientId() - { - if (runtimeClientId == null) - { - synchronized (connectLock) - { - if (runtimeClientId == null) - { - runtimeClientId = StringUtils.isNotEmpty(properties.getClientId()) - ? properties.getClientId() - : "ruoyi-iot-" + IdUtils.fastSimpleUUID(); - } - } - } - return runtimeClientId; - } - - private MqttClient requireClient() - { - MqttClient current = this.client; - if (current == null || !current.isConnected()) - { - throw new IllegalStateException("MQTT客户端未连接,请检查 broker 配置与网络"); - } - return current; - } - - private void closeQuietly(MqttClient current) - { - try - { - if (current.isConnected()) - { - current.disconnect(); - } - } - catch (Exception e) - { - log.debug("MQTT disconnect 异常(可忽略)"); - } - try - { - current.close(true); - } - catch (Exception e) - { - log.debug("MQTT close 异常(可忽略)"); - } - } - - private byte[] toBytes(Object payload) - { - if (payload == null) - { - return new byte[0]; - } - if (payload instanceof byte[] bytes) - { - return bytes; - } - if (payload instanceof String text) - { - return text.getBytes(StandardCharsets.UTF_8); - } - return JSON.toJSONString(payload).getBytes(StandardCharsets.UTF_8); - } - - /** - * 模拟设备上行回执注入 (用于 QA 与端到端测试驱动) - */ - public void mockReceive(String topic, String payload) - { - if (!properties.isMock()) throw new SecurityException("Mock receipt injection is disabled in real mode"); - log.info("[MOCK-MQTT] 模拟接收设备上行回执: topic={}, payload={}", topic, payload); - com.ruoyi.iot.domain.MqttMessageDTO dto = new com.ruoyi.iot.domain.MqttMessageDTO(topic, payload); - dto.setTimestamp(new java.util.Date()); - dispatcher.dispatch(dto); - } - - /** - * 获取所有拦截到的 Mock 下行消息 - */ - public java.util.List> getMockPublishedMessages() - { - return mockPublishedMessages; - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttConnectionCallback.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttConnectionCallback.java deleted file mode 100644 index 03d1817fe..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttConnectionCallback.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.ruoyi.iot.core; - -/** - * MQTT连接状态回调,由客户端管理器实现,供回调处理器通知连接变化 - * - * @author ruoyi - */ -public interface MqttConnectionCallback -{ - /** - * 连接建立(含自动重连成功) - * - * @param reconnect 是否为重连 - * @param serverUri 服务器地址 - */ - void onConnectComplete(boolean reconnect, String serverUri); - - /** - * 连接断开 - * - * @param cause 断开原因 - */ - void onConnectionLost(Throwable cause); -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttMessageDispatcher.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttMessageDispatcher.java deleted file mode 100644 index 20e1adf7c..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/core/MqttMessageDispatcher.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.ruoyi.iot.core; - -import java.util.Collections; -import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.context.ApplicationEventPublisher; -import com.ruoyi.iot.domain.MqttMessageDTO; -import com.ruoyi.iot.event.MqttMessageEvent; - -/** - * MQTT消息分发器 - *

- * 将收到的消息按主题分发给所有 {@link IMqttMessageListener}, - * 同时发布 {@link MqttMessageEvent} 供其它模块监听。 - * - * @author ruoyi - */ -public class MqttMessageDispatcher -{ - private static final Logger log = LoggerFactory.getLogger(MqttMessageDispatcher.class); - - private final ApplicationEventPublisher eventPublisher; - - private final ObjectProvider listenerProvider; - - /** 业务监听器集合,首次分发时解析(构造期解析会拉起业务Bean,导致与 IMqttService 循环依赖) */ - private volatile List listeners; - - public MqttMessageDispatcher(ObjectProvider listenerProvider, - ApplicationEventPublisher eventPublisher) - { - this.listenerProvider = listenerProvider; - this.eventPublisher = eventPublisher; - } - - /** - * 惰性解析并缓存业务监听器 - */ - private List getListeners() - { - if (listeners == null) - { - synchronized (this) - { - if (listeners == null) - { - listeners = listenerProvider.orderedStream().toList(); - } - } - } - return listeners; - } - - /** - * 分发消息,单个监听器异常不影响其它监听器与MQTT回调线程 - * - * @param message 消息内容 - */ - public void dispatch(MqttMessageDTO message) - { - if (message == null) - { - return; - } - List targets; - try - { - targets = getListeners(); - } - catch (Exception e) - { - // 容器尚未就绪时监听器无法解析,此时仅发布事件,避免影响MQTT回调线程 - log.warn("业务监听器尚未就绪,本次消息仅发布事件, topic={}", message.getTopic()); - targets = Collections.emptyList(); - } - for (IMqttMessageListener listener : targets) - { - try - { - if (listener.supports(message.getTopic())) - { - listener.onMessage(message); - } - } - catch (Exception e) - { - log.error("MQTT消息处理异常, topic={}, listener={}", message.getTopic(), - listener.getClass().getName(), e); - } - } - if (eventPublisher != null) - { - try - { - eventPublisher.publishEvent(new MqttMessageEvent(this, message)); - } - catch (Exception e) - { - log.error("MQTT消息事件发布异常, topic={}", message.getTopic(), e); - } - } - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/domain/MqttMessageDTO.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/domain/MqttMessageDTO.java deleted file mode 100644 index d6bf5da41..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/domain/MqttMessageDTO.java +++ /dev/null @@ -1,142 +0,0 @@ -package com.ruoyi.iot.domain; - -import java.io.Serializable; -import java.util.Date; - -/** - * MQTT消息(接收与发布统一模型) - * - * @author ruoyi - */ -public class MqttMessageDTO implements Serializable -{ - private static final long serialVersionUID = 1L; - - /** 主题 */ - private String topic; - - /** 消息内容(文本形式) */ - private String payload; - - /** 原始字节内容 */ - private byte[] payloadBytes; - - /** 服务质量 0/1/2 */ - private int qos; - - /** 是否保留消息 */ - private boolean retained; - - /** 是否为重复消息 */ - private boolean duplicate; - - /** 消息编号 */ - private int messageId; - - /** 消息时间(接收或发布时间) */ - private Date timestamp; - - public MqttMessageDTO() - { - } - - public MqttMessageDTO(String topic, String payload) - { - this.topic = topic; - this.payload = payload; - } - - public String getTopic() - { - return topic; - } - - public MqttMessageDTO setTopic(String topic) - { - this.topic = topic; - return this; - } - - public String getPayload() - { - return payload; - } - - public MqttMessageDTO setPayload(String payload) - { - this.payload = payload; - return this; - } - - public byte[] getPayloadBytes() - { - return payloadBytes; - } - - public MqttMessageDTO setPayloadBytes(byte[] payloadBytes) - { - this.payloadBytes = payloadBytes; - return this; - } - - public int getQos() - { - return qos; - } - - public MqttMessageDTO setQos(int qos) - { - this.qos = qos; - return this; - } - - public boolean isRetained() - { - return retained; - } - - public MqttMessageDTO setRetained(boolean retained) - { - this.retained = retained; - return this; - } - - public boolean isDuplicate() - { - return duplicate; - } - - public MqttMessageDTO setDuplicate(boolean duplicate) - { - this.duplicate = duplicate; - return this; - } - - public int getMessageId() - { - return messageId; - } - - public MqttMessageDTO setMessageId(int messageId) - { - this.messageId = messageId; - return this; - } - - public Date getTimestamp() - { - return timestamp; - } - - public MqttMessageDTO setTimestamp(Date timestamp) - { - this.timestamp = timestamp; - return this; - } - - @Override - public String toString() - { - return "MqttMessageDTO{topic='" + topic + "', qos=" + qos + ", retained=" + retained + ", payload=" + payload + "}"; - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/domain/MqttPublishRequest.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/domain/MqttPublishRequest.java deleted file mode 100644 index a7afddf5f..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/domain/MqttPublishRequest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.ruoyi.iot.domain; - -import java.io.Serializable; - -/** - * MQTT发布/订阅请求参数 - * - * @author ruoyi - */ -public class MqttPublishRequest implements Serializable -{ - private static final long serialVersionUID = 1L; - - /** 主题(订阅时支持 +/# 通配符) */ - private String topic; - - /** 消息内容 */ - private String payload; - - /** 服务质量,为空时取默认配置 */ - private Integer qos; - - /** 是否保留消息,为空时取默认配置 */ - private Boolean retained; - - public String getTopic() - { - return topic; - } - - public void setTopic(String topic) - { - this.topic = topic; - } - - public String getPayload() - { - return payload; - } - - public void setPayload(String payload) - { - this.payload = payload; - } - - public Integer getQos() - { - return qos; - } - - public void setQos(Integer qos) - { - this.qos = qos; - } - - public Boolean getRetained() - { - return retained; - } - - public void setRetained(Boolean retained) - { - this.retained = retained; - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/event/MqttMessageEvent.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/event/MqttMessageEvent.java deleted file mode 100644 index 13631bba2..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/event/MqttMessageEvent.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.ruoyi.iot.event; - -import org.springframework.context.ApplicationEvent; -import com.ruoyi.iot.domain.MqttMessageDTO; - -/** - * MQTT消息事件 - *

- * 除实现 {@link com.ruoyi.iot.core.IMqttMessageListener} 外, - * 也可以通过监听本事件来处理设备消息: - *

- * @EventListener
- * public void onMessage(MqttMessageEvent event) { ... }
- * 
- * - * @author ruoyi - */ -public class MqttMessageEvent extends ApplicationEvent -{ - private static final long serialVersionUID = 1L; - - private final MqttMessageDTO message; - - public MqttMessageEvent(Object source, MqttMessageDTO message) - { - super(source); - this.message = message; - } - - public MqttMessageDTO getMessage() - { - return message; - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockMqttLogController.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockMqttLogController.java deleted file mode 100644 index d692ecd22..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/controller/LockMqttLogController.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.ruoyi.iot.lock.controller; - -import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import com.ruoyi.common.core.controller.BaseController; -import com.ruoyi.common.core.domain.AjaxResult; -import com.ruoyi.common.core.page.TableDataInfo; -import com.ruoyi.iot.lock.domain.LockMqttLog; -import com.ruoyi.iot.lock.service.ILockMqttLogService; - -/** - * MQTT报文日志Controller - * - * @author ruoyi - */ -@RestController -@RequestMapping("/lock/mqttlog") -public class LockMqttLogController extends BaseController -{ - @Autowired - private ILockMqttLogService lockMqttLogService; - - @PreAuthorize("@ss.hasPermi('lock:mqttlog:list')") - @GetMapping("/list") - public TableDataInfo list(LockMqttLog mqttLog) - { - startPage(); - List list = lockMqttLogService.selectMqttLogList(mqttLog); - return getDataTable(list); - } - - @PreAuthorize("@ss.hasPermi('lock:mqttlog:query')") - @GetMapping(value = "/{logId}") - public AjaxResult getInfo(@PathVariable Long logId) - { - return success(lockMqttLogService.selectMqttLogById(logId)); - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockMqttLog.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockMqttLog.java deleted file mode 100644 index 17dc342eb..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/domain/LockMqttLog.java +++ /dev/null @@ -1,309 +0,0 @@ -package com.ruoyi.iot.lock.domain; - -import java.io.Serializable; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import org.apache.commons.lang3.builder.ToStringBuilder; -import org.apache.commons.lang3.builder.ToStringStyle; -import com.fasterxml.jackson.annotation.JsonFormat; - -/** - * MQTT报文留痕对象 lock_mqtt_log - * - * @author ruoyi - */ -public class LockMqttLog implements Serializable -{ - private static final long serialVersionUID = 1L; - - /** 日志ID */ - private Long logId; - - /** 方向(1上行设备到平台 2下行平台到设备) */ - private String direction; - - /** MQTT主题 */ - private String topic; - - /** 解析出的分组编号 */ - private String groupCode; - - /** 解析出的网关SN */ - private String gatewaySn; - - /** 解析出的设备SN */ - private String deviceSn; - - /** 操作类型(协议 op) */ - private String op; - - /** 方向标识(s2d/d2s) */ - private String mode; - - /** 动作类型(协议 ac) */ - private String ac; - - /** 报文序号(协议 seq) */ - private String seq; - - /** 服务质量 */ - private Integer qos; - - /** 是否保留消息(0否 1是) */ - private String retained; - - /** 报文原文 */ - private String payload; - - /** 报文校验和 */ - private String chksum; - - /** 校验结果(0未校验 1通过 2失败) */ - private String verifyStatus; - - /** 处理状态(0待处理 1成功 2失败 3已忽略) */ - private String handleStatus; - - /** 处理结果说明 */ - private String handleMsg; - - /** 处理耗时(毫秒) */ - private Integer costTime; - - /** 接收/发送时间 */ - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private Date recvTime; - - /** 创建时间 */ - private Date createTime; - - /** 请求参数:时间范围检索(params.beginTime / params.endTime),见 LockMqttLogMapper */ - private Map params; - - public Long getLogId() - { - return logId; - } - - public void setLogId(Long logId) - { - this.logId = logId; - } - - public String getDirection() - { - return direction; - } - - public void setDirection(String direction) - { - this.direction = direction; - } - - public String getTopic() - { - return topic; - } - - public void setTopic(String topic) - { - this.topic = topic; - } - - public String getGroupCode() - { - return groupCode; - } - - public void setGroupCode(String groupCode) - { - this.groupCode = groupCode; - } - - public String getGatewaySn() - { - return gatewaySn; - } - - public void setGatewaySn(String gatewaySn) - { - this.gatewaySn = gatewaySn; - } - - public String getDeviceSn() - { - return deviceSn; - } - - public void setDeviceSn(String deviceSn) - { - this.deviceSn = deviceSn; - } - - public String getOp() - { - return op; - } - - public void setOp(String op) - { - this.op = op; - } - - public String getMode() - { - return mode; - } - - public void setMode(String mode) - { - this.mode = mode; - } - - public String getAc() - { - return ac; - } - - public void setAc(String ac) - { - this.ac = ac; - } - - public String getSeq() - { - return seq; - } - - public void setSeq(String seq) - { - this.seq = seq; - } - - public Integer getQos() - { - return qos; - } - - public void setQos(Integer qos) - { - this.qos = qos; - } - - public String getRetained() - { - return retained; - } - - public void setRetained(String retained) - { - this.retained = retained; - } - - public String getPayload() - { - return payload; - } - - public void setPayload(String payload) - { - this.payload = payload; - } - - public String getChksum() - { - return chksum; - } - - public void setChksum(String chksum) - { - this.chksum = chksum; - } - - public String getVerifyStatus() - { - return verifyStatus; - } - - public void setVerifyStatus(String verifyStatus) - { - this.verifyStatus = verifyStatus; - } - - public String getHandleStatus() - { - return handleStatus; - } - - public void setHandleStatus(String handleStatus) - { - this.handleStatus = handleStatus; - } - - public String getHandleMsg() - { - return handleMsg; - } - - public void setHandleMsg(String handleMsg) - { - this.handleMsg = handleMsg; - } - - public Integer getCostTime() - { - return costTime; - } - - public void setCostTime(Integer costTime) - { - this.costTime = costTime; - } - - public Date getRecvTime() - { - return recvTime; - } - - public void setRecvTime(Date recvTime) - { - this.recvTime = recvTime; - } - - public Date getCreateTime() - { - return createTime; - } - - public void setCreateTime(Date createTime) - { - this.createTime = createTime; - } - - /** - * 请求参数,懒初始化以便 Spring 直接把 params[beginTime] 等绑定到该 Map - */ - public Map getParams() - { - if (params == null) - { - params = new HashMap(); - } - return params; - } - - public void setParams(Map params) - { - this.params = params; - } - - @Override - public String toString() - { - return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE).append("logId", getLogId()) - .append("direction", getDirection()).append("topic", getTopic()).append("op", getOp()) - .append("seq", getSeq()).append("handleStatus", getHandleStatus()).append("recvTime", getRecvTime()) - .toString(); - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockMqttLogMapper.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockMqttLogMapper.java deleted file mode 100644 index b3b521208..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mapper/LockMqttLogMapper.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.ruoyi.iot.lock.mapper; - -import java.util.List; -import com.ruoyi.iot.lock.domain.LockMqttLog; - -/** - * MQTT报文留痕 数据层 - * - * @author ruoyi - */ -public interface LockMqttLogMapper -{ - List selectMqttLogList(LockMqttLog mqttLog); - - LockMqttLog selectMqttLogById(Long logId); - - int insertMqttLog(LockMqttLog mqttLog); - - int deleteMqttLogByIds(Long[] logIds); - - int deleteMqttLogBefore(String keepDate); -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/LockMessageListener.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/LockMessageListener.java deleted file mode 100644 index 86c8653d4..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/LockMessageListener.java +++ /dev/null @@ -1,175 +0,0 @@ -package com.ruoyi.iot.lock.mqtt; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import com.ruoyi.iot.core.IMqttMessageListener; -import com.ruoyi.iot.lock.LockConstants; -import com.ruoyi.iot.lock.domain.LockMqttLog; -import com.ruoyi.iot.lock.mqtt.handler.AccessRecordHandler; -import com.ruoyi.iot.lock.mqtt.handler.LockSignalHandler; -import com.ruoyi.iot.lock.mqtt.handler.CommandRespHandler; -import com.ruoyi.iot.lock.mqtt.handler.GatewayOnlineHandler; -import com.ruoyi.iot.lock.protocol.YdlinkCodec; -import com.ruoyi.iot.lock.protocol.YdlinkFrameDTO; -import com.ruoyi.iot.lock.protocol.YdlinkOp; -import com.ruoyi.iot.lock.protocol.YdlinkTopicBuilder; -import com.ruoyi.iot.lock.protocol.YdlinkTopicDTO; -import com.ruoyi.iot.lock.service.ILockMqttLogService; -import com.ruoyi.iot.domain.MqttMessageDTO; - -/** - * 门禁业务MQTT消息监听 - *

- * 只消费 ydlink 上行主题,按主题类型路由到对应处理器,并落库报文留痕。 - * 下行主题由平台自身发出,不消费。 - * - * @author ruoyi - */ -@Component -public class LockMessageListener implements IMqttMessageListener -{ - private static final Logger log = LoggerFactory.getLogger(LockMessageListener.class); - - @Autowired - private GatewayOnlineHandler gatewayOnlineHandler; - - @Autowired - private CommandRespHandler commandRespHandler; - - @Autowired - private AccessRecordHandler accessRecordHandler; - - @Autowired - private LockSignalHandler lockSignalHandler; - - @Autowired - private ILockMqttLogService mqttLogService; - - @Override - public boolean supports(String topic) - { - return YdlinkTopicBuilder.parse(topic) != null; - } - - @Override - public void onMessage(MqttMessageDTO message) - { - if (message == null || message.getTopic() == null) - { - return; - } - long start = System.currentTimeMillis(); - YdlinkTopicDTO topic = YdlinkTopicBuilder.parse(message.getTopic()); - if (topic == null || topic.getTopicType() == null || topic.getTopicType().isDownstream()) - { - // 丢弃前留痕:主题拼写/层级与协议不一致时,此前无任何记录,难以排查 - log.warn("上行报文被丢弃, topic={}, reason={}", message.getTopic(), - topic == null ? "主题格式无法识别" : (topic.getTopicType() == null ? "主题类型未登记" : "下行主题")); - return; - } - YdlinkFrameDTO frame = YdlinkCodec.fromJson(message.getPayload()); - if (frame == null) - { - recordLog(topic, null, message, LockConstants.HANDLE_FAIL, "报文解析失败", cost(start)); - return; - } - String result; - if (message.isRetained() && (topic.getTopicType() == com.ruoyi.iot.lock.protocol.YdlinkTopicType.GATEWAY_PROPERTY_SET_RESP - || topic.getTopicType() == com.ruoyi.iot.lock.protocol.YdlinkTopicType.GROUP_ACTION_EXECUTE_RESP - || (topic.getTopicType() == com.ruoyi.iot.lock.protocol.YdlinkTopicType.GROUP_DATA_BATCH_REPORT - && !YdlinkOp.LOCK_RSSI.equals(frame.getOp())))) { - recordLog(topic, frame, message, LockConstants.HANDLE_IGNORE, "RETAINED_ACK_REJECTED", cost(start)); - return; - } - String status = LockConstants.HANDLE_SUCCESS; - try - { - switch (topic.getTopicType()) - { - case GATEWAY_ONLINE: - result = gatewayOnlineHandler.handle(topic, frame); - break; - case GATEWAY_PROPERTY_SET_RESP: - case GROUP_ACTION_EXECUTE_RESP: - result = commandRespHandler.handle(topic, frame, message.getPayload()); - break; - case GROUP_DATA_BATCH_REPORT: - if (YdlinkOp.LOCK_RSSI.equals(frame.getOp())) - { - // 信号电量上报:seq 恒为 0,不参与命令匹配 - int updated = lockSignalHandler.handle(topic, frame); - result = "信号电量更新 " + updated + " 台"; - } - else - { - result = commandRespHandler.handle(topic, frame, message.getPayload()); - } - break; - case GROUP_EVENT_TRIGGER: - if (YdlinkOp.LOCK_RECORD.equals(frame.getOp())) - { - int saved = accessRecordHandler.handle(topic, frame); - result = "开门记录入库 " + saved + " 条"; - } - else - { - status = LockConstants.HANDLE_IGNORE; - result = "非开门记录事件,op=" + frame.getOp(); - } - break; - default: - status = LockConstants.HANDLE_IGNORE; - result = "未处理的主题类型"; - break; - } - } - catch (Exception e) - { - status = LockConstants.HANDLE_FAIL; - result = e.getMessage(); - log.error("处理上行报文异常, topic={}", message.getTopic(), e); - } - recordLog(topic, frame, message, status, result, cost(start)); - } - - private void recordLog(YdlinkTopicDTO topic, YdlinkFrameDTO frame, MqttMessageDTO message, String handleStatus, - String handleMsg, int costTime) - { - try - { - LockMqttLog logEntity = new LockMqttLog(); - logEntity.setDirection(LockConstants.DIRECTION_UP); - logEntity.setTopic(message.getTopic()); - logEntity.setGroupCode(topic.getGroupCode()); - logEntity.setGatewaySn(topic.getGatewaySn()); - if (frame != null) - { - logEntity.setDeviceSn(frame.getDevId()); - logEntity.setOp(frame.getOp()); - logEntity.setMode(frame.getMode()); - logEntity.setAc(frame.getAc()); - logEntity.setSeq(frame.getSeq()); - logEntity.setChksum(frame.getChksum()); - } - logEntity.setQos(message.getQos()); - logEntity.setRetained(message.isRetained() ? "1" : "0"); - logEntity.setPayload("[redacted]"); - logEntity.setHandleStatus(handleStatus); - logEntity.setHandleMsg(handleMsg); - logEntity.setCostTime(costTime); - logEntity.setRecvTime(message.getTimestamp()); - mqttLogService.insertMqttLog(logEntity); - } - catch (Exception e) - { - log.warn("报文留痕失败, topic={}", message.getTopic()); - } - } - - private int cost(long start) - { - return (int) (System.currentTimeMillis() - start); - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/LockTopicSubscriber.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/LockTopicSubscriber.java deleted file mode 100644 index 529ae87f8..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/LockTopicSubscriber.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.ruoyi.iot.lock.mqtt; - -import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.stereotype.Component; -import com.ruoyi.iot.lock.protocol.YdlinkTopicBuilder; -import com.ruoyi.iot.service.IMqttService; - -/** - * 门禁上行主题订阅 - *

- * 启动后订阅 ydlink 上行通配主题;MQTT 未连接时跳过,由通道层重连后的订阅恢复机制补齐。 - * - * @author ruoyi - */ -@Component -public class LockTopicSubscriber implements ApplicationRunner -{ - private static final Logger log = LoggerFactory.getLogger(LockTopicSubscriber.class); - - @Autowired - private IMqttService mqttService; - - @Override - public void run(ApplicationArguments args) - { - List topics = YdlinkTopicBuilder.uplinkWildcardTopics(); - for (String topic : topics) - { - try - { - mqttService.subscribe(topic, 1); - } - catch (Exception e) - { - log.warn("订阅上行主题失败,将在重连后自动恢复, topic={}", topic); - } - } - log.info("门禁上行主题订阅完成, topics={}", topics); - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/AccessRecordHandler.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/AccessRecordHandler.java deleted file mode 100644 index 4f5468823..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/AccessRecordHandler.java +++ /dev/null @@ -1,220 +0,0 @@ -package com.ruoyi.iot.lock.mqtt.handler; - -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import com.alibaba.fastjson2.JSON; -import com.ruoyi.common.utils.StringUtils; -import com.ruoyi.iot.lock.domain.LockAccessLog; -import com.ruoyi.iot.lock.domain.LockDevice; -import com.ruoyi.iot.lock.domain.LockGateway; -import com.ruoyi.iot.lock.domain.LockGroup; -import com.ruoyi.iot.lock.protocol.YdlinkCodec; -import com.ruoyi.iot.lock.protocol.YdlinkFrameDTO; -import com.ruoyi.iot.lock.protocol.YdlinkTopicDTO; -import com.ruoyi.iot.lock.service.ILockAccessLogService; -import com.ruoyi.iot.lock.service.ILockDeviceService; -import com.ruoyi.iot.lock.service.ILockGatewayService; -import com.ruoyi.iot.lock.service.ILockGroupService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -/** - * 开门记录上报处理:ydlink/{groupCode}/thing/event/trigger(op=lockrecord) - *

- * params 为二维数组,内层 8 个位置字段按《数据库设计文档》4.6 尽力映射: - * [0]卡号 [1]未映射 [2]开门方式 [3]人员编号 [4]开门结果 [5]未映射 [6]开门时间 [7]开门方向/校验位 - * 原始 params 完整写入 raw_params,待协议确认后可回刷历史数据。 - * - * @author ruoyi - */ -@Component -public class AccessRecordHandler -{ - private static final Logger log = LoggerFactory.getLogger(AccessRecordHandler.class); - - private static final String TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; - - /** params[0] 为门锁SN(信封 devId 是网关SN,与 lockrssi 上报一致) */ - private static final int IDX_DEVICE_SN = 0; - - private static final int IDX_OPEN_TYPE = 2; - - private static final int IDX_PERSON_NO = 3; - - private static final int IDX_OPEN_RESULT = 4; - - private static final int IDX_OPEN_TIME = 6; - - private static final int IDX_OPEN_DIR = 7; - - /** 列长度(lock_access_log):上报值超长会导致插入失败、整条记录丢失 */ - private static final int LEN_DEVICE_SN = 32; - - private static final int LEN_OPEN_TYPE = 8; - - private static final int LEN_PERSON_NO = 32; - - private static final int LEN_OPEN_RESULT = 8; - - private static final int LEN_OPEN_DIR = 8; - - @Autowired - private ILockAccessLogService accessLogService; - - @Autowired - private ILockDeviceService deviceService; - - @Autowired - private ILockGatewayService gatewayService; - - @Autowired - private ILockGroupService groupService; - - /** - * 处理开门记录报文 - * - * @param topic 解析后的主题(分组级) - * @param frame 报文 - * @return 成功入库条数 - */ - public int handle(YdlinkTopicDTO topic, YdlinkFrameDTO frame) - { - String groupCode = topic.getGroupCode(); - Long groupId = null; - if (StringUtils.isNotEmpty(groupCode)) - { - LockGroup group = groupService.selectGroupByCode(groupCode); - if (group != null) - { - groupId = group.getGroupId(); - } - } - // 信封 devId 是网关SN,真正的门锁SN在每条记录的 params[0],取不到时再回退 devId - String envelopeSn = frame.getDevId(); - List> records = YdlinkCodec.toRecordList(frame.getParams()); - int saved = 0; - for (List record : records) - { - String deviceSn = YdlinkCodec.get(record, IDX_DEVICE_SN); - if (StringUtils.isEmpty(deviceSn)) - { - deviceSn = envelopeSn; - } - try - { - LockDevice device = StringUtils.isNotEmpty(deviceSn) ? deviceService.selectDeviceBySn(deviceSn) : null; - LockAccessLog accessLog = convert(record, deviceSn, device, groupId); - accessLog.setSeq(frame.getSeq()); - accessLog.setRawParams(JSON.toJSONString(record)); - accessLog.setRecvTime(new Date()); - if (accessLogService.saveAccessLog(accessLog)) - { - saved++; - } - if (device != null) - { - deviceService.updateOnlineStatus(deviceSn, com.ruoyi.iot.lock.LockConstants.ONLINE); - } - } - catch (Exception e) - { - // 单条解析失败只跳过该条,不影响整批 - log.warn("开门记录解析失败, deviceSn={}, record={}, msg={}", deviceSn, record, e.getMessage()); - } - } - log.info("开门记录处理完成, groupCode={}, 信封devId={}, total={}, saved={}", groupCode, envelopeSn, - records.size(), saved); - return saved; - } - - private LockAccessLog convert(List record, String deviceSn, LockDevice device, Long groupId) - { - LockAccessLog logEntity = new LockAccessLog(); - logEntity.setDeviceSn(fit(deviceSn, LEN_DEVICE_SN)); - // params[0] 是门锁SN而非卡号,卡号位置待协议确认,暂不填充(原始 params 已存 raw_params 可回溯) - logEntity.setCardNo(null); - logEntity.setOpenType(fit(YdlinkCodec.get(record, IDX_OPEN_TYPE), LEN_OPEN_TYPE)); - logEntity.setPersonNo(fit(YdlinkCodec.get(record, IDX_PERSON_NO), LEN_PERSON_NO)); - logEntity.setOpenResult(fit(YdlinkCodec.get(record, IDX_OPEN_RESULT), LEN_OPEN_RESULT)); - logEntity.setOpenDir(fit(YdlinkCodec.get(record, IDX_OPEN_DIR), LEN_OPEN_DIR)); - logEntity.setOpenTime(parseTime(YdlinkCodec.get(record, IDX_OPEN_TIME))); - if (device != null) - { - logEntity.setDeviceId(device.getDeviceId()); - logEntity.setGroupId(resolveGroupId(device, groupId)); - logEntity.setGatewayId(device.getGatewayId()); - if (device.getGatewayId() != null) - { - LockGateway gateway = gatewayService.selectGatewayById(device.getGatewayId()); - if (gateway != null) - { - logEntity.setGatewaySn(gateway.getGatewaySn()); - } - } - if (StringUtils.isNotEmpty(device.getDeviceName())) - { - // 人员姓名需按人员编号关联人脸库,此处留空由查询时关联 - logEntity.setPersonName(null); - } - } - else - { - logEntity.setGroupId(groupId); - } - return logEntity; - } - - /** - * 开门记录归属分组:同样遵循「门锁 → 网关 → 分组」,网关推导优先, - * 其次才是门锁冗余分组与主题解析出的分组 - */ - private Long resolveGroupId(LockDevice device, Long defaultGroupId) - { - if (device != null && device.getGatewayId() != null) - { - LockGateway gateway = gatewayService.selectGatewayById(device.getGatewayId()); - if (gateway != null && gateway.getGroupId() != null) - { - return gateway.getGroupId(); - } - } - if (device != null && device.getGroupId() != null) - { - return device.getGroupId(); - } - return defaultGroupId; - } - - /** - * 按列长度截断:宁可截断也要留下记录,避免设备上报超长值导致整条开门记录丢失 - */ - private String fit(String value, int maxLength) - { - if (value == null || value.length() <= maxLength) - { - return value; - } - return value.substring(0, maxLength); - } - - private Date parseTime(String value) - { - if (StringUtils.isEmpty(value)) - { - return null; - } - try - { - return new SimpleDateFormat(TIME_PATTERN).parse(value); - } - catch (ParseException e) - { - return null; - } - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/CommandRespHandler.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/CommandRespHandler.java deleted file mode 100644 index f01aa3603..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/CommandRespHandler.java +++ /dev/null @@ -1,221 +0,0 @@ -package com.ruoyi.iot.lock.mqtt.handler; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Date; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import com.alibaba.fastjson2.JSON; -import com.ruoyi.common.utils.StringUtils; -import com.ruoyi.iot.lock.LockConstants; -import com.ruoyi.iot.lock.protocol.YdlinkCodec; -import com.ruoyi.iot.lock.protocol.YdlinkFrameDTO; -import com.ruoyi.iot.lock.protocol.YdlinkOp; -import com.ruoyi.iot.lock.protocol.YdlinkTopicDTO; -import com.ruoyi.iot.lock.service.ILockAccessLogService; -import com.ruoyi.iot.lock.service.ILockAuthService; -import com.ruoyi.iot.lock.service.ILockCommandService; - -/** - * 命令响应处理 - *

- * 覆盖:property/set_response、action/execute_response、data/batch_report - * 处理:按 seq 匹配命令 → 更新命令状态 → 授权类命令回写授权同步状态 - *

- * 门锁类下发会收到两次响应:
- * 1) 网关回执(结果码 10):仅表示消息已送达网关并转发,属中间态,不能判定为完成;
- * 2) 门锁回执(结果码 0):门锁执行完毕,属终态,据此判定成功与失败。 - * - * @author ruoyi - */ -@Component -public class CommandRespHandler -{ - /** 响应首项为动作标识时,结果码位于 params[2]:params = [动作, 门锁SN, 结果码, ...] */ - private static final int RESULT_CODE_INDEX_ACTION = 2; - - /** 响应首项即门锁SN时,结果码位于 params[1]:params = [门锁SN, 结果码, ...](开关锁响应无动作位) */ - private static final int RESULT_CODE_INDEX_DEVICE = 1; - - /** - * 响应首项为动作标识的取值:授权 add/delete、配置 systime/bind/... 等 - *

- * 实测两类响应: - *

-     * 授权   params = ["add", "807104B5", "10", "AA000006", "AA000006", "null"]
-     * 开关锁 params = ["807104B5", "10", "807104B5", "1", "1", "0", "0"]
-     * 
- */ - private static final Set ACTION_PREFIXES = new HashSet(Arrays.asList("add", "delete", "systime", - "bind", "unbind", "reboot", "save", "clear", "loracfg", "mqttcfg", "netcfg")); - - private static final Logger log = LoggerFactory.getLogger(CommandRespHandler.class); - - @Autowired - private ILockCommandService commandService; - - @Autowired - private ILockAuthService authService; - - @Autowired - private ILockAccessLogService accessLogService; - - public String handle(YdlinkTopicDTO topic, YdlinkFrameDTO frame, String payload) - { - String seq = frame.getSeq(); - if (StringUtils.isEmpty(seq)) - { - return "报文缺少 seq,无法匹配命令"; - } - RespResult result = parseResult(frame); - // 网关回执与门锁回执齐备才进入终态;未齐备时命令保持“已发送”继续等待 - String finalStatus = result.isGatewayAck() - ? commandService.handleGatewayAck(topic.getTopic(), seq, payload, result.getMessage()) - : commandService.handleResponse(topic.getTopic(), seq, payload, result.isSuccess(), result.getMessage()); - if (finalStatus == null) - { - log.info("回执已记录,等待另一端回执, topic={}, seq={}, code={}", topic.getTopic(), seq, result.getCode()); - return result.isGatewayAck() ? "网关已接收,等待门锁执行结果" : "门锁已响应,等待网关回执"; - } - boolean success = LockConstants.STATUS_SUCCESS.equals(finalStatus); - authService.handleResult(seq, success, result.getMessage()); - if (success && YdlinkOp.OPEN_CLOSE_LOCK.equals(frame.getOp())) - { - // 远程开锁成功补一条通行记录,与刷卡/人脸开门统一在通行记录中查询 - recordRemoteOpen(frame, seq); - } - log.info("命令响应已处理, topic={}, seq={}, code={}, success={}", topic.getTopic(), seq, - result.getCode(), success); - return success ? "执行成功" : ("执行失败:" + result.getMessage()); - } - - /** - * 解析响应结果 - *

- * 实测报文: - *

-     * 网关回执 params = ["add", "807104B5", "10", "AA000006", "AA000006", "null"]
-     * 门锁回执 params = ["add", "807104B5", "0", "", "msg received"]
-     * 
- * 第 3 项为结果码:0=成功(终态);10=网关已接收(中间态);其余视为失败。 - */ - private RespResult parseResult(YdlinkFrameDTO frame) - { - List> records = YdlinkCodec.toRecordList(frame.getParams()); - List first = records.isEmpty() ? new ArrayList() : records.get(0); - String message = parseMessage(first); - int codeIndex = resolveResultCodeIndex(first); - if (first.size() <= codeIndex) - { - // 未携带结果码时按门锁回执处理,保持旧行为 - return new RespResult(null, true, false, message); - } - String code = first.get(codeIndex); - if (StringUtils.isEmpty(code)) - { - return new RespResult(null, true, false, message); - } - boolean gatewayAck = LockConstants.RESP_CODE_GATEWAY_RECEIVED.equals(code); - return new RespResult(code, LockConstants.RESP_CODE_SUCCESS.equals(code), gatewayAck, message); - } - - /** - * 远程开锁成功后补记一条通行记录(开门方式取「远程开锁」), - * 与刷卡、人脸开门等在通行记录中统一查询 - */ - private void recordRemoteOpen(YdlinkFrameDTO frame, String seq) - { - List> records = YdlinkCodec.toRecordList(frame.getParams()); - String deviceSn = records.isEmpty() ? "" : YdlinkCodec.get(records.get(0), 0); - if (StringUtils.isEmpty(deviceSn)) - { - return; - } - try - { - accessLogService.saveRemoteOpenLog(deviceSn, seq, JSON.toJSONString(frame.getParams()), new Date()); - } - catch (Exception e) - { - // 日志补记失败不影响命令结果本身 - log.warn("远程开锁通行记录写入失败, deviceSn={}, msg={}", deviceSn, e.getMessage()); - } - } - - /** - * 确定结果码在响应参数中的位置 - *

- * 首项为动作标识时结果码在其后第 2 位;否则首项即门锁SN,结果码紧随其后。 - */ - private int resolveResultCodeIndex(List values) - { - if (!values.isEmpty() && ACTION_PREFIXES.contains(values.get(0))) - { - return RESULT_CODE_INDEX_ACTION; - } - return RESULT_CODE_INDEX_DEVICE; - } - - /** - * 结果说明取参数末项;设备未填写时该位置为字符串 "null" - */ - private String parseMessage(List values) - { - if (values.isEmpty()) - { - return null; - } - String message = values.get(values.size() - 1); - if (StringUtils.isEmpty(message) || "null".equals(message)) - { - return null; - } - return message; - } - - /** 响应解析结果 */ - private static class RespResult - { - private final String code; - - private final boolean success; - - /** 是否网关回执(true 表示仅送达网关,尚需门锁回执) */ - private final boolean gatewayAck; - - private final String message; - - RespResult(String code, boolean success, boolean gatewayAck, String message) - { - this.code = code; - this.success = success; - this.gatewayAck = gatewayAck; - this.message = message; - } - - String getCode() - { - return code; - } - - boolean isSuccess() - { - return success; - } - - boolean isGatewayAck() - { - return gatewayAck; - } - - String getMessage() - { - return message; - } - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/GatewayOnlineHandler.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/GatewayOnlineHandler.java deleted file mode 100644 index 8058f0ba7..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/GatewayOnlineHandler.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.ruoyi.iot.lock.mqtt.handler; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import com.ruoyi.iot.lock.LockConstants; -import com.ruoyi.iot.lock.domain.LockGateway; -import com.ruoyi.iot.lock.service.ILockGatewayService; -import com.ruoyi.iot.lock.protocol.YdlinkFrameDTO; -import com.ruoyi.iot.lock.protocol.YdlinkTopicDTO; - -/** - * 网关上下线处理:ydlink/{gatewaySn}/thing/onoff_line - * - * @author ruoyi - */ -@Component -public class GatewayOnlineHandler -{ - private static final Logger log = LoggerFactory.getLogger(GatewayOnlineHandler.class); - - @Autowired - private ILockGatewayService gatewayService; - - /** - * 处理上下线报文 - * - * @param topic 解析后的主题 - * @param frame 报文 - * @return 处理说明 - */ - public String handle(YdlinkTopicDTO topic, YdlinkFrameDTO frame) - { - String gatewaySn = topic.getGatewaySn(); - if (gatewaySn == null) - { - return "非网关级主题"; - } - // 协议中上下线语义待确认:默认按“收到上报即在线”处理 - String onlineStatus = LockConstants.ONLINE; - gatewayService.updateOnlineStatus(gatewaySn, onlineStatus); - LockGateway gateway = gatewayService.selectGatewayBySn(gatewaySn); - log.info("网关状态更新, gatewaySn={}, onlineStatus={}, registered={}", gatewaySn, onlineStatus, - gateway != null); - return gateway == null ? "网关未登记,已忽略" : "已更新为在线"; - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/LockSignalHandler.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/LockSignalHandler.java deleted file mode 100644 index 2b099d8b5..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/mqtt/handler/LockSignalHandler.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.ruoyi.iot.lock.mqtt.handler; - -import java.util.Date; -import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import com.ruoyi.common.utils.StringUtils; -import com.ruoyi.iot.lock.LockConstants; -import com.ruoyi.iot.lock.domain.LockDevice; -import com.ruoyi.iot.lock.protocol.YdlinkCodec; -import com.ruoyi.iot.lock.protocol.YdlinkFrameDTO; -import com.ruoyi.iot.lock.protocol.YdlinkTopicDTO; -import com.ruoyi.iot.lock.service.ILockDeviceService; - -/** - * 门锁信号强度与电量上报处理:ydlink/{groupCode}/thing/data/batch_report(op=lockrssi) - *

- * 报文示例: - *

- * {"devId":"E8F7AFF69483","seq":"0","op":"lockrssi","params":[["807104B5","-42","100","1"]]}
- * 
- * params 为二维数组,每行按位置映射: - * [0]门锁SN [1]信号强度RSSI(dBm) [2]剩余电量(%) [3]未映射(实测为 "1",语义待确认) - *

- * 注意:信封 devId 是网关SN,门锁SN 取自 params 第 1 列; - * 若该列为空则回退 devId,兼容门锁直连上报的场景。 - * - * @author ruoyi - */ -@Component -public class LockSignalHandler -{ - private static final int IDX_DEVICE_SN = 0; - - private static final int IDX_RSSI = 1; - - private static final int IDX_BATTERY = 2; - - private static final Logger log = LoggerFactory.getLogger(LockSignalHandler.class); - - @Autowired - private ILockDeviceService deviceService; - - /** - * 处理信号与电量上报 - * - * @param topic 解析后的主题(分组级) - * @param frame 报文 - * @return 成功更新的门锁数量 - */ - public int handle(YdlinkTopicDTO topic, YdlinkFrameDTO frame) - { - List> records = YdlinkCodec.toRecordList(frame.getParams()); - int updated = 0; - for (List record : records) - { - String deviceSn = YdlinkCodec.get(record, IDX_DEVICE_SN); - if (StringUtils.isEmpty(deviceSn)) - { - deviceSn = frame.getDevId(); - } - if (StringUtils.isEmpty(deviceSn)) - { - continue; - } - try - { - LockDevice device = deviceService.selectDeviceBySn(deviceSn); - if (device == null) - { - log.warn("信号上报未匹配到门锁, deviceSn={}, topic={}", deviceSn, topic.getTopic()); - continue; - } - device.setSignalStrength(YdlinkCodec.get(record, IDX_RSSI)); - device.setBatteryLevel(parseInt(YdlinkCodec.get(record, IDX_BATTERY))); - device.setLastStatusTime(new Date()); - deviceService.updateDevice(device); - // 能上报信号说明门锁在线,顺带刷新在线状态 - deviceService.updateOnlineStatus(deviceSn, LockConstants.ONLINE); - updated++; - } - catch (Exception e) - { - // 单条失败不影响整批 - log.warn("信号电量更新失败, deviceSn={}, msg={}", deviceSn, e.getMessage()); - } - } - if (updated > 0) - { - log.info("信号电量处理完成, topic={}, total={}, updated={}", topic.getTopic(), records.size(), updated); - } - return updated; - } - - private Integer parseInt(String value) - { - if (StringUtils.isEmpty(value)) - { - return null; - } - try - { - return Integer.valueOf(value.trim()); - } - catch (NumberFormatException e) - { - return null; - } - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/RealIotSecurityGate.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/RealIotSecurityGate.java index 8e15acdb6..00219c2e8 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/RealIotSecurityGate.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/security/RealIotSecurityGate.java @@ -7,7 +7,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import com.ruoyi.iot.config.IotMqttProperties; +import com.ruoyi.iot.config.IotLockSecurityProperties; import com.ruoyi.iot.lock.LockConstants; import com.ruoyi.iot.lock.domain.*; import com.ruoyi.iot.lock.mapper.*; @@ -17,14 +17,14 @@ import com.ruoyi.iot.lock.protocol.*; @Component public class RealIotSecurityGate { private static final Logger log = LoggerFactory.getLogger(RealIotSecurityGate.class); - @Autowired private IotMqttProperties properties; + @Autowired private IotLockSecurityProperties properties; @Autowired private LockDeviceMapper deviceMapper; @Autowired private LockGatewayMapper gatewayMapper; @Autowired private LockGroupMapper groupMapper; @Autowired private LockAuthMapper authMapper; @Autowired private LockCommandMapper commandMapper; public RealIotSecurityGate() { } - public RealIotSecurityGate(IotMqttProperties properties) { this.properties = properties; } + public RealIotSecurityGate(IotLockSecurityProperties properties) { this.properties = properties; } public boolean isMock() { return properties != null && properties.isMock(); } private static final java.util.Set REAL_MODE_CONTROL = new java.util.HashSet<>(java.util.Arrays.asList( LockConstants.CMD_BIND, LockConstants.CMD_UNBIND, LockConstants.CMD_SYNC_TIME, diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockCommandService.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockCommandService.java index f8384b091..f3527600e 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockCommandService.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockCommandService.java @@ -6,7 +6,9 @@ import com.ruoyi.iot.lock.domain.LockCommand; /** * 下行命令服务 *

- * 负责命令生成与落库、发布到MQTT、响应匹配、超时判定与重试补偿。 + * 负责命令生成与落库、响应匹配、超时判定与重试补偿。 + *

+ * 说明:MQTT 传输层已移除,命令落库后不再向外下发,派发时统一置为失败(TRANSPORT_DISABLED)。 * * @author ruoyi */ diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockMqttLogService.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockMqttLogService.java deleted file mode 100644 index eaa7559d1..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/ILockMqttLogService.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.ruoyi.iot.lock.service; - -import java.util.List; -import com.ruoyi.iot.lock.domain.LockMqttLog; - -/** - * MQTT报文留痕服务 - * - * @author ruoyi - */ -public interface ILockMqttLogService -{ - List selectMqttLogList(LockMqttLog mqttLog); - - LockMqttLog selectMqttLogById(Long logId); - - int insertMqttLog(LockMqttLog mqttLog); - - int deleteMqttLogByIds(Long[] logIds); - - int cleanBefore(String keepDate); -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java index a425e53d6..b1c9b7a18 100644 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java +++ b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockCommandServiceImpl.java @@ -6,7 +6,6 @@ import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.uuid.IdUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.context.annotation.Lazy; import org.springframework.core.task.TaskExecutor; import org.springframework.stereotype.Service; import com.ruoyi.iot.lock.LockConstants; @@ -15,7 +14,6 @@ import com.ruoyi.iot.lock.mapper.*; import com.ruoyi.iot.lock.protocol.*; import com.ruoyi.iot.lock.security.*; import com.ruoyi.iot.lock.service.*; -import com.ruoyi.iot.service.IMqttService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,14 +25,10 @@ public class LockCommandServiceImpl implements ILockCommandService { /** 默认命令超时时间(秒) */ private static final int DEFAULT_TIMEOUT_SECONDS = 30; - @Autowired - private LockMqttLogMapper mqttLogMapper; - @Autowired private LockCommandMapper commandMapper; @Autowired private LockAuthMapper authMapper; @Autowired private LockExecutionTransactions transactions; @Autowired private RealIotSecurityGate securityGate; - @Lazy @Autowired private IMqttService mqttService; @Autowired @Qualifier("threadPoolTaskExecutor") private TaskExecutor taskExecutor; private static final Logger log = LoggerFactory.getLogger(LockCommandServiceImpl.class); @@ -64,8 +58,6 @@ public class LockCommandServiceImpl implements ILockCommandService { command.setMaxRetry(command.getMaxRetry() == null ? DEFAULT_MAX_RETRY : command.getMaxRetry()); command.setReqTime(new Date()); commandMapper.insertCommand(command); - // 报文留痕(下行) - recordLog(command); // Kingbase 下 useGeneratedKeys 不会回填自增主键,按唯一 intent_key 取回真实 command_id LockCommand persisted = commandMapper.selectCommandByIntentKey(command.getIntentKey()); final Long dispatchedId = persisted != null ? persisted.getCommandId() : command.getCommandId(); @@ -90,29 +82,6 @@ public class LockCommandServiceImpl implements ILockCommandService { return LockConstants.CMD_AUTH_PUSH.equals(command.getCommandType()) || LockConstants.CMD_AUTH_CLEAR.equals(command.getCommandType()); } - /** - * 下行报文留痕 - */ - private void recordLog(LockCommand command) - { - try - { - LockMqttLog logEntity = new LockMqttLog(); - logEntity.setDirection(LockConstants.DIRECTION_DOWN); - logEntity.setTopic(command.getTopic()); - logEntity.setPayload(command.getPayload()); - logEntity.setSeq(command.getCommandNo()); - logEntity.setRecvTime(command.getReqTime()); - logEntity.setHandleStatus(StringUtils.equals(command.getCommandStatus(), LockConstants.STATUS_FAIL) - ? LockConstants.HANDLE_FAIL - : LockConstants.HANDLE_SUCCESS); - mqttLogMapper.insertMqttLog(logEntity); - } - catch (Exception e) - { - log.warn("下行报文留痕失败, commandNo={}", command.getCommandNo()); - } - } // Always acquire the authorization lock before the command lock. private LockAuth lockAuth(LockCommand command) { return authCommand(command) && command.getBizId() != null @@ -166,47 +135,19 @@ public class LockCommandServiceImpl implements ILockCommandService { command.setErrorMsg("POLICY_BLOCKED_NOT_ATTEMPTED"); commandMapper.updateCommand(command); return null; } - if (!mqttService.isConnected()) { - log.warn("[TRACE-DISPATCH] cid={} isConnected=false -> TRANSPORT_DISCONNECTED", commandId); - command.setCommandStatus(LockConstants.STATUS_FAIL); - command.setErrorMsg("TRANSPORT_DISCONNECTED_NOT_ATTEMPTED"); - commandMapper.updateCommand(command); return null; - } - command.setAttemptStartedAt(new Date()); - transition(command, ExecutionStates.DISPATCHING, LockConstants.STATUS_PENDING, "DISPATCH_CLAIM_COMMITTED"); - project(command, ExecutionStates.DISPATCHING, LockConstants.SYNC_PENDING, "DISPATCH_CLAIM_COMMITTED"); - return command; + // MQTT 传输层已移除:命令不再下发到真实设备,直接置为失败,避免状态悬挂 + log.warn("[TRACE-DISPATCH] cid={} MQTT transport removed -> TRANSPORT_DISABLED", commandId); + command.setCommandStatus(LockConstants.STATUS_FAIL); + command.setErrorMsg("TRANSPORT_DISABLED"); + commandMapper.updateCommand(command); + return null; }); - if (claimed == null) return false; - try { - mqttService.publishCommand(claimed); - transactions.committed(() -> { - lockAuth(claimed); - LockCommand current = commandMapper.selectCommandByIdForUpdate(commandId); - if (ExecutionStates.DISPATCHING.equals(current.getExecutionState())) { - transition(current, ExecutionStates.AWAITING_ACK, LockConstants.STATUS_SENT, "AWAITING_DEVICE_CONFIRMATION"); - project(current, ExecutionStates.AWAITING_ACK, LockConstants.SYNC_SENT, "AWAITING_DEVICE_CONFIRMATION"); - } - return null; - }); - } catch (RuntimeException uncertain) { - transactions.committed(() -> { - lockAuth(claimed); - LockCommand current = commandMapper.selectCommandByIdForUpdate(commandId); - if (ExecutionStates.DISPATCHING.equals(current.getExecutionState())) { - transition(current, ExecutionStates.MANUAL_REVIEW, LockConstants.STATUS_TIMEOUT, "TRANSPORT_OUTCOME_UNKNOWN_NO_AUTOREPLAY"); - project(current, ExecutionStates.MANUAL_REVIEW, LockConstants.SYNC_FAIL, "TRANSPORT_OUTCOME_UNKNOWN_NO_AUTOREPLAY"); - } - return null; - }); - } - return true; + return false; } @Override public int retryPending() { - int sent = 0; - for (LockCommand command : commandMapper.selectReadyCommands()) if (dispatch(command.getCommandId())) sent++; - return sent; + // MQTT 传输层已移除,不再存在可补偿下发的命令 + return 0; } @Override public void retry(Long commandId) { LockCommand command = commandMapper.selectCommandById(commandId); diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockMqttLogServiceImpl.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockMqttLogServiceImpl.java deleted file mode 100644 index 3d8e4c820..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/service/impl/LockMqttLogServiceImpl.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.ruoyi.iot.lock.service.impl; - -import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; -import com.ruoyi.iot.lock.domain.LockMqttLog; -import com.ruoyi.iot.lock.mapper.LockMqttLogMapper; -import com.ruoyi.iot.lock.service.ILockMqttLogService; - -/** - * MQTT报文留痕服务实现 - * - * @author ruoyi - */ -@Service -public class LockMqttLogServiceImpl implements ILockMqttLogService -{ - @Autowired - private LockMqttLogMapper mqttLogMapper; - - @Override - public List selectMqttLogList(LockMqttLog mqttLog) - { - return mqttLogMapper.selectMqttLogList(mqttLog); - } - - @Override - public LockMqttLog selectMqttLogById(Long logId) - { - return mqttLogMapper.selectMqttLogById(logId); - } - - @Override - public int insertMqttLog(LockMqttLog mqttLog) - { - return mqttLogMapper.insertMqttLog(mqttLog); - } - - @Override - public int deleteMqttLogByIds(Long[] logIds) - { - return mqttLogMapper.deleteMqttLogByIds(logIds); - } - - @Override - public int cleanBefore(String keepDate) - { - return mqttLogMapper.deleteMqttLogBefore(keepDate); - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/GatewayOfflineCheckTask.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/GatewayOfflineCheckTask.java deleted file mode 100644 index 9cfaf004f..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/GatewayOfflineCheckTask.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.ruoyi.iot.lock.task; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import com.ruoyi.iot.lock.service.ILockGatewayService; - -/** - * 网关离线检测:超过指定时间未上报即置为离线 - * - * @author ruoyi - */ -@Component -public class GatewayOfflineCheckTask -{ - private static final Logger log = LoggerFactory.getLogger(GatewayOfflineCheckTask.class); - - /** 超过该分钟数未上报判定为离线 */ - private static final int OFFLINE_MINUTES = 10; - - @Autowired - private ILockGatewayService gatewayService; - - @Scheduled(initialDelay = 60000L, fixedDelay = 300000L) - public void execute() - { - try - { - int count = gatewayService.markOffline(OFFLINE_MINUTES); - if (count > 0) - { - log.info("网关离线检测完成, 本次置离线 {} 台", count); - } - } - catch (Exception e) - { - log.error("网关离线检测失败", e); - } - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockCommandRetryTask.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockCommandRetryTask.java deleted file mode 100644 index f5e837776..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockCommandRetryTask.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.ruoyi.iot.lock.task; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import com.ruoyi.iot.lock.service.ILockAuthService; -import com.ruoyi.iot.lock.service.ILockCommandService; - -/** - * 命令补偿任务:超时判定与失败重试 - * - * @author ruoyi - */ -@Component -public class LockCommandRetryTask -{ - private static final Logger log = LoggerFactory.getLogger(LockCommandRetryTask.class); - - /** 命令超时时间(秒) */ - private static final int TIMEOUT_SECONDS = 30; - - @Autowired - private ILockCommandService commandService; - - @Autowired - private ILockAuthService authService; - - @Autowired(required = false) - private com.ruoyi.iot.config.IotMqttProperties properties; - - @Scheduled(initialDelay = 2000L, fixedDelay = 2000L) - public void execute() - { - if (properties == null || !properties.isRetryTaskEnabled()) - { - return; - } - try - { - int timeout = commandService.markTimeout(TIMEOUT_SECONDS); - int retried = commandService.retryPending(); - int authPushed = authService.retryPending(); - if (timeout + retried + authPushed > 0) - { - log.info("命令补偿完成, 超时={}, 重试={}, 授权补发={}", timeout, retried, authPushed); - } - } - catch (Exception e) - { - log.error("命令补偿任务执行失败", e); - } - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockDeviceOfflineCheckTask.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockDeviceOfflineCheckTask.java deleted file mode 100644 index b7bfe9310..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/lock/task/LockDeviceOfflineCheckTask.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.ruoyi.iot.lock.task; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import com.ruoyi.iot.lock.service.ILockDeviceService; - -/** - * 门锁离线检测:超过指定时间未上报即置为离线 - *

- * 门锁按 1 分钟周期上报信号与电量(op=lockrssi),连续 3 分钟未收到即判定离线。 - * 检测周期 30 秒,保证最迟在超时后 30 秒内被发现。 - * - * @author ruoyi - */ -@Component -public class LockDeviceOfflineCheckTask -{ - private static final Logger log = LoggerFactory.getLogger(LockDeviceOfflineCheckTask.class); - - /** 超过该分钟数未上报判定为离线(上报周期 1 分钟,取 3 倍冗余) */ - private static final int OFFLINE_MINUTES = 3; - - @Autowired - private ILockDeviceService deviceService; - - @Scheduled(initialDelay = 30000L, fixedDelay = 30000L) - public void execute() - { - try - { - int count = deviceService.markOffline(OFFLINE_MINUTES); - if (count > 0) - { - log.info("门锁离线检测完成, 超时 {} 分钟未上报, 本次置离线 {} 把", OFFLINE_MINUTES, count); - } - } - catch (Exception e) - { - log.error("门锁离线检测失败", e); - } - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/service/IMqttService.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/service/IMqttService.java deleted file mode 100644 index 4d6f1ef3b..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/service/IMqttService.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.ruoyi.iot.service; - -import java.util.Map; - -/** - * MQTT服务接口 - * - * @author ruoyi - */ -public interface IMqttService -{ - /** Only a persisted, claimed business command may enter real transport. */ - default void publishCommand(com.ruoyi.iot.lock.domain.LockCommand command) { - throw new SecurityException("Command-aware transport is required"); - } - /** - * 客户端是否已连接 - */ - boolean isConnected(); - - /** - * 发布消息(使用默认服务质量与保留策略) - * - * @param topic 主题 - * @param payload 消息内容 - */ - void publish(String topic, String payload); - - /** - * 发布消息 - * - * @param topic 主题 - * @param payload 消息内容 - * @param qos 服务质量,为空取默认配置 - * @param retained 是否保留,为空取默认配置 - */ - void publish(String topic, Object payload, Integer qos, Boolean retained); - - /** - * 订阅主题(使用默认服务质量) - * - * @param topic 主题 - */ - void subscribe(String topic); - - /** - * 订阅主题 - * - * @param topic 主题 - * @param qos 服务质量,为空取默认配置 - */ - void subscribe(String topic, Integer qos); - - /** - * 取消订阅 - * - * @param topic 主题 - */ - void unsubscribe(String topic); - - /** - * 手动重连 - */ - void reconnect(); - - /** - * 当前已订阅的主题与服务质量的映射 - */ - Map getSubscriptions(); - - /** - * 模拟设备上行回执注入 (用于 QA 与端到端测试驱动) - */ - default void mockReceive(String topic, String payload) {} - - /** - * 获取所有拦截到的 Mock 下行消息 - */ - default java.util.List> getMockPublishedMessages() - { - return java.util.Collections.emptyList(); - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/service/impl/MqttServiceImpl.java b/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/service/impl/MqttServiceImpl.java deleted file mode 100644 index 6864877e2..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/java/com/ruoyi/iot/service/impl/MqttServiceImpl.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.ruoyi.iot.service.impl; - -import java.util.Map; -import org.springframework.stereotype.Service; -import com.ruoyi.iot.core.MqttClientManager; -import com.ruoyi.iot.service.IMqttService; - -/** - * MQTT服务实现 - * - * @author ruoyi - */ -@Service -public class MqttServiceImpl implements IMqttService -{ - private final MqttClientManager clientManager; - - public MqttServiceImpl(MqttClientManager clientManager) - { - this.clientManager = clientManager; - } - - @Override - public boolean isConnected() - { - return clientManager.isConnected(); - } - - @Override - public void publishCommand(com.ruoyi.iot.lock.domain.LockCommand command) { - clientManager.publishCommand(command); - } - - @Override - public void publish(String topic, String payload) - { - clientManager.publish(topic, payload, null, null); - } - - @Override - public void publish(String topic, Object payload, Integer qos, Boolean retained) - { - clientManager.publish(topic, payload, qos, retained); - } - - @Override - public void subscribe(String topic) - { - clientManager.subscribe(topic, null); - } - - @Override - public void subscribe(String topic, Integer qos) - { - clientManager.subscribe(topic, qos); - } - - @Override - public void unsubscribe(String topic) - { - clientManager.unsubscribe(topic); - } - - @Override - public void reconnect() - { - clientManager.reconnect(); - } - - @Override - public Map getSubscriptions() - { - return clientManager.getSubscriptions(); - } - - @Override - public void mockReceive(String topic, String payload) - { - clientManager.mockReceive(topic, payload); - } - - @Override - public java.util.List> getMockPublishedMessages() - { - return clientManager.getMockPublishedMessages(); - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/main/resources/mapper/lock/LockMqttLogMapper.xml b/RuoYi-Vue/ruoyi-iot/src/main/resources/mapper/lock/LockMqttLogMapper.xml deleted file mode 100644 index ce9d6ba32..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/main/resources/mapper/lock/LockMqttLogMapper.xml +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - select log_id, direction, topic, group_code, gateway_sn, device_sn, op, mode, ac, seq, qos, - retained, payload, chksum, verify_status, handle_status, handle_msg, cost_time, - recv_time, create_time from lock_mqtt_log - - - - - - - - insert into lock_mqtt_log ( - direction, topic, group_code, gateway_sn, device_sn, op, mode, ac, seq, qos, retained, - payload, chksum, verify_status, handle_status, handle_msg, cost_time, recv_time, create_time - ) values ( - #{direction}, #{topic}, #{groupCode}, #{gatewaySn}, #{deviceSn}, #{op}, #{mode}, #{ac}, - #{seq}, #{qos}, #{retained}, #{payload}, #{chksum}, #{verifyStatus}, #{handleStatus}, - #{handleMsg}, #{costTime}, #{recvTime}, CURRENT_TIMESTAMP - ) - - - - delete from lock_mqtt_log where log_id in - - #{logId} - - - - - delete from lock_mqtt_log where - - recv_time < to_date(#{keepDate},'yyyy-mm-dd') - recv_time < #{keepDate} - - - - diff --git a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/mqtt/handler/CommandRespHandlerMockTest.java b/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/mqtt/handler/CommandRespHandlerMockTest.java deleted file mode 100644 index bcf82aef5..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/mqtt/handler/CommandRespHandlerMockTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.ruoyi.iot.lock.mqtt.handler; - -import static org.junit.Assert.*; - -import java.lang.reflect.Field; -import java.lang.reflect.Proxy; -import com.alibaba.fastjson2.JSON; -import com.ruoyi.iot.lock.protocol.YdlinkFrameDTO; -import com.ruoyi.iot.lock.protocol.YdlinkOp; -import com.ruoyi.iot.lock.protocol.YdlinkTopicDTO; -import com.ruoyi.iot.lock.service.ILockAccessLogService; -import com.ruoyi.iot.lock.service.ILockAuthService; -import com.ruoyi.iot.lock.service.ILockCommandService; -import org.junit.Test; - -public class CommandRespHandlerMockTest -{ - @Test - public void testDoubleAckAndFinalReceiptFlow() throws Exception - { - CommandRespHandler handler = new CommandRespHandler(); - - final String[] commandStatus = new String[]{"1"}; // 初始为 1: 已发送 - - ILockCommandService mockCommandService = (ILockCommandService) Proxy.newProxyInstance( - ILockCommandService.class.getClassLoader(), - new Class[]{ILockCommandService.class}, - (proxy, method, args) -> { - String methodName = method.getName(); - if ("handleGatewayAck".equals(methodName)) { - // 网关 ACK 回执,返回 null 表示回执已记录,等待另一端回执 - return null; - } - if ("handleResponse".equals(methodName)) { - // 门锁终态回执,返回终态 "2" (LockConstants.STATUS_SUCCESS) - commandStatus[0] = "2"; - return "2"; - } - return null; - } - ); - - ILockAuthService mockAuthService = (ILockAuthService) Proxy.newProxyInstance( - ILockAuthService.class.getClassLoader(), - new Class[]{ILockAuthService.class}, - (proxy, method, args) -> null - ); - - ILockAccessLogService mockAccessLogService = (ILockAccessLogService) Proxy.newProxyInstance( - ILockAccessLogService.class.getClassLoader(), - new Class[]{ILockAccessLogService.class}, - (proxy, method, args) -> null - ); - - setField(handler, "commandService", mockCommandService); - setField(handler, "authService", mockAuthService); - setField(handler, "accessLogService", mockAccessLogService); - - YdlinkTopicDTO topic = new YdlinkTopicDTO(); - setField(topic, "topic", "sys/E8F7AFF69483/lock/response"); - - // 阶段 1:模拟收到网关第 1 回执 (ACK 中间态, 结果码 10) - // params = ["add", "807104B5", "10", "AA000006", "AA000006", "null"] - YdlinkFrameDTO gatewayAckFrame = new YdlinkFrameDTO(); - gatewayAckFrame.setSeq("SEQ-123456"); - gatewayAckFrame.setOp(YdlinkOp.PASSWORD); - gatewayAckFrame.setParams(JSON.parseArray("[\"add\", \"807104B5\", \"10\", \"AA000006\", \"AA000006\", \"null\"]")); - - String ackResult = handler.handle(topic, gatewayAckFrame, "{\"mockPayload\":\"ack\"}"); - - System.out.println("[CommandRespHandlerMockTest] 网关第1回执处理结果: " + ackResult); - assertEquals("网关已接收,等待门锁执行结果", ackResult); - assertEquals("网关回执期间命令状态必须维持已发送(1)", "1", commandStatus[0]); - - // 阶段 2:模拟收到门锁第 2 回执 (终态, 结果码 0) - // params = ["add", "807104B5", "0", "", "msg received"] - YdlinkFrameDTO deviceFinalFrame = new YdlinkFrameDTO(); - deviceFinalFrame.setSeq("SEQ-123456"); - deviceFinalFrame.setOp(YdlinkOp.PASSWORD); - deviceFinalFrame.setParams(JSON.parseArray("[\"add\", \"807104B5\", \"0\", \"\", \"msg received\"]")); - - String finalResult = handler.handle(topic, deviceFinalFrame, "{\"mockPayload\":\"final\"}"); - - System.out.println("[CommandRespHandlerMockTest] 门锁第2回执处理结果: " + finalResult); - assertEquals("执行成功", finalResult); - assertEquals("门锁终态回执后命令状态必须转为终态成功(2)", "2", commandStatus[0]); - } - - private static void setField(Object target, String name, Object val) throws Exception - { - Field f = target.getClass().getDeclaredField(name); - f.setAccessible(true); - f.set(target, val); - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/remote/FaceExtractClientMockTest.java b/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/remote/FaceExtractClientMockTest.java index 90e7a7763..b6c50f3e4 100644 --- a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/remote/FaceExtractClientMockTest.java +++ b/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/remote/FaceExtractClientMockTest.java @@ -65,7 +65,7 @@ public class FaceExtractClientMockTest props.setMock(false); props.setEnabled(true); props.setRealHttpEnabled(true); props.setEnvironmentId("isolated-test-real"); props.setAllowedEndpoints(java.util.Arrays.asList(props.getEndpoint())); - com.ruoyi.iot.config.IotMqttProperties mqtt = new com.ruoyi.iot.config.IotMqttProperties(); + com.ruoyi.iot.config.IotLockSecurityProperties mqtt = new com.ruoyi.iot.config.IotLockSecurityProperties(); mqtt.setEnabled(true); mqtt.setRealDeviceWriteEnabled(true); mqtt.setEnvironmentId(props.getEnvironmentId()); mqtt.setDeviceWhitelist(java.util.Arrays.asList("TEST0001")); Field gateField = FaceExtractClient.class.getDeclaredField("securityGate"); gateField.setAccessible(true); diff --git a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/RealIotSecurityGateTest.java b/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/RealIotSecurityGateTest.java index b15284b3a..9c863d577 100644 --- a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/RealIotSecurityGateTest.java +++ b/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/RealIotSecurityGateTest.java @@ -2,20 +2,19 @@ package com.ruoyi.iot.lock.security; import static org.junit.Assert.*; import java.util.*; import org.junit.Test; -import com.ruoyi.iot.config.IotMqttProperties; -import com.ruoyi.iot.core.MqttClientManager; +import com.ruoyi.iot.config.IotLockSecurityProperties; import com.ruoyi.iot.lock.domain.LockAuth; public class RealIotSecurityGateTest { @Test public void defaultsAreClosed() { - IotMqttProperties p=new IotMqttProperties(); + IotLockSecurityProperties p=new IotLockSecurityProperties(); RealIotSecurityGate g=new RealIotSecurityGate(p); - assertFalse(p.isEnabled());assertFalse(p.isRealDeviceWriteEnabled());assertFalse(p.isRetryTaskEnabled()); + assertFalse(p.isEnabled());assertFalse(p.isRealDeviceWriteEnabled()); assertTrue(p.getDeviceWhitelist().isEmpty());assertFalse(g.checkDeviceWriteAllowed("TEST0001","0011")); assertFalse(g.sameOrigin(new LockAuth())); } @Test public void explicitIdentityOnly() { - IotMqttProperties p=new IotMqttProperties();p.setEnabled(true);p.setRealDeviceWriteEnabled(true); + IotLockSecurityProperties p=new IotLockSecurityProperties();p.setEnabled(true);p.setRealDeviceWriteEnabled(true); RealIotSecurityGate g=new RealIotSecurityGate(p); assertFalse(g.checkDeviceWriteAllowed("TEST0001","0011")); p.setDeviceWhitelist(Arrays.asList("TEST0001")); @@ -24,17 +23,13 @@ public class RealIotSecurityGateTest { assertFalse(g.checkDeviceWriteAllowed("UNKNOWN","0011")); } @Test public void provenanceIsStableAcrossGateRestartButNotAcrossEnvironments() { - IotMqttProperties p=new IotMqttProperties();p.setMock(true);p.setEnvironmentId("test-mock"); + IotLockSecurityProperties p=new IotLockSecurityProperties();p.setMock(true);p.setEnvironmentId("test-mock"); RealIotSecurityGate first=new RealIotSecurityGate(p);LockAuth a=new LockAuth();first.initializeIntent(a,ExecutionStates.REVOKE); a.setIntentAt(new Date(0));assertTrue(new RealIotSecurityGate(p).sameOrigin(a)); p.setMock(false);p.setEnvironmentId("test-real");assertFalse(new RealIotSecurityGate(p).sameOrigin(a)); } @Test public void personNumberIsStrict() { - RealIotSecurityGate g=new RealIotSecurityGate(new IotMqttProperties()); + RealIotSecurityGate g=new RealIotSecurityGate(new IotLockSecurityProperties()); assertTrue(g.isValidPersonNo("ZZ000001"));assertFalse(g.isValidPersonNo("ZZ1"));assertFalse(g.isValidPersonNo("ZZ000001 ")); } - @Test(expected=SecurityException.class) public void rawRealPublicationAlwaysDenied() { - IotMqttProperties p=new IotMqttProperties();p.setRealDeviceWriteEnabled(true); - new MqttClientManager(p,null).publish("ydlink/TEST/thing/data/batch","{}",1,false); - } } diff --git a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/Round5sEgressIsolationTest.java b/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/Round5sEgressIsolationTest.java deleted file mode 100644 index 557c5ed60..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/Round5sEgressIsolationTest.java +++ /dev/null @@ -1,125 +0,0 @@ -package com.ruoyi.iot.lock.security; - -import static org.junit.Assert.*; -import java.io.*; -import java.lang.reflect.*; -import java.util.*; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.Test; -import org.springframework.boot.context.properties.bind.Binder; -import org.springframework.core.env.*; -import jakarta.servlet.*; -import jakarta.servlet.http.*; -import com.alibaba.fastjson2.JSONObject; -import com.ruoyi.iot.config.*; -import com.ruoyi.iot.lock.remote.FaceExtractClient; -import com.ruoyi.iot.lock.service.ILockServiceConfigService; -import com.ruoyi.iot.lock.domain.LockCommand; -import com.ruoyi.iot.openapi.config.OpenApiProperties; -import com.ruoyi.iot.openapi.filter.OpenApiAuthFilter; - -public class Round5sEgressIsolationTest { - private void inject(Object obj,String key,Object value)throws Exception {Field f=obj.getClass().getDeclaredField(key);f.setAccessible(true);f.set(obj,value);} - private void denied(Runnable work){try{work.run();fail("Expected denial");}catch(SecurityException expected){}} - private FaceExtractClient face(FaceExtractProperties props,AtomicInteger configReads)throws Exception { - FaceExtractClient client=new FaceExtractClient();inject(client,"properties",props); - IotMqttProperties mqtt=new IotMqttProperties();mqtt.setEnabled(true);mqtt.setRealDeviceWriteEnabled(true); - mqtt.setEnvironmentId("isolated-test-real");mqtt.setDeviceWhitelist(Arrays.asList("TEST0001")); - inject(client,"securityGate",new RealIotSecurityGate(mqtt)); - inject(client,"configService",Proxy.newProxyInstance(ILockServiceConfigService.class.getClassLoader(),new Class[]{ILockServiceConfigService.class},(p,m,a)->{ - configReads.incrementAndGet();JSONObject json=new JSONObject();json.put("endpoint","http://192.0.2.10/unapproved");return json; - }));return client; - } - @Test public void mockFlagIsActuallyBoundAndWinsOverDatabaseEndpoint()throws Exception { - StandardEnvironment env=new StandardEnvironment();env.getPropertySources().addFirst(new MapPropertySource("test",Map.of("iot.face.mock","true","iot.face.endpoint","http://192.0.2.10/unapproved"))); - FaceExtractProperties props=Binder.get(env).bind("iot.face",FaceExtractProperties.class).get();assertTrue(props.isMock()); - AtomicInteger reads=new AtomicInteger();String result=face(props,reads).extract(new byte[]{0},"synthetic.jpg","test"); - assertTrue(result.startsWith("mock_"));assertEquals(0,reads.get()); - } - @Test public void realFaceHttpNeedsExplicitNetworkAuthorization()throws Exception { - FaceExtractProperties props=new FaceExtractProperties();props.setMock(false);props.setEnvironmentId("isolated-test-real"); - AtomicInteger reads=new AtomicInteger();FaceExtractClient client=face(props,reads); - denied(()->client.extract(new byte[]{0},"synthetic.jpg","test","TEST0001"));assertEquals(0,reads.get()); - } - @Test public void databaseEndpointOverrideCannotEscapeEndpointWhitelist()throws Exception { - FaceExtractProperties props=new FaceExtractProperties();props.setMock(false);props.setEnabled(true);props.setRealHttpEnabled(true); - props.setEnvironmentId("isolated-test-real");props.setAllowedEndpoints(Arrays.asList("http://127.0.0.1:1/approved")); - FaceExtractClient client=face(props,new AtomicInteger()); - denied(()->client.extract(new byte[]{0},"synthetic.jpg","test","TEST0001")); - denied(()->client.extract(new byte[]{0},"synthetic.jpg","test","UNKNOWN")); - denied(()->client.extract(new byte[]{0},"synthetic.jpg","test")); - } - @Test public void faceEnvironmentMismatchIsDeniedBeforeConfigOrNetwork()throws Exception { - FaceExtractProperties props=new FaceExtractProperties();props.setMock(false);props.setEnabled(true);props.setRealHttpEnabled(true); - props.setEnvironmentId("other-real");AtomicInteger reads=new AtomicInteger();FaceExtractClient client=face(props,reads); - denied(()->client.extract(new byte[]{0},"synthetic.jpg","test","TEST0001"));assertEquals(0,reads.get()); - } - private int filter(boolean enabled,String configuredKey,String presentedKey,String context,String configuredPrefix)throws Exception { - return filterPath(enabled,configuredKey,presentedKey,context,configuredPrefix,"/openapi/iot/auth"); - } - private int filterPath(boolean enabled,String configuredKey,String presentedKey,String context,String configuredPrefix,String path)throws Exception { - OpenApiProperties props=new OpenApiProperties();props.setEnabled(enabled);props.setApiKey(configuredKey);props.setPathPrefix(configuredPrefix); - OpenApiAuthFilter filter=new OpenApiAuthFilter();inject(filter,"properties",props);AtomicInteger status=new AtomicInteger();AtomicInteger passed=new AtomicInteger(); - HttpServletRequest req=(HttpServletRequest)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{HttpServletRequest.class},(p,m,a)->{ - if(m.getName().equals("getRequestURI"))return context+path; - if(m.getName().equals("getContextPath"))return context; - if(m.getName().equals("getHeader"))return presentedKey; - return null; - }); - HttpServletResponse resp=(HttpServletResponse)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{HttpServletResponse.class},(p,m,a)->{ - if(m.getName().equals("setStatus"))status.set((Integer)a[0]); - if(m.getName().equals("getWriter"))return new PrintWriter(new StringWriter());return null; - }); - filter.doFilter(req,resp,(r,s)->passed.incrementAndGet());return passed.get()>0?200:status.get(); - } - @Test public void openApiDefaultAndMissingOrShortKeysFailClosed()throws Exception { - assertEquals(404,filter(false,null,null,"","/openapi/iot")); - assertEquals(401,filter(true,null,null,"","/openapi/iot")); - assertEquals(401,filter(true,"short-test-key","short-test-key","","/openapi/iot")); - } - @Test public void openApiPrefixOverrideAndContextPathCannotBypassAuthentication()throws Exception { - String key=UUID.randomUUID().toString()+UUID.randomUUID(); - assertEquals(401,filter(true,key,null,"/application","/unrelated")); - assertEquals(200,filter(true,key,key,"/application","/unrelated")); - } - @Test public void encodedOrMatrixPathsCannotBypassOpenApiAuthentication()throws Exception { - String key=UUID.randomUUID().toString()+UUID.randomUUID(); - assertEquals(401,filterPath(true,key,null,"/application","/unrelated","/openapi%2Fiot/auth")); - assertEquals(401,filterPath(true,key,null,"","/unrelated","/openapi/iot;ignored=1/auth")); - assertEquals(404,filterPath(false,key,null,"","/unrelated","/openapi%2Fiot/auth")); - } - @Test public void provenanceCannotBeInjectedThroughJson()throws Exception { - tools.jackson.databind.json.JsonMapper mapper=tools.jackson.databind.json.JsonMapper.builder().build(); - LockCommand command=mapper.readValue("{\"executionMode\":\"REAL\",\"environmentId\":\"fake-real\",\"intentVersion\":99,\"intentKey\":\"fake\",\"executionState\":\"DISPATCHING\"}",LockCommand.class); - assertNull(command.getExecutionMode());assertNull(command.getIntentKey());assertNull(command.getExecutionState());assertNull(command.getIntentVersion()); - } - @Test public void rawMqttControllerIsAbsentInRealModeOrWithoutExplicitQaOptIn() { - for (Map values:Arrays.>asList( - Map.of("iot.mqtt.mock","false","iot.qa-api-enabled","true"), - Map.of("iot.mqtt.mock","true","iot.qa-api-enabled","false"))) { - try(org.springframework.context.annotation.AnnotationConfigApplicationContext context = - new org.springframework.context.annotation.AnnotationConfigApplicationContext()) { - context.getEnvironment().getPropertySources().addFirst(new MapPropertySource("test",values)); - context.register(com.ruoyi.iot.controller.IotMqttController.class);context.refresh(); - assertTrue(context.getBeansOfType(com.ruoyi.iot.controller.IotMqttController.class).isEmpty()); - } - } - } - @Test public void checkedInYamlDefaultsBindToClosedCapabilities() throws Exception { - StandardEnvironment env=new StandardEnvironment(); - env.getPropertySources().remove(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME); - env.getPropertySources().remove(StandardEnvironment.SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME); - for(PropertySource source:new org.springframework.boot.env.YamlPropertySourceLoader().load("defaults", - new org.springframework.core.io.FileSystemResource("../ruoyi-admin/src/main/resources/application.yml"))) { - env.getPropertySources().addLast(source); - } - IotMqttProperties mqtt=Binder.get(env).bind("iot.mqtt",IotMqttProperties.class).get(); - assertFalse(mqtt.isEnabled());assertTrue(mqtt.isMock());assertFalse(mqtt.isRealDeviceWriteEnabled()); - assertTrue(mqtt.getDeviceWhitelist().isEmpty());assertFalse(mqtt.isRetryTaskEnabled());assertFalse(mqtt.isProtocolVerified()); - FaceExtractProperties face=Binder.get(env).bind("iot.face",FaceExtractProperties.class).get(); - assertTrue(face.isMock());assertFalse(face.isEnabled());assertFalse(face.isRealHttpEnabled());assertTrue(face.getAllowedEndpoints().isEmpty()); - OpenApiProperties api=Binder.get(env).bind("iot.openapi",OpenApiProperties.class).get(); - assertFalse(api.isEnabled());assertTrue(api.getApiKey()==null||api.getApiKey().isEmpty()); - assertEquals("",env.getProperty("token.secret")); - } -} diff --git a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/Round5sExecutionIntegrationTest.java b/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/Round5sExecutionIntegrationTest.java deleted file mode 100644 index 49a1847b5..000000000 --- a/RuoYi-Vue/ruoyi-iot/src/test/java/com/ruoyi/iot/lock/security/Round5sExecutionIntegrationTest.java +++ /dev/null @@ -1,328 +0,0 @@ -package com.ruoyi.iot.lock.security; - -import static org.junit.Assert.*; -import java.io.*; -import java.lang.reflect.*; -import java.nio.file.*; -import java.sql.*; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.*; -import javax.sql.DataSource; -import org.h2.jdbcx.JdbcDataSource; -import org.h2.tools.RunScript; -import org.junit.*; -import org.mybatis.spring.*; -import org.springframework.core.io.ClassPathResource; -import org.springframework.jdbc.datasource.*; -import com.ruoyi.iot.config.IotMqttProperties; -import com.ruoyi.iot.core.MqttClientManager; -import com.ruoyi.iot.lock.LockConstants; -import com.ruoyi.iot.lock.domain.*; -import com.ruoyi.iot.lock.mapper.*; -import com.ruoyi.iot.lock.protocol.*; -import com.ruoyi.iot.lock.service.*; -import com.ruoyi.iot.lock.service.impl.*; -import com.ruoyi.iot.service.IMqttService; - -/** Real Services + production MyBatis XML + real local SQL transactions. No remote service is used. */ -public class Round5sExecutionIntegrationTest { - private DataSource ds; - private LockAuthMapper authMapper; - private LockCommandMapper commandMapper; - private LockExecutionTransactions tx; - private IotMqttProperties props; - private RealIotSecurityGate gate; - private LockAuthServiceImpl auths; - private LockCommandServiceImpl commands; - private LockDevice device; - private LockGateway gateway; - private LockGroup group; - private Transport transport; - private SqlSessionTemplate sessions; - - @Before public void setup() throws Exception { - JdbcDataSource raw = new JdbcDataSource(); - Path cases=Files.createDirectories(Paths.get("target/round5s-db")); - Path database=Files.createTempDirectory(cases,"case-").resolve("execution").toAbsolutePath(); - raw.setURL("jdbc:h2:file:" + database + ";MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE"); - try (Connection c = raw.getConnection()) { - RunScript.execute(c, new InputStreamReader(new ClassPathResource("round5s-before-migration.sql").getInputStream())); - RunScript.execute(c, Files.newBufferedReader(migration())); - } - ds = new AbstractDataSource() { - @Override public Connection getConnection() throws SQLException { Connection c = raw.getConnection(); c.setSchema("iotlocks"); return c; } - @Override public Connection getConnection(String u, String p) throws SQLException { return getConnection(); } - }; - SqlSessionFactoryBean factory = new SqlSessionFactoryBean(); factory.setDataSource(ds); - factory.setTypeAliasesPackage("com.ruoyi.iot.lock.domain"); - factory.setMapperLocations(new ClassPathResource("mapper/lock/LockAuthMapper.xml"), new ClassPathResource("mapper/lock/LockCommandMapper.xml")); - sessions = new SqlSessionTemplate(factory.getObject()); - authMapper = sessions.getMapper(LockAuthMapper.class); commandMapper = sessions.getMapper(LockCommandMapper.class); - tx = new LockExecutionTransactions(new DataSourceTransactionManager(ds)); - props = new IotMqttProperties(); props.setMock(false); props.setEnabled(true); - props.setRealDeviceWriteEnabled(true); props.setProtocolVerified(true); - props.setEnvironmentId("isolated-test-real"); props.setDeviceWhitelist(Arrays.asList("TEST0001")); - device = new LockDevice(); device.setDeviceId(1L); device.setDeviceSn("TEST0001"); device.setGatewayId(2L); - device.setDevicePassword(UUID.randomUUID().toString()); device.setDevVer("11"); device.setDevType("19"); - gateway = new LockGateway(); gateway.setGatewayId(2L); gateway.setGatewaySn("TESTGW01"); gateway.setGroupId(3L); - group = new LockGroup(); group.setGroupId(3L); group.setGroupCode("TESTGROUP"); - transport = new Transport(); restartServices(); - } - private Path migration() { return Paths.get("../sql/iot/005_round5s_execution_safety.sql"); } - private void restartServices() throws Exception { - gate = new RealIotSecurityGate(props); - inject(gate, "deviceMapper", fake(LockDeviceMapper.class, device)); - inject(gate, "gatewayMapper", fake(LockGatewayMapper.class, gateway)); - inject(gate, "groupMapper", fake(LockGroupMapper.class, group)); - inject(gate, "authMapper", authMapper); inject(gate, "commandMapper", commandMapper); - commands = new LockCommandServiceImpl(); auths = new LockAuthServiceImpl(); - for (Object service : Arrays.asList(commands, auths)) { - inject(service, "commandMapper", commandMapper); inject(service, "authMapper", authMapper); - inject(service, "securityGate", gate); inject(service, "transactions", tx); - } - inject(commands, "mqttService", transport); - inject(auths, "deviceMapper", fake(LockDeviceMapper.class, device)); - inject(auths, "gatewayMapper", fake(LockGatewayMapper.class, gateway)); - inject(auths, "groupMapper", fake(LockGroupMapper.class, group)); - inject(auths, "commandService", commands); - } - static void inject(Object obj, String name, Object value) throws Exception { - Field f = obj.getClass().getDeclaredField(name); f.setAccessible(true); f.set(obj, value); - } - @SuppressWarnings("unchecked") private T fake(Class type, Object result) { - return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, (p,m,a) -> result); - } - private LockAuth fresh() { - LockAuth a = new LockAuth(); a.setPersonNo("ZZ000001"); a.setDeviceId(1L); - a.setDeviceSn(device.getDeviceSn()); a.setGatewayId(2L); a.setGroupId(3L); - a.setAuthType(LockConstants.AUTH_FACE); a.setAuthValue("synthetic-feature-only-in-isolated-test"); - a.setBeginTime(new java.util.Date(System.currentTimeMillis() - 1000)); - a.setEndTime(new java.util.Date(System.currentTimeMillis() + 600000)); - return a; - } - private LockCommand latest(LockAuth a) { - LockAuth current = authMapper.selectAuthById(a.getAuthId()); - return commandMapper.selectCommandByIntentKey(current.getIntentKey()); - } - private void ack(LockCommand c, String code) { - YdlinkFrameDTO request = YdlinkCodec.fromJson(c.getPayload()); - YdlinkFrameDTO response = new YdlinkFrameDTO(); response.setSeq(c.getCommandNo()); response.setOp(request.getOp()); - response.setFlatParams(Arrays.asList(String.valueOf(request.getParams().get(0)), c.getDeviceSn(), code, "", "test")); - String payload = YdlinkCodec.toJson(response); - if ("10".equals(code)) commands.handleGatewayAck(c.getTopic() + "_report", c.getCommandNo(), payload, "test"); - else commands.handleResponse(c.getTopic() + "_report", c.getCommandNo(), payload, "0".equals(code), "test"); - } - private LockAuth confirmedGrant() { - LockAuth a = fresh(); auths.insertAuth(a); LockCommand c = latest(a); ack(c,"10"); ack(c,"0"); - assertEquals(ExecutionStates.CONFIRMED, authMapper.selectAuthById(a.getAuthId()).getExecutionState()); - return a; - } - private void persistRevokeIntentOnly(LockAuth a) { - tx.required(() -> { - LockAuth current = authMapper.selectAuthByIdForUpdate(a.getAuthId()); - gate.advanceIntent(current, ExecutionStates.REVOKE); current.setStatus("1"); - current.setSyncStatus(LockConstants.SYNC_PENDING); assertEquals(1, authMapper.updateAuth(current)); return null; - }); - } - private void expectDenied(Runnable work) { try { work.run(); fail("Expected policy denial"); } catch (SecurityException expected) { } } - - @Test public void emptyWhitelistAndUnknownIdentityNeverReachTransport() { - props.setDeviceWhitelist(Collections.emptyList()); - assertFalse(gate.checkDeviceWriteAllowed(null,"0011")); assertFalse(gate.checkDeviceWriteAllowed("","0011")); - assertFalse(gate.checkDeviceWriteAllowed("TEST0001","0011")); - LockAuth a = fresh(); auths.insertAuth(a); assertEquals(0, transport.attempts.get()); - props.setDeviceWhitelist(Arrays.asList("UNKNOWN")); commands.retryPending(); assertEquals(0, transport.attempts.get()); - props.setDeviceWhitelist(Arrays.asList("TEST0001")); commands.retryPending(); assertEquals(1, transport.attempts.get()); - } - @Test public void groupIdentifierCannotServeAsDeviceWhitelist() { - props.setDeviceWhitelist(Arrays.asList(group.getGroupCode())); - LockAuth a = fresh(); auths.insertAuth(a); assertEquals(0, transport.attempts.get()); - } - @Test public void payloadGrantRevokeAndDualAckAreRealServiceResults() { - LockAuth a = fresh(); auths.insertAuth(a); - LockCommand grant = latest(a); YdlinkFrameDTO g = YdlinkCodec.fromJson(grant.getPayload()); - assertEquals("0011",grant.getCommandType()); assertEquals("facefeature",g.getOp()); - assertEquals(8,g.getParams().size()); assertEquals("add",g.getParams().get(0)); - assertEquals("ZZ000001",g.getParams().get(1)); assertEquals("TEST0001",g.getParams().get(6)); - assertEquals("ydlink/TESTGROUP/thing/data/batch",grant.getTopic()); assertEquals(grant.getCommandNo(),g.getSeq()); - ack(grant,"10"); assertEquals(LockConstants.SYNC_SENT, authMapper.selectAuthById(a.getAuthId()).getSyncStatus()); - ack(grant,"0"); auths.clear(a.getAuthId()); - LockCommand revoke = latest(a); YdlinkFrameDTO r = YdlinkCodec.fromJson(revoke.getPayload()); - assertEquals("0012",revoke.getCommandType()); assertEquals("facefeature",r.getOp()); - assertEquals(Arrays.asList("delete","ZZ000001","TEST0001",device.getDevicePassword()),r.getParams()); - ack(revoke,"0"); assertNotEquals(LockConstants.SYNC_CLEARED,authMapper.selectAuthById(a.getAuthId()).getSyncStatus()); - ack(revoke,"10"); assertEquals(LockConstants.SYNC_CLEARED,authMapper.selectAuthById(a.getAuthId()).getSyncStatus()); - } - @Test public void passwordLayoutAndMissingCredentialsAreValidated() { - LockAuth a=fresh();a.setAuthType(LockConstants.AUTH_PASSWORD);a.setAuthValue(String.valueOf(new java.security.SecureRandom().nextInt(900000)+100000)); - auths.insertAuth(a); assertEquals(7,YdlinkCodec.fromJson(latest(a).getPayload()).getParams().size()); - LockAuth invalid=fresh();invalid.setAuthValue("");auths.insertAuth(invalid); - assertNull(latest(invalid)); assertEquals(ExecutionStates.MANUAL_REVIEW,authMapper.selectAuthById(invalid.getAuthId()).getExecutionState()); - } - @Test public void mockHistoryCannotBePromotedOrRepushedAfterModeSwitch() throws Exception { - props.setMock(true); props.setEnvironmentId("isolated-mock"); transport.connected=false; - LockAuth a=fresh();auths.insertAuth(a);LockCommand mock=latest(a); - props.setMock(false);props.setEnvironmentId("isolated-test-real");transport.connected=true;restartServices(); - commands.retryPending();auths.retryPending();auths.clear(a.getAuthId()); - assertEquals(0,transport.attempts.get()); - assertEquals(ExecutionStates.QUARANTINED,commandMapper.selectCommandById(mock.getCommandId()).getExecutionState()); - assertEquals("MOCK",authMapper.selectAuthById(a.getAuthId()).getExecutionMode()); - expectDenied(() -> commands.retry(mock.getCommandId())); - } - @Test public void revokeCommandNeverAttemptedRecoversAcrossRestartDespiteOldRequestTime() throws Exception { - LockAuth a=confirmedGrant();transport.connected=false;auths.clear(a.getAuthId());LockCommand revoke=latest(a); - sql("update lock_command set req_time = timestamp '2000-01-01 00:00:00' where command_id="+revoke.getCommandId()); - assertNull(revoke.getAttemptStartedAt());int before=transport.attempts.get(); - restartServices();transport.connected=true;assertEquals(1,commands.retryPending()); - assertEquals(before+1,transport.attempts.get());assertEquals(revoke.getCommandNo(),latest(a).getCommandNo()); - } - @Test public void committedRevokeIntentWithoutCommandRecoversAfterRestart() throws Exception { - LockAuth a=confirmedGrant();persistRevokeIntentOnly(a);assertNull(latest(a)); - restartServices();assertEquals(1,auths.retryPending()); - assertEquals(LockConstants.CMD_AUTH_CLEAR,latest(a).getCommandType()); - assertEquals(0,auths.retryPending());assertEquals(2,count("lock_command")); - } - @Test public void sentWithUnknownAckCannotBeBlindlyRetried() { - LockAuth a=confirmedGrant();auths.clear(a.getAuthId());LockCommand revoke=latest(a);int before=transport.attempts.get(); - sql("update lock_command set attempt_started_at=timestamp '2000-01-01 00:00:00' where command_id="+revoke.getCommandId()); - commands.markTimeout(30);commands.retryPending();auths.retryPending(); - expectDenied(() -> commands.retry(revoke.getCommandId())); - assertEquals(before,transport.attempts.get());assertEquals(ExecutionStates.MANUAL_REVIEW,latest(a).getExecutionState()); - } - @Test public void crashAfterCommittedClaimRequiresReviewAndNeverDuplicates() throws Exception { - LockAuth a=confirmedGrant();transport.crash=true; - try {auths.clear(a.getAuthId());fail();}catch(SimulatedProcessCrash expected){} - LockCommand uncertain=latest(a); - assertEquals(ExecutionStates.DISPATCHING,uncertain.getExecutionState()); - int before=transport.attempts.get();transport.crash=false;restartServices(); - commands.retryPending();auths.retryPending();assertEquals(before,transport.attempts.get()); - sql("update lock_command set attempt_started_at=timestamp '2000-01-01 00:00:00' where command_id="+uncertain.getCommandId()); - commands.markTimeout(30);assertEquals(ExecutionStates.MANUAL_REVIEW,latest(a).getExecutionState()); - } - @Test public void ambiguousTransportExceptionDoesNotBecomeRetryableFail() { - LockAuth a=confirmedGrant();transport.ambiguous=true;auths.clear(a.getAuthId());int before=transport.attempts.get(); - commands.retryPending();auths.retryPending();auths.clear(a.getAuthId()); - assertEquals(before,transport.attempts.get());assertEquals(ExecutionStates.MANUAL_REVIEW,latest(a).getExecutionState()); - } - @Test public void explicitDeviceFailureRequiresReviewAndCannotSuppressAnotherRevokeVersion() { - LockAuth a=confirmedGrant();auths.clear(a.getAuthId());LockCommand revoke=latest(a);ack(revoke,"20"); - assertEquals(ExecutionStates.REJECTED,latest(a).getExecutionState()); - assertEquals(ExecutionStates.MANUAL_REVIEW,authMapper.selectAuthById(a.getAuthId()).getExecutionState()); - int before=transport.attempts.get();commands.retryPending();auths.retryPending();assertEquals(before,transport.attempts.get()); - } - @Test public void lateRevokeAckCannotOverwriteNewGrantVersion() { - LockAuth a=confirmedGrant();auths.clear(a.getAuthId());LockCommand old=latest(a);ack(old,"10");ack(old,"0"); - LockAuth change=new LockAuth();change.setAuthId(a.getAuthId());auths.updateAuth(change); - LockAuth before=authMapper.selectAuthById(a.getAuthId());ack(old,"0");ack(old,"10"); - LockAuth after=authMapper.selectAuthById(a.getAuthId()); - assertEquals(before.getIntentKey(),after.getIntentKey());assertEquals(ExecutionStates.GRANT,after.getIntentAction()); - assertEquals(before.getSyncStatus(),after.getSyncStatus());assertNotEquals(LockConstants.SYNC_CLEARED,after.getSyncStatus()); - } - @Test public void wrongTopicOpSequenceOrTargetCannotConfirm() { - LockAuth a=fresh();auths.insertAuth(a);LockCommand c=latest(a); - YdlinkFrameDTO r=new YdlinkFrameDTO();r.setSeq(c.getCommandNo());r.setOp("facefeature"); - r.setFlatParams(Arrays.asList("add","OTHERDEV","0")); - commands.handleResponse(c.getTopic()+"_report",c.getCommandNo(),YdlinkCodec.toJson(r),true,"ignored"); - assertEquals(ExecutionStates.AWAITING_ACK,latest(a).getExecutionState()); - r.setFlatParams(Arrays.asList("add",device.getDeviceSn(),"0")); - commands.handleResponse("ydlink/OTHER/thing/data/batch_report",c.getCommandNo(),YdlinkCodec.toJson(r),true,"ignored"); - assertEquals(ExecutionStates.AWAITING_ACK,latest(a).getExecutionState()); - } - @Test public void disabledWriteAndNetworkNeverInvokeTransport() { - props.setRealDeviceWriteEnabled(false);LockAuth a=fresh();auths.insertAuth(a);commands.retryPending();assertEquals(0,transport.attempts.get()); - props.setRealDeviceWriteEnabled(true);props.setEnabled(false);commands.retryPending();assertEquals(0,transport.attempts.get()); - } - @Test public void unapprovedProtocolCannotReachTransport() { - props.setProtocolVerified(false);LockAuth a=fresh();auths.insertAuth(a);commands.retryPending(); - assertEquals(0,transport.attempts.get());assertNull(latest(a).getAttemptStartedAt()); - } - @Test public void mappingChangesOrEmptyPayloadAreRejectedBeforeDispatch() { - transport.connected=false;LockAuth a=fresh();auths.insertAuth(a);LockCommand c=latest(a); - sql("update lock_command set payload='' where command_id="+c.getCommandId());transport.connected=true; - commands.retryPending();assertEquals(0,transport.attempts.get());assertEquals(ExecutionStates.QUARANTINED,latest(a).getExecutionState()); - LockAuth b=fresh();transport.connected=false;auths.insertAuth(b);gateway.setGroupId(999L);transport.connected=true; - commands.retryPending();assertEquals(0,transport.attempts.get()); - } - @Test public void transactionRollbackLeavesNoIntentCommandOrNetworkAttempt() { - try { tx.required(() -> { auths.insertAuth(fresh()); throw new IllegalStateException("simulate rollback"); }); fail(); } - catch(IllegalStateException expected) { } - assertEquals(0,count("lock_auth"));assertEquals(0,count("lock_command"));assertEquals(0,transport.attempts.get()); - } - @Test public void concurrentDispatchClaimsOnlyOneAttempt() throws Exception { - transport.connected=false;LockAuth a=fresh();auths.insertAuth(a);LockCommand c=latest(a);transport.connected=true; - ExecutorService pool=Executors.newFixedThreadPool(2); - try { Future one=pool.submit(() -> commands.dispatch(c.getCommandId()));Future two=pool.submit(() -> commands.dispatch(c.getCommandId()));one.get(10,TimeUnit.SECONDS);two.get(10,TimeUnit.SECONDS); } - finally { pool.shutdownNow(); } - assertEquals(1,transport.attempts.get()); - } - @Test public void additiveMigrationQuarantinesLegacyAndIsRerunnable() throws Exception { - sql("insert into lock_auth(auth_no,status,del_flag) values('legacy','1','0')"); - try(Connection c=ds.getConnection()) { RunScript.execute(c,Files.newBufferedReader(migration())); } - LockAuth legacy=authMapper.selectAuthList(new LockAuth()).get(0); - assertEquals("LEGACY",legacy.getExecutionMode());assertEquals(Long.valueOf(0),legacy.getIntentVersion()); - assertEquals(ExecutionStates.QUARANTINED,legacy.getExecutionState());auths.retryPending();assertEquals(0,transport.attempts.get()); - } - @Test public void rawPublishReconnectAndWillCannotBypassPolicy() throws Exception { - MqttClientManager manager=new MqttClientManager(props,null,gate); - expectDenied(() -> manager.publish("ydlink/TESTGROUP/thing/data/batch","{}",1,false)); - props.setEnabled(false);expectDenied(manager::connect);expectDenied(manager::reconnect); - IotMqttProperties.MqttWill will=new IotMqttProperties.MqttWill();will.setTopic("ydlink/TESTGROUP/thing/data/batch");props.setWill(will); - Method method=MqttClientManager.class.getDeclaredMethod("buildConnectOptions");method.setAccessible(true); - try {method.invoke(manager);fail();}catch(InvocationTargetException expected){assertTrue(expected.getCause() instanceof SecurityException);} - props.setMock(false);expectDenied(() -> manager.mockReceive("x","{}")); - assertEquals(0,transport.attempts.get()); - } - @Test public void mqttInternalSessionOrPersistenceCannotReplayControlPackets() throws Exception { - MqttClientManager manager=new MqttClientManager(props,null,gate); - props.setAutomaticReconnect(true);props.setCleanSession(false); - Method options=MqttClientManager.class.getDeclaredMethod("buildConnectOptions");options.setAccessible(true); - org.eclipse.paho.client.mqttv3.MqttConnectOptions value=(org.eclipse.paho.client.mqttv3.MqttConnectOptions)options.invoke(manager); - assertFalse(value.isAutomaticReconnect());assertTrue(value.isCleanSession()); - props.setPersistenceDirectory("untrusted-history"); - Method persistence=MqttClientManager.class.getDeclaredMethod("createPersistence");persistence.setAccessible(true); - try{persistence.invoke(manager);fail();}catch(InvocationTargetException expected){assertTrue(expected.getCause() instanceof SecurityException);} - assertEquals(0,transport.attempts.get()); - } - @Test public void actualMqttManagerPublishesValidatedPayloadAtMostOnceWithoutRetaining() throws Exception { - transport.connected=false;LockAuth a=fresh();auths.insertAuth(a);LockCommand command=latest(a); - AtomicReference captured=new AtomicReference<>(); - org.eclipse.paho.client.mqttv3.MqttClient client=new org.eclipse.paho.client.mqttv3.MqttClient( - "tcp://127.0.0.1:1","round5s-"+UUID.randomUUID(), - new org.eclipse.paho.client.mqttv3.persist.MemoryPersistence()) { - @Override public boolean isConnected(){return true;} - @Override public void publish(String topic,org.eclipse.paho.client.mqttv3.MqttMessage message){ - assertEquals(command.getTopic(),topic);captured.set(message); // No connect() or socket publication occurs. - } - }; - try { - MqttClientManager manager=new MqttClientManager(props,null,gate);inject(manager,"client",client); - inject(commands,"mqttService",new com.ruoyi.iot.service.impl.MqttServiceImpl(manager)); - commands.retryPending();assertNotNull(captured.get());assertEquals(0,captured.get().getQos()); - assertFalse(captured.get().isRetained()); - assertEquals(command.getPayload(),new String(captured.get().getPayload(),java.nio.charset.StandardCharsets.UTF_8)); - } finally {client.close();} - } - private void sql(String text) { try(Connection c=ds.getConnection();Statement s=c.createStatement()){s.execute(text);}catch(SQLException e){throw new AssertionError(e);} } - private int count(String table) {try(Connection c=ds.getConnection();Statement s=c.createStatement();ResultSet r=s.executeQuery("select count(*) from "+table)){r.next();return r.getInt(1);}catch(SQLException e){throw new AssertionError(e);} } - private class Transport implements IMqttService { - boolean connected=true;boolean ambiguous;boolean crash; - AtomicInteger attempts=new AtomicInteger(); - @Override public boolean isConnected(){return connected;} - @Override public void publishCommand(LockCommand command){ - gate.requirePersistedDispatch(command); - // Independent connection proves the claim is committed before the transport attempt. - try(Connection c=ds.getConnection();PreparedStatement s=c.prepareStatement("select execution_state from lock_command where command_id=?")){ - s.setLong(1,command.getCommandId());try(ResultSet r=s.executeQuery()){assertTrue(r.next());assertEquals(ExecutionStates.DISPATCHING,r.getString(1));} - }catch(SQLException e){throw new AssertionError(e);} - if(crash)throw new SimulatedProcessCrash(); - attempts.incrementAndGet();if(ambiguous)throw new IllegalStateException("synthetic uncertain transport"); - } - @Override public void publish(String t,String p){throw new AssertionError("raw transport forbidden");} - @Override public void publish(String t,Object p,Integer q,Boolean r){throw new AssertionError("raw transport forbidden");} - @Override public void subscribe(String t){} @Override public void subscribe(String t,Integer q){} - @Override public void unsubscribe(String t){} @Override public void reconnect(){} - @Override public Map getSubscriptions(){return Collections.emptyMap();} - } - private static class SimulatedProcessCrash extends Error {} -} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4BrowserFixture.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4BrowserFixture.java deleted file mode 100644 index bf9518e90..000000000 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4BrowserFixture.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.ruoyi.train.integration.lock; - -import java.io.*; -import java.nio.file.*; -import java.nio.file.attribute.PosixFilePermissions; -import java.util.*; -import com.ruoyi.iot.lock.domain.*; -import com.ruoyi.iot.lock.security.ExecutionStates; - -/** Offline CLI fixture: no web endpoint, no real MQTT client, only explicitly marked synthetic QA records. */ -public final class Round5v4BrowserFixture { - public static void main(String[] args) throws Exception { - Round5v4KingbaseIntegrationTest.Harness h=new Round5v4KingbaseIntegrationTest.Harness("round5v-local-mock"); - Path path=Paths.get(System.getenv("ROUND5V4_LOG_DIR"),"browser-fixture.properties"); - Properties p=new Properties(); - if("seed".equals(args[0])) { - if(Files.exists(path))throw new SecurityException("Fixture already exists; preserve its audit trail"); - h.seed();p.setProperty("marker",h.marker);p.setProperty("studentId",h.marker+"S");p.setProperty("person",h.person); - p.setProperty("dormA",h.dormA);p.setProperty("dormB",h.dormB);p.setProperty("acc",String.valueOf(h.acc)); - p.setProperty("deviceA",String.valueOf(h.deviceA));p.setProperty("deviceB",String.valueOf(h.deviceB)); - Files.createFile(path,PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); - try(Writer w=Files.newBufferedWriter(path)){p.store(w,"SYNTHETIC ONLY; no credentials");} - System.out.println("BROWSER_FIXTURE_CREATED "+h.marker);return; - } - try(Reader r=Files.newBufferedReader(path)){p.load(r);} - if(!p.getProperty("marker","").matches("R5V4[0-9a-f]{16}"))throw new SecurityException("Invalid fixture identity"); - String person=p.getProperty("person");Set devices=new HashSet<>(Arrays.asList(Long.valueOf(p.getProperty("deviceA")),Long.valueOf(p.getProperty("deviceB")))); - java.util.List all=h.authMapper.selectAuthsByPersonNo(person); - for(LockAuth auth:all) { - if(!devices.contains(auth.getDeviceId()) || !h.gate.sameOrigin(auth) || !"MOCK".equals(auth.getExecutionMode()))throw new SecurityException("Non-fixture authorization"); - LockCommand c=h.commandMapper.selectCommandByIntentKey(auth.getIntentKey()); - if("ack".equals(args[0]) && c!=null && ExecutionStates.AWAITING_ACK.equals(c.getExecutionState())) { - if(!"MOCK".equals(c.getExecutionMode()) || !h.gate.sameOrigin(c))throw new SecurityException("Non-Mock command"); - h.confirm(c); - } else if(!Arrays.asList("ack","state","verify").contains(args[0]))throw new IllegalArgumentException("seed/ack/state/verify only"); - auth=h.authMapper.selectAuthById(auth.getAuthId()); - System.out.println("FIXTURE_AUTH id="+auth.getAuthId()+" action="+auth.getIntentAction()+" version="+auth.getIntentVersion()+" state="+auth.getExecutionState()+" sync="+auth.getSyncStatus()); - } - if("verify".equals(args[0])) { - org.junit.Assert.assertEquals(2,all.size()); - for(LockAuth auth:all){org.junit.Assert.assertEquals(ExecutionStates.REVOKE,auth.getIntentAction());org.junit.Assert.assertEquals(ExecutionStates.CONFIRMED,auth.getExecutionState());org.junit.Assert.assertEquals("4",auth.getSyncStatus());} - java.util.List commands=h.jdbc.queryForList("select c.command_id from iotlocks.lock_command c join iotlocks.lock_auth a on a.auth_id=c.biz_id where a.person_no=? and c.environment_id=? order by c.command_id",Long.class,person,h.env); - org.junit.Assert.assertEquals(4,commands.size()); - for(Long id:commands){LockCommand c=h.commandMapper.selectCommandById(id);org.junit.Assert.assertEquals("MOCK",c.getExecutionMode());org.junit.Assert.assertEquals(ExecutionStates.CONFIRMED,c.getExecutionState());org.junit.Assert.assertEquals("1",c.getGatewayAck());org.junit.Assert.assertEquals("1",c.getDeviceAck());System.out.println("FIXTURE_COMMAND id="+id+" type="+c.getCommandType()+" mode=MOCK state=CONFIRMED dual_ack=true");} - org.junit.Assert.assertEquals(Integer.valueOf(2),h.jdbc.queryForObject("select count(*) from public.train_accommodation a join public.train_enrollment e on e.enrollment_id=a.enrollment_id where e.student_id=? and a.accommodation_status=2",Integer.class,p.getProperty("studentId"))); - System.out.println("FIXTURE_BROWSER_DATABASE_CLOSED_LOOP_PASS"); - } - } -} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4KingbaseIntegrationTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4KingbaseIntegrationTest.java deleted file mode 100644 index aed5ca2ea..000000000 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/Round5v4KingbaseIntegrationTest.java +++ /dev/null @@ -1,227 +0,0 @@ -package com.ruoyi.train.integration.lock; - -import static org.junit.Assert.*; -import java.io.*; -import java.lang.reflect.*; -import java.nio.file.*; -import java.nio.file.attribute.PosixFilePermissions; -import java.sql.*; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicInteger; -import javax.sql.DataSource; -import org.junit.*; -import org.mybatis.spring.*; -import org.springframework.aop.framework.ProxyFactory; -import org.springframework.core.io.ClassPathResource; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.datasource.*; -import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; -import org.springframework.transaction.interceptor.TransactionInterceptor; -import com.ruoyi.iot.config.IotMqttProperties; -import com.ruoyi.iot.lock.LockConstants; -import com.ruoyi.iot.lock.domain.*; -import com.ruoyi.iot.lock.mapper.*; -import com.ruoyi.iot.lock.protocol.*; -import com.ruoyi.iot.lock.security.*; -import com.ruoyi.iot.lock.service.*; -import com.ruoyi.iot.lock.service.impl.*; -import com.ruoyi.iot.service.IMqttService; -import com.ruoyi.train.domain.TrainAccommodation; -import com.ruoyi.train.integration.lock.service.ITrainLockAuthBridgeService; -import com.ruoyi.train.integration.lock.service.impl.TrainIotLockAuthBridgeServiceImpl; -import com.ruoyi.train.mapper.*; -import com.ruoyi.train.service.ITrainAccommodationService; -import com.ruoyi.train.service.impl.TrainAccommodationServiceImpl; - -/** Opt-in REAL Kingbase, real production XML/Services/transaction manager. All egress is a fake transport. */ -public class Round5v4KingbaseIntegrationTest { - private Harness h; - @Before public void setup() throws Exception { - Assume.assumeTrue("Explicit QA write opt-in required", "1".equals(System.getenv("ROUND5V4_KINGBASE_TESTS"))); - h=new Harness("round5v4-qa-"+UUID.randomUUID()); h.seed(); - } - @After public void preserveSyntheticAuditRows() { - if(h==null)return; - h.props.setMock(true); - // Preserve all synthetic records; close only never-attempted test work, never historical business rows. - for(Long id:h.jdbc.queryForList("select command_id from iotlocks.lock_command where environment_id=? and execution_state='READY' and attempt_started_at is null",Long.class,h.env)) { - try {h.commands.cancel(id);}catch(RuntimeException ignored) { } - } - h.jdbc.update("update iotlocks.lock_auth set execution_state='MANUAL_REVIEW',remark='Round5V4 synthetic test complete; preserved for audit' where environment_id=? and execution_state='READY'",h.env); - } - @Test public void checkinTransferCheckoutRealTransactionsAndMockDualAck() throws Exception { - h.accommodations.checkIn(h.acc,"round5v4");LockAuth first=h.auth(h.deviceA);h.confirm(h.latest(first)); - assertEquals(ExecutionStates.CONFIRMED,h.auth(h.deviceA).getExecutionState()); - TrainAccommodation next=h.accommodations.transfer(h.acc,h.dormB,"1","round5v4"); - LockAuth old=h.auth(h.deviceA),fresh=h.auth(h.deviceB); - assertEquals(ExecutionStates.REVOKE,old.getIntentAction());assertEquals(ExecutionStates.GRANT,fresh.getIntentAction()); - h.confirm(h.latest(old));h.confirm(h.latest(fresh)); - h.accommodations.checkOut(next.getAccommodationId(),"round5v4");h.confirm(h.latest(h.auth(h.deviceB))); - assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus()); - assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceB).getSyncStatus()); - assertEquals(Integer.valueOf(2),h.trainMapper.selectTrainAccommodationByAccommodationId(next.getAccommodationId()).getAccommodationStatus()); - } - @Test public void revokeIntentBeforeCommandSurvivesActualJvmHalt() throws Exception { - h.checkedIn();assertEquals(73,h.child("intent",h.acc)); - LockAuth pending=h.auth(h.deviceA);assertEquals("1",pending.getStatus());assertEquals(ExecutionStates.REVOKE,pending.getIntentAction()); - assertNull(h.commandMapper.selectCommandByIntentKey(pending.getIntentKey())); - h.restart();h.auths.retryPending();LockCommand revoke=h.latest(pending);assertNotNull(revoke);h.confirm(revoke); - assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus()); - assertEquals(Integer.valueOf(2),h.trainMapper.selectTrainAccommodationByAccommodationId(h.acc).getAccommodationStatus()); - } - @Test public void readyRevokeBeforeSendSurvivesActualJvmHalt() throws Exception { - h.checkedIn();assertEquals(75,h.child("ready",h.auth(h.deviceA).getAuthId())); - LockCommand revoke=h.latest(h.auth(h.deviceA));assertEquals(ExecutionStates.READY,revoke.getExecutionState());assertNull(revoke.getAttemptStartedAt()); - h.restart();h.commands.retry(revoke.getCommandId());assertEquals(1,h.transport.attempts.get());h.confirm(revoke); - assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus()); - } - @Test public void committedClaimUnknownAfterJvmHaltNeverBlindlyReplays() throws Exception { - h.checkedIn();assertEquals(74,h.child("claim",h.auth(h.deviceA).getAuthId())); - LockCommand revoke=h.latest(h.auth(h.deviceA));assertEquals(ExecutionStates.DISPATCHING,revoke.getExecutionState());assertNotNull(revoke.getAttemptStartedAt()); - h.jdbc.update("update iotlocks.lock_command set attempt_started_at=? where command_id=?",new Timestamp(System.currentTimeMillis()-120000),revoke.getCommandId()); - h.restart();h.commands.markTimeout(30);h.commands.retryPending();h.auths.retryPending(); - assertEquals(ExecutionStates.MANUAL_REVIEW,h.commandMapper.selectCommandById(revoke.getCommandId()).getExecutionState());assertEquals(0,h.transport.attempts.get()); - assertNotEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus()); - } - @Test public void failedNeverAttemptedCommandCanRecover() throws Exception { - h.checkedIn();h.transport.attempts.set(0);h.transport.connected=false;h.auths.clear(h.auth(h.deviceA).getAuthId());LockCommand command=h.latest(h.auth(h.deviceA)); - assertEquals(LockConstants.STATUS_FAIL,command.getCommandStatus());assertEquals(ExecutionStates.READY,command.getExecutionState());assertNull(command.getAttemptStartedAt()); - h.transport.connected=true;h.commands.retry(command.getCommandId());assertEquals(1,h.transport.attempts.get());h.confirm(command); - assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus()); - } - @Test public void concurrentNativeRowLocksClaimOnlyOnce() throws Exception { - h.transport.connected=false;h.accommodations.checkIn(h.acc,"round5v4");Long id=h.latest(h.auth(h.deviceA)).getCommandId();h.transport.connected=true; - ExecutorService pool=Executors.newFixedThreadPool(4); - try { - List> tasks=new ArrayList<>();for(int i=0;i<4;i++)tasks.add(pool.submit(()->h.commands.dispatch(id))); - for(Future f:tasks)f.get(20,TimeUnit.SECONDS); - }finally {pool.shutdownNow();} - assertEquals(1,h.transport.attempts.get());h.confirm(h.commandMapper.selectCommandById(id)); - } - @Test public void checkinAndIntentRollbackTogetherAfterInjectedFailure() throws Exception { - h.injectBridgeFault("reconcileCheckin"); - try{h.accommodations.checkIn(h.acc,"round5v4");fail();}catch(IllegalStateException expected) { } - assertEquals(Integer.valueOf(0),h.trainMapper.selectTrainAccommodationByAccommodationId(h.acc).getAccommodationStatus()); - assertNull(h.auth(h.deviceA));assertEquals(0,h.transport.attempts.get()); - } - @Test public void checkoutAndRevokeIntentRollbackTogether() throws Exception { - h.checkedIn();LockAuth before=h.auth(h.deviceA);h.injectBridgeFault("reconcileCheckout"); - try{h.accommodations.checkOut(h.acc,"round5v4");fail();}catch(IllegalStateException expected) { } - LockAuth after=h.auth(h.deviceA);assertEquals(before.getIntentKey(),after.getIntentKey());assertEquals(ExecutionStates.GRANT,after.getIntentAction()); - assertEquals(Integer.valueOf(1),h.trainMapper.selectTrainAccommodationByAccommodationId(h.acc).getAccommodationStatus()); - } - @Test public void lateOldRevokeAckCannotOverwriteNewGrantVersion() throws Exception { - h.checkedIn();h.auths.clear(h.auth(h.deviceA).getAuthId());LockCommand old=h.latest(h.auth(h.deviceA));h.confirm(old); - LockAuth grant=h.auth(h.deviceA);h.auths.updateAuth(grant);LockAuth newer=h.auth(h.deviceA);String key=newer.getIntentKey();Long version=newer.getIntentVersion(); - h.confirm(old);assertEquals(key,h.auth(h.deviceA).getIntentKey());assertEquals(version,h.auth(h.deviceA).getIntentVersion());assertEquals("0",h.auth(h.deviceA).getStatus()); - } - @Test public void mockAndLegacyCommandsAreQuarantinedAcrossRealBoundary() throws Exception { - h.transport.connected=false;h.accommodations.checkIn(h.acc,"round5v4");LockCommand old=h.latest(h.auth(h.deviceA)); - h.props.setMock(false);h.props.setEnabled(false);h.props.setRealDeviceWriteEnabled(false);h.props.setDeviceWhitelist(Collections.emptyList()); - h.transport.connected=true;h.commands.dispatch(old.getCommandId()); - assertEquals(ExecutionStates.QUARANTINED,h.commandMapper.selectCommandById(old.getCommandId()).getExecutionState());assertEquals(0,h.transport.attempts.get()); - assertFalse(h.gate.checkDeviceWriteAllowed(old.getDeviceSn(),"0012")); - } - @Test public void uniqueIntentConstraintIsEnforcedByActualKingbase() throws Exception { - h.transport.connected=false;h.accommodations.checkIn(h.acc,"round5v4");LockCommand original=h.latest(h.auth(h.deviceA)); - try { - h.tx.required(()->{LockCommand copy=h.commandMapper.selectCommandById(original.getCommandId());copy.setCommandId(null);copy.setCommandNo("R5V4"+UUID.randomUUID().toString().replace("-",""));h.commandMapper.insertCommand(copy);return null;});fail(); - }catch(org.springframework.dao.DuplicateKeyException expected) { } - assertEquals(Integer.valueOf(1),h.jdbc.queryForObject("select count(*) from iotlocks.lock_command where intent_key=?",Integer.class,original.getIntentKey())); - } - - static class Harness { - final String env;DataSource ds;JdbcTemplate jdbc;SqlSessionTemplate sessions;DataSourceTransactionManager manager; - LockAuthMapper authMapper;LockCommandMapper commandMapper;TrainAccommodationMapper trainMapper; - LockExecutionTransactions tx;IotMqttProperties props;RealIotSecurityGate gate;LockAuthServiceImpl auths;LockCommandServiceImpl commands; - TrainIotLockAuthBridgeServiceImpl bridgeTarget;ITrainLockAuthBridgeService bridge;TrainAccommodationServiceImpl accommodationTarget;ITrainAccommodationService accommodations; - Transport transport;String person,marker,dormA,dormB;long deviceA,deviceB,acc,enrollment; - Harness(String environment) throws Exception { - env=environment;String url=System.getenv("QA_DB_URL"); - if(!"1".equals(System.getenv("ROUND5V4_KINGBASE_TESTS")) || url==null || !url.matches("jdbc:kingbase8://192\\.168\\.139\\.218:4321/railwaysystem_fusion_qa_20261013(?:\\?.*)?"))throw new SecurityException("Explicit isolated QA opt-in/URL required"); - Class.forName("com.kingbase8.Driver");DriverManagerDataSource raw=new DriverManagerDataSource(url,"fusion_qa_app",System.getenv("QA_DB_PASSWORD")); - ds=new AbstractDataSource(){public Connection getConnection()throws SQLException{Connection c=raw.getConnection();try(Statement s=c.createStatement()){s.execute("set search_path to iotlocks,public,pg_catalog,sys_catalog");s.execute("set lock_timeout='5s'");}return c;}public Connection getConnection(String u,String p)throws SQLException{return getConnection();}}; - jdbc=new JdbcTemplate(ds);assertEquals("railwaysystem_fusion_qa_20261013",jdbc.queryForObject("select current_database()",String.class));assertEquals("fusion_qa_app",jdbc.queryForObject("select current_user",String.class)); - // Recovery APIs scan pending work. Refuse tests if unrelated active work could be affected. - for(String table:Arrays.asList("lock_auth","lock_command")) - if(jdbc.queryForObject("select count(*) from iotlocks."+table+" where execution_state='READY' and execution_mode<>'LEGACY' and coalesce(environment_id,'') not like 'round5v4-qa-%' and coalesce(environment_id,'')<>?",Integer.class,env)>0) - throw new SecurityException("Unrelated pending QA work exists; recovery test refused"); - manager=new DataSourceTransactionManager(ds);tx=new LockExecutionTransactions(manager); - SqlSessionFactoryBean f=new SqlSessionFactoryBean();f.setDataSource(ds);org.apache.ibatis.session.Configuration mc=new org.apache.ibatis.session.Configuration();mc.setDatabaseId("kingbase");f.setConfiguration(mc);f.setTypeAliasesPackage("com.ruoyi.iot.lock.domain;com.ruoyi.train.domain"); - List resources=new ArrayList<>(); - for(String n:Arrays.asList("LockAuth","LockCommand","LockDevice","LockGateway","LockGroup","LockFace"))resources.add(new ClassPathResource("mapper/lock/"+n+"Mapper.xml")); - for(String n:Arrays.asList("TrainAccommodation","TrainEnrollment","TrainStudent","TrainDormitoryInfo","TrainClassPlan"))resources.add(new ClassPathResource("mapper/train/"+n+"Mapper.xml")); - f.setMapperLocations(resources.toArray(new org.springframework.core.io.Resource[0]));sessions=new SqlSessionTemplate(f.getObject()); - authMapper=sessions.getMapper(LockAuthMapper.class);commandMapper=sessions.getMapper(LockCommandMapper.class);trainMapper=sessions.getMapper(TrainAccommodationMapper.class); - props=new IotMqttProperties();props.setMock(true);props.setEnabled(false);props.setRealDeviceWriteEnabled(false);props.setDeviceWhitelist(Collections.emptyList());props.setEnvironmentId(env);restart(); - } - @SuppressWarnings("unchecked") T transactional(Object target,Class type){ - ProxyFactory p=new ProxyFactory(target);p.setInterfaces(type);p.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));return (T)p.getProxy(); - } - void restart() throws Exception { - gate=new RealIotSecurityGate(props); - for(String n:Arrays.asList("device","gateway","group","auth","command")){ - Class type=Class.forName("com.ruoyi.iot.lock.mapper.Lock"+Character.toUpperCase(n.charAt(0))+n.substring(1)+"Mapper");inject(gate,n+"Mapper",sessions.getMapper(type)); - } - commands=new LockCommandServiceImpl();auths=new LockAuthServiceImpl();transport=new Transport(this); - for(Object s:Arrays.asList(commands,auths)){inject(s,"authMapper",authMapper);inject(s,"commandMapper",commandMapper);inject(s,"securityGate",gate);inject(s,"transactions",tx);} - inject(commands,"mqttService",transport);inject(auths,"deviceMapper",sessions.getMapper(LockDeviceMapper.class));inject(auths,"gatewayMapper",sessions.getMapper(LockGatewayMapper.class));inject(auths,"groupMapper",sessions.getMapper(LockGroupMapper.class));inject(auths,"commandService",commands); - LockFaceServiceImpl faces=new LockFaceServiceImpl();inject(faces,"faceMapper",sessions.getMapper(LockFaceMapper.class)); - bridgeTarget=new TrainIotLockAuthBridgeServiceImpl(); - inject(bridgeTarget,"executionTransactions",tx); - for(String n:Arrays.asList("TrainAccommodation","TrainEnrollment","TrainStudent","TrainDormitoryInfo","TrainClassPlan"))inject(bridgeTarget,Character.toLowerCase(n.charAt(0))+n.substring(1)+"Mapper",sessions.getMapper(Class.forName("com.ruoyi.train.mapper."+n+"Mapper"))); - inject(bridgeTarget,"lockDeviceMapper",sessions.getMapper(LockDeviceMapper.class));inject(bridgeTarget,"lockAuthMapper",authMapper);inject(bridgeTarget,"lockCommandMapper",commandMapper);inject(bridgeTarget,"lockAuthService",auths);inject(bridgeTarget,"lockCommandService",commands);inject(bridgeTarget,"lockFaceService",faces);inject(bridgeTarget,"securityGate",gate); - bridge=transactional(bridgeTarget,ITrainLockAuthBridgeService.class); - accommodationTarget=new TrainAccommodationServiceImpl(); - for(String n:Arrays.asList("TrainAccommodation","TrainEnrollment","TrainDormitoryInfo","TrainClassPlan"))inject(accommodationTarget,Character.toLowerCase(n.charAt(0))+n.substring(1)+"Mapper",sessions.getMapper(Class.forName("com.ruoyi.train.mapper."+n+"Mapper"))); - accommodationTarget.setAuthEngine("iot");accommodationTarget.setIotLockAuthBridgeService(bridge);accommodations=transactional(accommodationTarget,ITrainAccommodationService.class); - } - void seed(){ - String key=UUID.randomUUID().toString().replace("-","");marker="R5V4"+key.substring(0,16);person="P5"+key.substring(0,6);dormA=marker+"A";dormB=marker+"B"; - String plan=marker+"P",student=marker+"S";Timestamp start=new Timestamp(System.currentTimeMillis()-86400000),end=new Timestamp(System.currentTimeMillis()+3*86400000L); - jdbc.update("insert into public.train_class_plan(plan_id,plan_name,start_time,end_time,create_by) values(?,?,?,?,?)",plan,"Round5V4 Synthetic",start,end,"round5v4"); - jdbc.update("insert into public.train_student(s_id,s_name,person_no,dept_name,create_by) values(?,?,?,?,?)",student,"Round5V4 Synthetic",person,"ROUND5V4_SYNTHETIC","round5v4"); - enrollment=jdbc.queryForObject("insert into public.train_enrollment(plan_id,student_id,accommodation_required,create_by) values(?,?,1,'round5v4') returning enrollment_id",Long.class,plan,student); - Long group=jdbc.queryForObject("insert into iotlocks.lock_group(group_code,group_name,create_by) values(?,?,'round5v4') returning group_id",Long.class,marker,"Round5V4 Synthetic"); - Long gateway=jdbc.queryForObject("insert into iotlocks.lock_gateway(gateway_sn,group_id,create_by) values(?,?,'round5v4') returning gateway_id",Long.class,"G5"+key.substring(0,6),group); - for(int i=0;i<2;i++){ - String sn=(i==0?"D5":"D6")+key.substring(0,6); - Long id=jdbc.queryForObject("insert into iotlocks.lock_device(device_sn,device_name,gateway_id,group_id,device_password,dev_ver,dev_type,create_by) values(?,?,?,?,?,'11','19','round5v4') returning device_id",Long.class,sn,"Round5V4 Synthetic",gateway,group,UUID.randomUUID().toString()); - if(i==0)deviceA=id;else deviceB=id; - jdbc.update("insert into public.train_dormitory_info(dorm_id,dorm_building,dorm_floor,dorm_room,lock_device_sn,capacity,create_by) values(?,?,'1',?,?,4,'round5v4')",i==0?dormA:dormB,marker,i==0?"A":"B",sn); - } - jdbc.update("insert into iotlocks.lock_face(person_no,person_name,feature_data,feature_md5,extract_status,create_by) values(?,'Round5V4 Synthetic',?,?,'1','round5v4')",person,"mock_feature_round5v4_"+marker,key); - TrainAccommodation a=new TrainAccommodation(enrollment,dormA,"1");a.setCreateBy("round5v4");accommodations.insertTrainAccommodation(a);acc=a.getAccommodationId(); - } - LockAuth auth(long device){List all=authMapper.selectAuthsByPersonNo(person);return all.stream().filter(a->a.getDeviceId()==device).findFirst().orElse(null);} - LockCommand latest(LockAuth a){return commandMapper.selectCommandByIntentKey(authMapper.selectAuthById(a.getAuthId()).getIntentKey());} - void checkedIn(){accommodations.checkIn(acc,"round5v4");confirm(latest(auth(deviceA)));} - void confirm(LockCommand c){ack(c,"10");ack(c,"0");} - void ack(LockCommand c,String code){YdlinkFrameDTO req=YdlinkCodec.fromJson(c.getPayload());YdlinkFrameDTO response=new YdlinkFrameDTO();response.setSeq(c.getCommandNo());response.setOp(req.getOp());response.setFlatParams(Arrays.asList(String.valueOf(req.getParams().get(0)),c.getDeviceSn(),code,"","round5v4-mock"));String body=YdlinkCodec.toJson(response);if("10".equals(code))commands.handleGatewayAck(c.getTopic()+"_report",c.getCommandNo(),body,"Mock only");else commands.handleResponse(c.getTopic()+"_report",c.getCommandNo(),body,true,"Mock only");} - void injectBridgeFault(String method){accommodationTarget.setIotLockAuthBridgeService((ITrainLockAuthBridgeService)Proxy.newProxyInstance(ITrainLockAuthBridgeService.class.getClassLoader(),new Class[]{ITrainLockAuthBridgeService.class},(p,m,a)->{try{Object r=m.invoke(bridge,a);if(m.getName().equals(method))throw new IllegalStateException("synthetic post-intent rollback");return r;}catch(InvocationTargetException e){throw e.getCause();}}));} - int child(String mode,long id)throws Exception{ - Path dir=Paths.get(System.getenv("ROUND5V4_LOG_DIR"));Files.createDirectories(dir);Path log=Files.createTempFile(dir,"child-"+mode,".log",PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); - Process p=new ProcessBuilder(System.getProperty("java.home")+"/bin/java","-cp",System.getProperty("surefire.test.class.path",System.getProperty("java.class.path")),Round5v4KingbaseIntegrationTest.class.getName(),mode,String.valueOf(id),env).redirectErrorStream(true).redirectOutput(log.toFile()).start(); - if(!p.waitFor(40,TimeUnit.SECONDS)){p.destroyForcibly();throw new AssertionError("Isolated child timeout");}return p.exitValue(); - } - } - static void inject(Object target,String name,Object value)throws Exception{Field f=target.getClass().getDeclaredField(name);f.setAccessible(true);f.set(target,value);} - static class Transport implements IMqttService { - final Harness h;boolean connected=true,crashAfterClaim;AtomicInteger attempts=new AtomicInteger();Transport(Harness harness){h=harness;} - public boolean isConnected(){return connected;} - public void publishCommand(LockCommand c){h.gate.requirePersistedDispatch(c);assertEquals(ExecutionStates.DISPATCHING,h.jdbc.queryForObject("select execution_state from iotlocks.lock_command where command_id=?",String.class,c.getCommandId()));if(crashAfterClaim)Runtime.getRuntime().halt(74);attempts.incrementAndGet();} - public void publish(String t,String p){throw new AssertionError("Raw publication forbidden");}public void publish(String t,Object p,Integer q,Boolean r){throw new AssertionError("Raw publication forbidden");} - public void subscribe(String t){}public void subscribe(String t,Integer q){}public void unsubscribe(String t){}public void reconnect(){}public Map getSubscriptions(){return Collections.emptyMap();} - } - /** Separate process: committed database intent/claim survives Runtime.halt without touching any server process. */ - public static void main(String[] args)throws Exception{ - Harness h=new Harness(args[2]);long id=Long.parseLong(args[1]); - if(args[0].equals("intent")){ - ILockAuthService crashing=(ILockAuthService)Proxy.newProxyInstance(ILockAuthService.class.getClassLoader(),new Class[]{ILockAuthService.class},(p,m,a)->{if(m.getName().equals("clear"))Runtime.getRuntime().halt(73);try{return m.invoke(h.auths,a);}catch(InvocationTargetException e){throw e.getCause();}}); - inject(h.bridgeTarget,"lockAuthService",crashing);h.accommodations.checkOut(id,"round5v4"); - }else if(args[0].equals("ready")){h.transport.connected=false;h.auths.clear(id);Runtime.getRuntime().halt(75);} - else if(args[0].equals("claim")){h.transport.crashAfterClaim=true;h.auths.clear(id);} - throw new AssertionError("Crash injection was not reached"); - } -} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/TrainIotLockAuthBridgeMockTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/TrainIotLockAuthBridgeMockTest.java index 207be51d7..f2a79c4c5 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/TrainIotLockAuthBridgeMockTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/lock/TrainIotLockAuthBridgeMockTest.java @@ -22,7 +22,6 @@ import com.ruoyi.iot.lock.domain.LockFace; import com.ruoyi.iot.lock.mapper.LockAuthMapper; import com.ruoyi.iot.lock.mapper.LockCommandMapper; import com.ruoyi.iot.lock.mapper.LockDeviceMapper; -import com.ruoyi.iot.lock.mqtt.handler.CommandRespHandler; import com.ruoyi.iot.lock.protocol.YdlinkFrameDTO; import com.ruoyi.iot.lock.protocol.YdlinkOp; import com.ruoyi.iot.lock.protocol.YdlinkTopicDTO; @@ -121,7 +120,7 @@ public class TrainIotLockAuthBridgeMockTest public void rollback(org.springframework.transaction.TransactionStatus s) { } }); setField(bridgeService, "executionTransactions", transactions); - com.ruoyi.iot.config.IotMqttProperties executionProps = new com.ruoyi.iot.config.IotMqttProperties(); + com.ruoyi.iot.config.IotLockSecurityProperties executionProps = new com.ruoyi.iot.config.IotLockSecurityProperties(); executionProps.setMock(true); executionProps.setEnvironmentId("bridge-fixture-mock"); com.ruoyi.iot.lock.security.RealIotSecurityGate executionGate = new com.ruoyi.iot.lock.security.RealIotSecurityGate(executionProps); setField(executionGate, "deviceMapper", createMockDeviceMapper()); diff --git a/build_ui/src/api/iot/mqttlog.js b/build_ui/src/api/iot/mqttlog.js deleted file mode 100644 index 8b9192fb6..000000000 --- a/build_ui/src/api/iot/mqttlog.js +++ /dev/null @@ -1,18 +0,0 @@ -import request from '@/utils/request' - -// 查询报文日志列表 -export function listMqttLog(query) { - return request({ - url: '/lock/mqttlog/list', - method: 'get', - params: query - }) -} - -// 查询报文日志详细 -export function getMqttLog(logId) { - return request({ - url: '/lock/mqttlog/' + logId, - method: 'get' - }) -} diff --git a/build_ui/src/views/iot/mqttlog/index.vue b/build_ui/src/views/iot/mqttlog/index.vue deleted file mode 100644 index c90ac8300..000000000 --- a/build_ui/src/views/iot/mqttlog/index.vue +++ /dev/null @@ -1,183 +0,0 @@ - - - diff --git a/scripts/qa/browser_regression.js b/scripts/qa/browser_regression.js index 0becf7a90..6881a1b87 100644 --- a/scripts/qa/browser_regression.js +++ b/scripts/qa/browser_regression.js @@ -12,7 +12,7 @@ async (page) => { ['/index', null], ['/person/student', 'Student'], ['/person/teacher', null], ['/coursemgr/plan', null], ['/equip/dormitory', null], ['/equip/accommodation', 'Accommodation'], ...Object.entries({group:'Group',gateway:'Gateway',device:'Device',face:'Face',auth:'Auth', - accesslog:'AccessLog',command:'Command',mqttlog:'MqttLog',config:'ServiceConfig'}).map(([r,c])=>['/iot/'+r,c]) + accesslog:'AccessLog',command:'Command',config:'ServiceConfig'}).map(([r,c])=>['/iot/'+r,c]) ]; const results=[]; for (const [path,expected] of routes) { diff --git a/scripts/qa/qa.py b/scripts/qa/qa.py index 9fc56f3d4..fa753fc7e 100644 --- a/scripts/qa/qa.py +++ b/scripts/qa/qa.py @@ -172,10 +172,10 @@ def start(config, env, backend_port, redis_port): '--spring.data.redis.host=127.0.0.1', '--spring.data.redis.port=' + str(redis_port), '--spring.data.redis.database=0', '--spring.quartz.auto-startup=false', '--ruoyi.profile=' + str(RUNTIME / 'upload'), '--logging.file.path=' + str(RUNTIME / 'logs'), - '--logging.level.com.ruoyi=INFO', '--iot.mqtt.mock=true', '--iot.mqtt.enabled=false', - '--iot.mqtt.real-device-write-enabled=false', '--iot.mqtt.device-whitelist=', - '--iot.mqtt.environment-id=round5v-local-mock', '--iot.mqtt.retry-task-enabled=false', - '--iot.mqtt.protocol-verified=false', '--iot.qa-api-enabled=false', '--iot.openapi.enabled=false', + '--logging.level.com.ruoyi=INFO', '--iot.lock-security.mock=true', '--iot.lock-security.enabled=false', + '--iot.lock-security.real-device-write-enabled=false', '--iot.lock-security.device-whitelist=', + '--iot.lock-security.environment-id=round5v-local-mock', '--iot.lock-security.protocol-verified=false', + '--iot.qa-api-enabled=false', '--iot.openapi.enabled=false', '--iot.face.mock=true', '--iot.face.enabled=false', '--iot.face.real-http-enabled=false', '--iot.face.allowed-endpoints=', '--dms.enabled=false', '--dms.mock=true', '--spring.datasource.druid.stat-view-servlet.enabled=false'] diff --git a/scripts/qa/test_qa_tools.py b/scripts/qa/test_qa_tools.py index 8a9de8c23..376ddbaff 100644 --- a/scripts/qa/test_qa_tools.py +++ b/scripts/qa/test_qa_tools.py @@ -39,10 +39,10 @@ class QaToolsTest(unittest.TestCase): self.assertEqual('jdbc:kingbase8://192.168.139.218:4321/' + qa.DATABASE + '?characterEncoding=utf8', qa.druid_url(url)) def test_unsafe_inherited_overrides_removed(self): - with patch.dict(os.environ, {'SPRING_APPLICATION_JSON': '{"danger":true}', 'IOT_MQTT_ENABLED': 'true', 'JAVA_TOOL_OPTIONS': '-Dunsafe=true'}): + with patch.dict(os.environ, {'SPRING_APPLICATION_JSON': '{"danger":true}', 'IOT_LOCK_SECURITY_ENABLED': 'true', 'JAVA_TOOL_OPTIONS': '-Dunsafe=true'}): env = qa.environment({'FUSION_QA_APP_PASSWORD': 'synthetic'}) self.assertNotIn('SPRING_APPLICATION_JSON', env) - self.assertNotIn('IOT_MQTT_ENABLED', env) + self.assertNotIn('IOT_LOCK_SECURITY_ENABLED', env) self.assertNotIn('JAVA_TOOL_OPTIONS', env) def test_private_config_required(self): @@ -78,8 +78,9 @@ class QaToolsTest(unittest.TestCase): self.assertEqual(2,launch.call_count) name, command, env = launch.call_args.args self.assertEqual('backend',name) - for flag in ['--iot.mqtt.enabled=false','--iot.mqtt.mock=true', '--iot.mqtt.real-device-write-enabled=false', - '--iot.mqtt.device-whitelist=', '--iot.mqtt.retry-task-enabled=false', '--iot.qa-api-enabled=false', + for flag in ['--iot.lock-security.enabled=false','--iot.lock-security.mock=true', + '--iot.lock-security.real-device-write-enabled=false','--iot.lock-security.device-whitelist=', + '--iot.lock-security.protocol-verified=false', '--iot.qa-api-enabled=false', '--iot.openapi.enabled=false','--iot.face.real-http-enabled=false','--dms.enabled=false', '--spring.quartz.auto-startup=false','--spring.datasource.druid.master.username=fusion_qa_app']: self.assertIn(flag,command)