refactor(iot): 移除 MQTT 传输层,仅保留设备开放接口与门禁写操作策略
- 依赖:删除根 pom 的 paho.version 与 org.eclipse.paho.client.mqttv3 依赖声明, 同步移除 ruoyi-iot 模块中的 Paho 依赖,模块描述改为“iot设备数据与开放接口” - MQTT 协议栈整体删除:IotMqttConfig、IotMqttProperties、MqttClientManager、 MqttMessageDispatcher、MqttCallbackHandler、MqttConnectionCallback、 IMqttMessageListener、MqttMessageDTO、MqttPublishRequest、MqttMessageEvent、 IMqttService/MqttServiceImpl、IotMqttController,以及 lock 侧 topic 订阅与 LockMessageListener、AccessRecordHandler、CommandRespHandler、 GatewayOnlineHandler、LockSignalHandler - MQTT 报文留痕链路删除:LockMqttLog 实体、LockMqttLogMapper(+XML)、 ILockMqttLogService/LockMqttLogServiceImpl、LockMqttLogController, 以及对应的前端页面 build_ui/src/views/iot/mqttlog 与 API build_ui/src/api/iot/mqttlog.js - 命令下发改为仅落库:LockCommandServiceImpl 派发时直接置 TRANSPORT_DISABLED 失败, retryPending 返回 0;删除 GatewayOfflineCheckTask、LockCommandRetryTask、 LockDeviceOfflineCheckTask 等依赖 MQTT 的补偿与离线检查定时任务 - 保留与传输无关的门禁写操作策略:新增 IotLockSecurityProperties(iot.lock-security), RealIotSecurityGate 及其测试改用该配置类 - 配置与调用处清理:application*.yml 中 iot.mqtt 段迁移为 iot.lock-security, 删除 broker-url/client-id/username/password/topics/will/qos 等连接参数, 修正 iot.face 环境标识注释引用;QA 工具启动参数改用 --iot.lock-security.*, 并移除 browser_regression.js 的 mqttlog 路由 - 测试同步:删除依赖 MQTT 与传输层的 CommandRespHandlerMockTest、 Round5sEgressIsolationTest、Round5sExecutionIntegrationTest、 Round5v4BrowserFixture、Round5v4KingbaseIntegrationTest; 更新 RealIotSecurityGateTest、FaceExtractClientMockTest、 TrainIotLockAuthBridgeMockTest 说明:数据库侧 lock_mqtt_log 表结构与历史验收文档保留,未做数据迁移; 前端若仍配置了“MQTT报文日志”菜单,需在后台菜单管理中一并移除。
This commit is contained in:
1 parent
f5e8d7f564
commit
1def568f24
54 files changed
+688
-4732
No files matched your search
@@ -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 行)
|
||||||
|
- `<description>` 由"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` 六类文件**不在删除范围**;删除会直接破坏保留功能或导致其它模块编译失败。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档结束。*
|
||||||
@@ -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`(会周期性重发至真实设备,属生产变更,需审批)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档结束。如需补充时序图或按模块拆分,可在此基础上扩展。*
|
||||||
@@ -32,7 +32,6 @@
|
|||||||
<jaxb-api.version>2.3.1</jaxb-api.version>
|
<jaxb-api.version>2.3.1</jaxb-api.version>
|
||||||
<springdoc.version>3.1.0</springdoc.version>
|
<springdoc.version>3.1.0</springdoc.version>
|
||||||
<kingbase.version>9.0.0</kingbase.version>
|
<kingbase.version>9.0.0</kingbase.version>
|
||||||
<paho.version>1.2.5</paho.version>
|
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<!-- 依赖声明 -->
|
<!-- 依赖声明 -->
|
||||||
@@ -145,13 +144,6 @@
|
|||||||
<version>${kingbase.version}</version>
|
<version>${kingbase.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Eclipse Paho MQTT客户端 -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.eclipse.paho</groupId>
|
|
||||||
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
|
|
||||||
<version>${paho.version}</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- 定时任务-->
|
<!-- 定时任务-->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.ruoyi</groupId>
|
<groupId>com.ruoyi</groupId>
|
||||||
|
|||||||
-113
@@ -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<String, Object> 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<Object> 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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -176,7 +176,7 @@ iot:
|
|||||||
#base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>)
|
#base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>)
|
||||||
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
|
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
|
||||||
face:
|
face:
|
||||||
# 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
|
# 必须与 iot.lock-security.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
|
||||||
environment-id: railway-iot-01
|
environment-id: railway-iot-01
|
||||||
endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
|
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
|
#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
|
avatar_profile: http://192.168.1.106:8096
|
||||||
face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU
|
face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU
|
||||||
|
|
||||||
mqtt:
|
# 门禁写操作安全策略(原 MQTT 配置中与传输无关的策略项,MQTT 已整体移除)
|
||||||
# 开关,false 时不创建任何连接
|
lock-security:
|
||||||
|
# 策略总开关,false 时禁止任何真实设备写操作
|
||||||
enabled: true
|
enabled: true
|
||||||
mock: false
|
mock: false
|
||||||
protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开)
|
protocol-verified: true # 真实协议证据已审批
|
||||||
retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false)
|
|
||||||
environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock)
|
environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock)
|
||||||
real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开)
|
real-device-write-enabled: true # 真实写操作总开关(同时约束人脸 HTTP 设备授权)
|
||||||
device-whitelist: [
|
device-whitelist: [
|
||||||
'807104B5', '80745D79','80745D95','80745D6F',
|
'807104B5', '80745D79','80745D95','80745D6F',
|
||||||
'80745DA3','80745D78','80745D89','80745D93',
|
'80745DA3','80745D78','80745D89','80745D93',
|
||||||
@@ -218,43 +218,3 @@ iot:
|
|||||||
'80745DA9','80745D91','80745D6B','80745DA6',
|
'80745DA9','80745D91','80745D6B','80745DA6',
|
||||||
'80745D81','80745D9E','80745D83','80745DA0',
|
'80745D81','80745D9E','80745D83','80745DA0',
|
||||||
'E8F7AFF69483']
|
'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
|
|
||||||
@@ -176,7 +176,7 @@ iot:
|
|||||||
#base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>)
|
#base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>)
|
||||||
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
|
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
|
||||||
face:
|
face:
|
||||||
# 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
|
# 必须与 iot.lock-security.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
|
||||||
environment-id: railway-iot-01
|
environment-id: railway-iot-01
|
||||||
#endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
|
#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
|
endpoint: http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
|
||||||
@@ -191,14 +191,13 @@ iot:
|
|||||||
enabled: true
|
enabled: true
|
||||||
api-key: please-change-me
|
api-key: please-change-me
|
||||||
path-prefix: /openapi/iot
|
path-prefix: /openapi/iot
|
||||||
mqtt:
|
# 门禁写操作安全策略(原 MQTT 配置中与传输无关的策略项,MQTT 已整体移除)
|
||||||
# 开关,false 时不创建任何连接
|
lock-security:
|
||||||
enabled: true
|
enabled: true
|
||||||
mock: false
|
mock: false
|
||||||
protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开)
|
protocol-verified: true
|
||||||
retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false)
|
environment-id: railway-iot-01
|
||||||
environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock)
|
real-device-write-enabled: true
|
||||||
real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开)
|
|
||||||
device-whitelist: [
|
device-whitelist: [
|
||||||
'807104B5', '80745D79','80745D95','80745D6F',
|
'807104B5', '80745D79','80745D95','80745D6F',
|
||||||
'80745DA3','80745D78','80745D89','80745D93',
|
'80745DA3','80745D78','80745D89','80745D93',
|
||||||
@@ -217,43 +216,3 @@ iot:
|
|||||||
'80745DA9','80745D91','80745D6B','80745DA6',
|
'80745DA9','80745D91','80745D6B','80745DA6',
|
||||||
'80745D81','80745D9E','80745D83','80745DA0',
|
'80745D81','80745D9E','80745D83','80745DA0',
|
||||||
'E8F7AFF69483']
|
'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
|
|
||||||
@@ -176,7 +176,7 @@ iot:
|
|||||||
#base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>)
|
#base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>)
|
||||||
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
|
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
|
||||||
face:
|
face:
|
||||||
# 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
|
# 必须与 iot.lock-security.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
|
||||||
environment-id: railway-iot-01
|
environment-id: railway-iot-01
|
||||||
#endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
|
#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
|
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
|
avatar_profile: http://192.168.106.24:8096
|
||||||
face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU
|
face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU
|
||||||
|
|
||||||
mqtt:
|
# 门禁写操作安全策略(原 MQTT 配置中与传输无关的策略项,MQTT 已整体移除)
|
||||||
# 开关,false 时不创建任何连接
|
lock-security:
|
||||||
enabled: true
|
enabled: true
|
||||||
mock: false
|
mock: false
|
||||||
protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开)
|
protocol-verified: true
|
||||||
retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false)
|
environment-id: railway-iot-01
|
||||||
environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock)
|
real-device-write-enabled: true
|
||||||
real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开)
|
|
||||||
device-whitelist: [
|
device-whitelist: [
|
||||||
'807104B5', '80745D79','80745D95','80745D6F',
|
'807104B5', '80745D79','80745D95','80745D6F',
|
||||||
'80745DA3','80745D78','80745D89','80745D93',
|
'80745DA3','80745D78','80745D89','80745D93',
|
||||||
@@ -217,43 +216,3 @@ iot:
|
|||||||
'80745DA9','80745D91','80745D6B','80745DA6',
|
'80745DA9','80745D91','80745D6B','80745DA6',
|
||||||
'80745D81','80745D9E','80745D83','80745DA0',
|
'80745D81','80745D9E','80745D83','80745DA0',
|
||||||
'E8F7AFF69483']
|
'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
|
|
||||||
@@ -176,7 +176,7 @@ iot:
|
|||||||
#base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>)
|
#base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>)
|
||||||
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
|
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
|
||||||
face:
|
face:
|
||||||
# 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
|
# 必须与 iot.lock-security.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
|
||||||
environment-id: railway-iot-01
|
environment-id: railway-iot-01
|
||||||
endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
|
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
|
#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
|
avatar_profile: http://192.168.1.106:8096
|
||||||
face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU
|
face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU
|
||||||
|
|
||||||
mqtt:
|
# 门禁写操作安全策略(原 MQTT 配置中与传输无关的策略项,MQTT 已整体移除)
|
||||||
# 开关,false 时不创建任何连接
|
lock-security:
|
||||||
enabled: true
|
enabled: true
|
||||||
mock: false
|
mock: false
|
||||||
protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开)
|
protocol-verified: true
|
||||||
retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false)
|
environment-id: railway-iot-01
|
||||||
environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock)
|
real-device-write-enabled: true
|
||||||
real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开)
|
|
||||||
device-whitelist: [
|
device-whitelist: [
|
||||||
'807104B5', '80745D79','80745D95','80745D6F',
|
'807104B5', '80745D79','80745D95','80745D6F',
|
||||||
'80745DA3','80745D78','80745D89','80745D93',
|
'80745DA3','80745D78','80745D89','80745D93',
|
||||||
@@ -218,43 +217,3 @@ iot:
|
|||||||
'80745DA9','80745D91','80745D6B','80745DA6',
|
'80745DA9','80745D91','80745D6B','80745DA6',
|
||||||
'80745D81','80745D9E','80745D83','80745DA0',
|
'80745D81','80745D9E','80745D83','80745DA0',
|
||||||
'E8F7AFF69483']
|
'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
|
|
||||||
@@ -12,19 +12,12 @@
|
|||||||
<artifactId>ruoyi-iot</artifactId>
|
<artifactId>ruoyi-iot</artifactId>
|
||||||
|
|
||||||
<description>
|
<description>
|
||||||
iot设备接入(MQTT)
|
iot设备数据与开放接口
|
||||||
</description>
|
</description>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
|
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
|
||||||
|
|
||||||
|
|
||||||
<!-- MQTT客户端(Eclipse Paho,协议 MQTT 3.1.1) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.eclipse.paho</groupId>
|
|
||||||
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- 通用工具-->
|
<!-- 通用工具-->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.ruoyi</groupId>
|
<groupId>com.ruoyi</groupId>
|
||||||
|
|||||||
@@ -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 移除独立保留)。
|
||||||
|
* <p>
|
||||||
|
* 用于 {@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<String> deviceWhitelist = new ArrayList<String>();
|
||||||
|
|
||||||
|
/** 真实下发协议证据是否已审批 */
|
||||||
|
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<String> getDeviceWhitelist()
|
||||||
|
{
|
||||||
|
return deviceWhitelist;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceWhitelist(List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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自动装配
|
|
||||||
* <p>
|
|
||||||
* Bean 始终注册,是否真正连接由 iot.mqtt.enabled 决定:
|
|
||||||
* 关闭时 IMqttService 仍可注入,发布命令会落入“失败待重试”,保证依赖方无需条件装配。
|
|
||||||
*
|
|
||||||
* @author ruoyi
|
|
||||||
*/
|
|
||||||
@Configuration
|
|
||||||
@EnableConfigurationProperties(IotMqttProperties.class)
|
|
||||||
public class IotMqttConfig
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* 消息分发器,收集所有 {@link IMqttMessageListener} 实现
|
|
||||||
*/
|
|
||||||
@Bean
|
|
||||||
public MqttMessageDispatcher mqttMessageDispatcher(ObjectProvider<IMqttMessageListener> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<MqttTopic> topics = new ArrayList<MqttTopic>();
|
|
||||||
|
|
||||||
/** 遗嘱消息 */
|
|
||||||
private MqttWill will;
|
|
||||||
|
|
||||||
/** 真实硬件下行写入总开关(硬性安全门禁,默认为 false,禁止发控制指令至真实硬件) */
|
|
||||||
private boolean realDeviceWriteEnabled = false;
|
|
||||||
|
|
||||||
/** 真实设备 SN 白名单(仅在白名单内的设备才允许下发控制指令) */
|
|
||||||
private List<String> deviceWhitelist = new ArrayList<String>();
|
|
||||||
|
|
||||||
/** 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<MqttTopic> getTopics()
|
|
||||||
{
|
|
||||||
return topics;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setTopics(List<MqttTopic> 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<String> getDeviceWhitelist()
|
|
||||||
{
|
|
||||||
return deviceWhitelist;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setDeviceWhitelist(List<String> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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运维接口
|
|
||||||
* <p>
|
|
||||||
* 用于查看连接状态、发布消息、动态调整订阅与手动重连。
|
|
||||||
* 接口受登录认证保护;如需菜单级权限请自行补充 @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() ? "重连成功" : "重连未成功,请检查服务端状态");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package com.ruoyi.iot.core;
|
|
||||||
|
|
||||||
import com.ruoyi.iot.domain.MqttMessageDTO;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MQTT消息监听器
|
|
||||||
* <p>
|
|
||||||
* 业务模块实现该接口并注册为 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);
|
|
||||||
}
|
|
||||||
@@ -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回调处理器
|
|
||||||
* <p>
|
|
||||||
* 使用 {@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 消息投递确认信息不完整");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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客户端管理器
|
|
||||||
* <p>
|
|
||||||
* 负责:连接服务器、订阅主题、发布消息、断线重连、资源释放。
|
|
||||||
* 重连由三层保障: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<String, Integer> subscriptions = new ConcurrentHashMap<String, Integer>();
|
|
||||||
|
|
||||||
/** 连接互斥锁,避免并发重复建连 */
|
|
||||||
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<java.util.Map<String, Object>> 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 调用)
|
|
||||||
* <p>
|
|
||||||
* 首次连接异步执行: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<String, Object> 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<String, Integer> getSubscriptions()
|
|
||||||
{
|
|
||||||
return new LinkedHashMap<String, Integer>(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<String, Integer> targets = new LinkedHashMap<String, Integer>();
|
|
||||||
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<java.util.Map<String, Object>> getMockPublishedMessages()
|
|
||||||
{
|
|
||||||
return mockPublishedMessages;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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消息分发器
|
|
||||||
* <p>
|
|
||||||
* 将收到的消息按主题分发给所有 {@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<IMqttMessageListener> listenerProvider;
|
|
||||||
|
|
||||||
/** 业务监听器集合,首次分发时解析(构造期解析会拉起业务Bean,导致与 IMqttService 循环依赖) */
|
|
||||||
private volatile List<IMqttMessageListener> listeners;
|
|
||||||
|
|
||||||
public MqttMessageDispatcher(ObjectProvider<IMqttMessageListener> listenerProvider,
|
|
||||||
ApplicationEventPublisher eventPublisher)
|
|
||||||
{
|
|
||||||
this.listenerProvider = listenerProvider;
|
|
||||||
this.eventPublisher = eventPublisher;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 惰性解析并缓存业务监听器
|
|
||||||
*/
|
|
||||||
private List<IMqttMessageListener> 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<IMqttMessageListener> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 + "}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package com.ruoyi.iot.event;
|
|
||||||
|
|
||||||
import org.springframework.context.ApplicationEvent;
|
|
||||||
import com.ruoyi.iot.domain.MqttMessageDTO;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MQTT消息事件
|
|
||||||
* <p>
|
|
||||||
* 除实现 {@link com.ruoyi.iot.core.IMqttMessageListener} 外,
|
|
||||||
* 也可以通过监听本事件来处理设备消息:
|
|
||||||
* <pre>
|
|
||||||
* @EventListener
|
|
||||||
* public void onMessage(MqttMessageEvent event) { ... }
|
|
||||||
* </pre>
|
|
||||||
*
|
|
||||||
* @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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-43
@@ -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<LockMqttLog> 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String, Object> 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<String, Object> getParams()
|
|
||||||
{
|
|
||||||
if (params == null)
|
|
||||||
{
|
|
||||||
params = new HashMap<String, Object>();
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setParams(Map<String, Object> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<LockMqttLog> selectMqttLogList(LockMqttLog mqttLog);
|
|
||||||
|
|
||||||
LockMqttLog selectMqttLogById(Long logId);
|
|
||||||
|
|
||||||
int insertMqttLog(LockMqttLog mqttLog);
|
|
||||||
|
|
||||||
int deleteMqttLogByIds(Long[] logIds);
|
|
||||||
|
|
||||||
int deleteMqttLogBefore(String keepDate);
|
|
||||||
}
|
|
||||||
@@ -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消息监听
|
|
||||||
* <p>
|
|
||||||
* 只消费 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 门禁上行主题订阅
|
|
||||||
* <p>
|
|
||||||
* 启动后订阅 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<String> topics = YdlinkTopicBuilder.uplinkWildcardTopics();
|
|
||||||
for (String topic : topics)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
mqttService.subscribe(topic, 1);
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
log.warn("订阅上行主题失败,将在重连后自动恢复, topic={}", topic);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.info("门禁上行主题订阅完成, topics={}", topics);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-220
@@ -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)
|
|
||||||
* <p>
|
|
||||||
* 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<List<String>> records = YdlinkCodec.toRecordList(frame.getParams());
|
|
||||||
int saved = 0;
|
|
||||||
for (List<String> 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<String> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-221
@@ -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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 命令响应处理
|
|
||||||
* <p>
|
|
||||||
* 覆盖:property/set_response、action/execute_response、data/batch_report
|
|
||||||
* 处理:按 seq 匹配命令 → 更新命令状态 → 授权类命令回写授权同步状态
|
|
||||||
* <p>
|
|
||||||
* 门锁类下发会收到两次响应:<br>
|
|
||||||
* 1) 网关回执(结果码 10):仅表示消息已送达网关并转发,属中间态,不能判定为完成;<br>
|
|
||||||
* 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/... 等
|
|
||||||
* <p>
|
|
||||||
* 实测两类响应:
|
|
||||||
* <pre>
|
|
||||||
* 授权 params = ["add", "807104B5", "10", "AA000006", "AA000006", "null"]
|
|
||||||
* 开关锁 params = ["807104B5", "10", "807104B5", "1", "1", "0", "0"]
|
|
||||||
* </pre>
|
|
||||||
*/
|
|
||||||
private static final Set<String> ACTION_PREFIXES = new HashSet<String>(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());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析响应结果
|
|
||||||
* <p>
|
|
||||||
* 实测报文:
|
|
||||||
* <pre>
|
|
||||||
* 网关回执 params = ["add", "807104B5", "10", "AA000006", "AA000006", "null"]
|
|
||||||
* 门锁回执 params = ["add", "807104B5", "0", "", "msg received"]
|
|
||||||
* </pre>
|
|
||||||
* 第 3 项为结果码:0=成功(终态);10=网关已接收(中间态);其余视为失败。
|
|
||||||
*/
|
|
||||||
private RespResult parseResult(YdlinkFrameDTO frame)
|
|
||||||
{
|
|
||||||
List<List<String>> records = YdlinkCodec.toRecordList(frame.getParams());
|
|
||||||
List<String> first = records.isEmpty() ? new ArrayList<String>() : 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<List<String>> 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 确定结果码在响应参数中的位置
|
|
||||||
* <p>
|
|
||||||
* 首项为动作标识时结果码在其后第 2 位;否则首项即门锁SN,结果码紧随其后。
|
|
||||||
*/
|
|
||||||
private int resolveResultCodeIndex(List<String> 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<String> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-48
@@ -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 ? "网关未登记,已忽略" : "已更新为在线";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-112
@@ -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)
|
|
||||||
* <p>
|
|
||||||
* 报文示例:
|
|
||||||
* <pre>
|
|
||||||
* {"devId":"E8F7AFF69483","seq":"0","op":"lockrssi","params":[["807104B5","-42","100","1"]]}
|
|
||||||
* </pre>
|
|
||||||
* params 为二维数组,每行按位置映射:
|
|
||||||
* [0]门锁SN [1]信号强度RSSI(dBm) [2]剩余电量(%) [3]未映射(实测为 "1",语义待确认)
|
|
||||||
* <p>
|
|
||||||
* 注意:信封 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<List<String>> records = YdlinkCodec.toRecordList(frame.getParams());
|
|
||||||
int updated = 0;
|
|
||||||
for (List<String> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-3
@@ -7,7 +7,7 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Component;
|
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.LockConstants;
|
||||||
import com.ruoyi.iot.lock.domain.*;
|
import com.ruoyi.iot.lock.domain.*;
|
||||||
import com.ruoyi.iot.lock.mapper.*;
|
import com.ruoyi.iot.lock.mapper.*;
|
||||||
@@ -17,14 +17,14 @@ import com.ruoyi.iot.lock.protocol.*;
|
|||||||
@Component
|
@Component
|
||||||
public class RealIotSecurityGate {
|
public class RealIotSecurityGate {
|
||||||
private static final Logger log = LoggerFactory.getLogger(RealIotSecurityGate.class);
|
private static final Logger log = LoggerFactory.getLogger(RealIotSecurityGate.class);
|
||||||
@Autowired private IotMqttProperties properties;
|
@Autowired private IotLockSecurityProperties properties;
|
||||||
@Autowired private LockDeviceMapper deviceMapper;
|
@Autowired private LockDeviceMapper deviceMapper;
|
||||||
@Autowired private LockGatewayMapper gatewayMapper;
|
@Autowired private LockGatewayMapper gatewayMapper;
|
||||||
@Autowired private LockGroupMapper groupMapper;
|
@Autowired private LockGroupMapper groupMapper;
|
||||||
@Autowired private LockAuthMapper authMapper;
|
@Autowired private LockAuthMapper authMapper;
|
||||||
@Autowired private LockCommandMapper commandMapper;
|
@Autowired private LockCommandMapper commandMapper;
|
||||||
public RealIotSecurityGate() { }
|
public RealIotSecurityGate() { }
|
||||||
public RealIotSecurityGate(IotMqttProperties properties) { this.properties = properties; }
|
public RealIotSecurityGate(IotLockSecurityProperties properties) { this.properties = properties; }
|
||||||
public boolean isMock() { return properties != null && properties.isMock(); }
|
public boolean isMock() { return properties != null && properties.isMock(); }
|
||||||
private static final java.util.Set<String> REAL_MODE_CONTROL = new java.util.HashSet<>(java.util.Arrays.asList(
|
private static final java.util.Set<String> REAL_MODE_CONTROL = new java.util.HashSet<>(java.util.Arrays.asList(
|
||||||
LockConstants.CMD_BIND, LockConstants.CMD_UNBIND, LockConstants.CMD_SYNC_TIME,
|
LockConstants.CMD_BIND, LockConstants.CMD_UNBIND, LockConstants.CMD_SYNC_TIME,
|
||||||
|
|||||||
+3
-1
@@ -6,7 +6,9 @@ import com.ruoyi.iot.lock.domain.LockCommand;
|
|||||||
/**
|
/**
|
||||||
* 下行命令服务
|
* 下行命令服务
|
||||||
* <p>
|
* <p>
|
||||||
* 负责命令生成与落库、发布到MQTT、响应匹配、超时判定与重试补偿。
|
* 负责命令生成与落库、响应匹配、超时判定与重试补偿。
|
||||||
|
* <p>
|
||||||
|
* 说明:MQTT 传输层已移除,命令落库后不再向外下发,派发时统一置为失败(TRANSPORT_DISABLED)。
|
||||||
*
|
*
|
||||||
* @author ruoyi
|
* @author ruoyi
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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<LockMqttLog> selectMqttLogList(LockMqttLog mqttLog);
|
|
||||||
|
|
||||||
LockMqttLog selectMqttLogById(Long logId);
|
|
||||||
|
|
||||||
int insertMqttLog(LockMqttLog mqttLog);
|
|
||||||
|
|
||||||
int deleteMqttLogByIds(Long[] logIds);
|
|
||||||
|
|
||||||
int cleanBefore(String keepDate);
|
|
||||||
}
|
|
||||||
+7
-66
@@ -6,7 +6,6 @@ import com.ruoyi.common.utils.StringUtils;
|
|||||||
import com.ruoyi.common.utils.uuid.IdUtils;
|
import com.ruoyi.common.utils.uuid.IdUtils;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.context.annotation.Lazy;
|
|
||||||
import org.springframework.core.task.TaskExecutor;
|
import org.springframework.core.task.TaskExecutor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import com.ruoyi.iot.lock.LockConstants;
|
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.protocol.*;
|
||||||
import com.ruoyi.iot.lock.security.*;
|
import com.ruoyi.iot.lock.security.*;
|
||||||
import com.ruoyi.iot.lock.service.*;
|
import com.ruoyi.iot.lock.service.*;
|
||||||
import com.ruoyi.iot.service.IMqttService;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
@@ -27,14 +25,10 @@ public class LockCommandServiceImpl implements ILockCommandService {
|
|||||||
|
|
||||||
/** 默认命令超时时间(秒) */
|
/** 默认命令超时时间(秒) */
|
||||||
private static final int DEFAULT_TIMEOUT_SECONDS = 30;
|
private static final int DEFAULT_TIMEOUT_SECONDS = 30;
|
||||||
@Autowired
|
|
||||||
private LockMqttLogMapper mqttLogMapper;
|
|
||||||
|
|
||||||
@Autowired private LockCommandMapper commandMapper;
|
@Autowired private LockCommandMapper commandMapper;
|
||||||
@Autowired private LockAuthMapper authMapper;
|
@Autowired private LockAuthMapper authMapper;
|
||||||
@Autowired private LockExecutionTransactions transactions;
|
@Autowired private LockExecutionTransactions transactions;
|
||||||
@Autowired private RealIotSecurityGate securityGate;
|
@Autowired private RealIotSecurityGate securityGate;
|
||||||
@Lazy @Autowired private IMqttService mqttService;
|
|
||||||
@Autowired @Qualifier("threadPoolTaskExecutor") private TaskExecutor taskExecutor;
|
@Autowired @Qualifier("threadPoolTaskExecutor") private TaskExecutor taskExecutor;
|
||||||
private static final Logger log = LoggerFactory.getLogger(LockCommandServiceImpl.class);
|
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.setMaxRetry(command.getMaxRetry() == null ? DEFAULT_MAX_RETRY : command.getMaxRetry());
|
||||||
command.setReqTime(new Date());
|
command.setReqTime(new Date());
|
||||||
commandMapper.insertCommand(command);
|
commandMapper.insertCommand(command);
|
||||||
// 报文留痕(下行)
|
|
||||||
recordLog(command);
|
|
||||||
// Kingbase 下 useGeneratedKeys 不会回填自增主键,按唯一 intent_key 取回真实 command_id
|
// Kingbase 下 useGeneratedKeys 不会回填自增主键,按唯一 intent_key 取回真实 command_id
|
||||||
LockCommand persisted = commandMapper.selectCommandByIntentKey(command.getIntentKey());
|
LockCommand persisted = commandMapper.selectCommandByIntentKey(command.getIntentKey());
|
||||||
final Long dispatchedId = persisted != null ? persisted.getCommandId() : command.getCommandId();
|
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())
|
return LockConstants.CMD_AUTH_PUSH.equals(command.getCommandType())
|
||||||
|| LockConstants.CMD_AUTH_CLEAR.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.
|
// Always acquire the authorization lock before the command lock.
|
||||||
private LockAuth lockAuth(LockCommand command) {
|
private LockAuth lockAuth(LockCommand command) {
|
||||||
return authCommand(command) && command.getBizId() != null
|
return authCommand(command) && command.getBizId() != null
|
||||||
@@ -166,47 +135,19 @@ public class LockCommandServiceImpl implements ILockCommandService {
|
|||||||
command.setErrorMsg("POLICY_BLOCKED_NOT_ATTEMPTED"); commandMapper.updateCommand(command);
|
command.setErrorMsg("POLICY_BLOCKED_NOT_ATTEMPTED"); commandMapper.updateCommand(command);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!mqttService.isConnected()) {
|
// MQTT 传输层已移除:命令不再下发到真实设备,直接置为失败,避免状态悬挂
|
||||||
log.warn("[TRACE-DISPATCH] cid={} isConnected=false -> TRANSPORT_DISCONNECTED", commandId);
|
log.warn("[TRACE-DISPATCH] cid={} MQTT transport removed -> TRANSPORT_DISABLED", commandId);
|
||||||
command.setCommandStatus(LockConstants.STATUS_FAIL);
|
command.setCommandStatus(LockConstants.STATUS_FAIL);
|
||||||
command.setErrorMsg("TRANSPORT_DISCONNECTED_NOT_ATTEMPTED");
|
command.setErrorMsg("TRANSPORT_DISABLED");
|
||||||
commandMapper.updateCommand(command); return null;
|
commandMapper.updateCommand(command);
|
||||||
}
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
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;
|
return null;
|
||||||
});
|
});
|
||||||
} catch (RuntimeException uncertain) {
|
return false;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public int retryPending() {
|
@Override public int retryPending() {
|
||||||
int sent = 0;
|
// MQTT 传输层已移除,不再存在可补偿下发的命令
|
||||||
for (LockCommand command : commandMapper.selectReadyCommands()) if (dispatch(command.getCommandId())) sent++;
|
return 0;
|
||||||
return sent;
|
|
||||||
}
|
}
|
||||||
@Override public void retry(Long commandId) {
|
@Override public void retry(Long commandId) {
|
||||||
LockCommand command = commandMapper.selectCommandById(commandId);
|
LockCommand command = commandMapper.selectCommandById(commandId);
|
||||||
|
|||||||
-50
@@ -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<LockMqttLog> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-42
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-45
@@ -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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 门锁离线检测:超过指定时间未上报即置为离线
|
|
||||||
* <p>
|
|
||||||
* 门锁按 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String, Integer> getSubscriptions();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 模拟设备上行回执注入 (用于 QA 与端到端测试驱动)
|
|
||||||
*/
|
|
||||||
default void mockReceive(String topic, String payload) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有拦截到的 Mock 下行消息
|
|
||||||
*/
|
|
||||||
default java.util.List<java.util.Map<String, Object>> getMockPublishedMessages()
|
|
||||||
{
|
|
||||||
return java.util.Collections.emptyList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String, Integer> getSubscriptions()
|
|
||||||
{
|
|
||||||
return clientManager.getSubscriptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void mockReceive(String topic, String payload)
|
|
||||||
{
|
|
||||||
clientManager.mockReceive(topic, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public java.util.List<java.util.Map<String, Object>> getMockPublishedMessages()
|
|
||||||
{
|
|
||||||
return clientManager.getMockPublishedMessages();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" ?>
|
|
||||||
<!DOCTYPE mapper
|
|
||||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|
||||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
|
||||||
<mapper namespace="com.ruoyi.iot.lock.mapper.LockMqttLogMapper">
|
|
||||||
|
|
||||||
<resultMap type="LockMqttLog" id="LockMqttLogResult">
|
|
||||||
<id property="logId" column="log_id" />
|
|
||||||
<result property="direction" column="direction" />
|
|
||||||
<result property="topic" column="topic" />
|
|
||||||
<result property="groupCode" column="group_code" />
|
|
||||||
<result property="gatewaySn" column="gateway_sn" />
|
|
||||||
<result property="deviceSn" column="device_sn" />
|
|
||||||
<result property="op" column="op" />
|
|
||||||
<result property="mode" column="mode" />
|
|
||||||
<result property="ac" column="ac" />
|
|
||||||
<result property="seq" column="seq" />
|
|
||||||
<result property="qos" column="qos" />
|
|
||||||
<result property="retained" column="retained" />
|
|
||||||
<result property="payload" column="payload" />
|
|
||||||
<result property="chksum" column="chksum" />
|
|
||||||
<result property="verifyStatus" column="verify_status" />
|
|
||||||
<result property="handleStatus" column="handle_status" />
|
|
||||||
<result property="handleMsg" column="handle_msg" />
|
|
||||||
<result property="costTime" column="cost_time" />
|
|
||||||
<result property="recvTime" column="recv_time" />
|
|
||||||
<result property="createTime" column="create_time" />
|
|
||||||
</resultMap>
|
|
||||||
|
|
||||||
<sql id="selectMqttLogVo">
|
|
||||||
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
|
|
||||||
</sql>
|
|
||||||
|
|
||||||
<select id="selectMqttLogList" parameterType="LockMqttLog" resultMap="LockMqttLogResult">
|
|
||||||
<include refid="selectMqttLogVo"/>
|
|
||||||
<where>
|
|
||||||
<if test="direction != null and direction != ''">
|
|
||||||
and direction = #{direction}
|
|
||||||
</if>
|
|
||||||
<if test="topic != null and topic != ''">
|
|
||||||
and topic like concat('%', #{topic}, '%')
|
|
||||||
</if>
|
|
||||||
<if test="deviceSn != null and deviceSn != ''">
|
|
||||||
and device_sn = #{deviceSn}
|
|
||||||
</if>
|
|
||||||
<if test="gatewaySn != null and gatewaySn != ''">
|
|
||||||
and gateway_sn = #{gatewaySn}
|
|
||||||
</if>
|
|
||||||
<if test="op != null and op != ''">
|
|
||||||
and op = #{op}
|
|
||||||
</if>
|
|
||||||
<if test="seq != null and seq != ''">
|
|
||||||
and seq = #{seq}
|
|
||||||
</if>
|
|
||||||
<if test="handleStatus != null and handleStatus != ''">
|
|
||||||
and handle_status = #{handleStatus}
|
|
||||||
</if>
|
|
||||||
<if test="params.beginTime != null and params.beginTime != ''">
|
|
||||||
<choose>
|
|
||||||
<when test="_databaseId == 'kingbase'">and to_char(recv_time,'yyyymmdd') >= to_char(to_date(#{params.beginTime},'yyyy-mm-dd'),'yyyymmdd')</when>
|
|
||||||
<otherwise>and date_format(recv_time,'%Y%m%d') >= date_format(#{params.beginTime},'%Y%m%d')</otherwise>
|
|
||||||
</choose>
|
|
||||||
</if>
|
|
||||||
<if test="params.endTime != null and params.endTime != ''">
|
|
||||||
<choose>
|
|
||||||
<when test="_databaseId == 'kingbase'">and to_char(recv_time,'yyyymmdd') <= to_char(to_date(#{params.endTime},'yyyy-mm-dd'),'yyyymmdd')</when>
|
|
||||||
<otherwise>and date_format(recv_time,'%Y%m%d') <= date_format(#{params.endTime},'%Y%m%d')</otherwise>
|
|
||||||
</choose>
|
|
||||||
</if>
|
|
||||||
</where>
|
|
||||||
order by log_id desc
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select id="selectMqttLogById" parameterType="Long" resultMap="LockMqttLogResult">
|
|
||||||
<include refid="selectMqttLogVo"/>
|
|
||||||
where log_id = #{logId}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<insert id="insertMqttLog" parameterType="LockMqttLog" useGeneratedKeys="true" keyProperty="logId">
|
|
||||||
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
|
|
||||||
)
|
|
||||||
</insert>
|
|
||||||
|
|
||||||
<delete id="deleteMqttLogByIds" parameterType="Long">
|
|
||||||
delete from lock_mqtt_log where log_id in
|
|
||||||
<foreach collection="array" item="logId" open="(" separator="," close=")">
|
|
||||||
#{logId}
|
|
||||||
</foreach>
|
|
||||||
</delete>
|
|
||||||
|
|
||||||
<delete id="deleteMqttLogBefore" parameterType="String">
|
|
||||||
delete from lock_mqtt_log where
|
|
||||||
<choose>
|
|
||||||
<when test="_databaseId == 'kingbase'">recv_time < to_date(#{keepDate},'yyyy-mm-dd')</when>
|
|
||||||
<otherwise>recv_time < #{keepDate}</otherwise>
|
|
||||||
</choose>
|
|
||||||
</delete>
|
|
||||||
|
|
||||||
</mapper>
|
|
||||||
-95
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -65,7 +65,7 @@ public class FaceExtractClientMockTest
|
|||||||
props.setMock(false); props.setEnabled(true); props.setRealHttpEnabled(true);
|
props.setMock(false); props.setEnabled(true); props.setRealHttpEnabled(true);
|
||||||
props.setEnvironmentId("isolated-test-real");
|
props.setEnvironmentId("isolated-test-real");
|
||||||
props.setAllowedEndpoints(java.util.Arrays.asList(props.getEndpoint()));
|
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.setEnabled(true); mqtt.setRealDeviceWriteEnabled(true); mqtt.setEnvironmentId(props.getEnvironmentId());
|
||||||
mqtt.setDeviceWhitelist(java.util.Arrays.asList("TEST0001"));
|
mqtt.setDeviceWhitelist(java.util.Arrays.asList("TEST0001"));
|
||||||
Field gateField = FaceExtractClient.class.getDeclaredField("securityGate"); gateField.setAccessible(true);
|
Field gateField = FaceExtractClient.class.getDeclaredField("securityGate"); gateField.setAccessible(true);
|
||||||
|
|||||||
+6
-11
@@ -2,20 +2,19 @@ package com.ruoyi.iot.lock.security;
|
|||||||
import static org.junit.Assert.*;
|
import static org.junit.Assert.*;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import com.ruoyi.iot.config.IotMqttProperties;
|
import com.ruoyi.iot.config.IotLockSecurityProperties;
|
||||||
import com.ruoyi.iot.core.MqttClientManager;
|
|
||||||
import com.ruoyi.iot.lock.domain.LockAuth;
|
import com.ruoyi.iot.lock.domain.LockAuth;
|
||||||
|
|
||||||
public class RealIotSecurityGateTest {
|
public class RealIotSecurityGateTest {
|
||||||
@Test public void defaultsAreClosed() {
|
@Test public void defaultsAreClosed() {
|
||||||
IotMqttProperties p=new IotMqttProperties();
|
IotLockSecurityProperties p=new IotLockSecurityProperties();
|
||||||
RealIotSecurityGate g=new RealIotSecurityGate(p);
|
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"));
|
assertTrue(p.getDeviceWhitelist().isEmpty());assertFalse(g.checkDeviceWriteAllowed("TEST0001","0011"));
|
||||||
assertFalse(g.sameOrigin(new LockAuth()));
|
assertFalse(g.sameOrigin(new LockAuth()));
|
||||||
}
|
}
|
||||||
@Test public void explicitIdentityOnly() {
|
@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);
|
RealIotSecurityGate g=new RealIotSecurityGate(p);
|
||||||
assertFalse(g.checkDeviceWriteAllowed("TEST0001","0011"));
|
assertFalse(g.checkDeviceWriteAllowed("TEST0001","0011"));
|
||||||
p.setDeviceWhitelist(Arrays.asList("TEST0001"));
|
p.setDeviceWhitelist(Arrays.asList("TEST0001"));
|
||||||
@@ -24,17 +23,13 @@ public class RealIotSecurityGateTest {
|
|||||||
assertFalse(g.checkDeviceWriteAllowed("UNKNOWN","0011"));
|
assertFalse(g.checkDeviceWriteAllowed("UNKNOWN","0011"));
|
||||||
}
|
}
|
||||||
@Test public void provenanceIsStableAcrossGateRestartButNotAcrossEnvironments() {
|
@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);
|
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));
|
a.setIntentAt(new Date(0));assertTrue(new RealIotSecurityGate(p).sameOrigin(a));
|
||||||
p.setMock(false);p.setEnvironmentId("test-real");assertFalse(new RealIotSecurityGate(p).sameOrigin(a));
|
p.setMock(false);p.setEnvironmentId("test-real");assertFalse(new RealIotSecurityGate(p).sameOrigin(a));
|
||||||
}
|
}
|
||||||
@Test public void personNumberIsStrict() {
|
@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 "));
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
-125
@@ -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<String,Object> values:Arrays.<Map<String,Object>>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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-328
@@ -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> T fake(Class<T> 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<Boolean> one=pool.submit(() -> commands.dispatch(c.getCommandId()));Future<Boolean> 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<org.eclipse.paho.client.mqttv3.MqttMessage> 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<String,Integer> getSubscriptions(){return Collections.emptyMap();}
|
|
||||||
}
|
|
||||||
private static class SimulatedProcessCrash extends Error {}
|
|
||||||
}
|
|
||||||
-49
@@ -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<Long> devices=new HashSet<>(Arrays.asList(Long.valueOf(p.getProperty("deviceA")),Long.valueOf(p.getProperty("deviceB"))));
|
|
||||||
java.util.List<LockAuth> 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<Long> 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-227
@@ -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<Future<?>> 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<org.springframework.core.io.Resource> 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>T transactional(Object target,Class<T> 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<LockAuth> 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<String,Integer> 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-2
@@ -22,7 +22,6 @@ import com.ruoyi.iot.lock.domain.LockFace;
|
|||||||
import com.ruoyi.iot.lock.mapper.LockAuthMapper;
|
import com.ruoyi.iot.lock.mapper.LockAuthMapper;
|
||||||
import com.ruoyi.iot.lock.mapper.LockCommandMapper;
|
import com.ruoyi.iot.lock.mapper.LockCommandMapper;
|
||||||
import com.ruoyi.iot.lock.mapper.LockDeviceMapper;
|
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.YdlinkFrameDTO;
|
||||||
import com.ruoyi.iot.lock.protocol.YdlinkOp;
|
import com.ruoyi.iot.lock.protocol.YdlinkOp;
|
||||||
import com.ruoyi.iot.lock.protocol.YdlinkTopicDTO;
|
import com.ruoyi.iot.lock.protocol.YdlinkTopicDTO;
|
||||||
@@ -121,7 +120,7 @@ public class TrainIotLockAuthBridgeMockTest
|
|||||||
public void rollback(org.springframework.transaction.TransactionStatus s) { }
|
public void rollback(org.springframework.transaction.TransactionStatus s) { }
|
||||||
});
|
});
|
||||||
setField(bridgeService, "executionTransactions", transactions);
|
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");
|
executionProps.setMock(true); executionProps.setEnvironmentId("bridge-fixture-mock");
|
||||||
com.ruoyi.iot.lock.security.RealIotSecurityGate executionGate = new com.ruoyi.iot.lock.security.RealIotSecurityGate(executionProps);
|
com.ruoyi.iot.lock.security.RealIotSecurityGate executionGate = new com.ruoyi.iot.lock.security.RealIotSecurityGate(executionProps);
|
||||||
setField(executionGate, "deviceMapper", createMockDeviceMapper());
|
setField(executionGate, "deviceMapper", createMockDeviceMapper());
|
||||||
|
|||||||
@@ -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'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="app-container">
|
|
||||||
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
|
|
||||||
<el-form-item label="方向" prop="direction">
|
|
||||||
<el-select v-model="queryParams.direction" placeholder="报文方向" clearable style="width: 160px">
|
|
||||||
<el-option label="上行(设备→平台)" value="1" />
|
|
||||||
<el-option label="下行(平台→设备)" value="2" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="主题" prop="topic">
|
|
||||||
<el-input
|
|
||||||
v-model="queryParams.topic"
|
|
||||||
placeholder="请输入MQTT主题"
|
|
||||||
clearable
|
|
||||||
style="width: 240px"
|
|
||||||
@keyup.enter.native="handleQuery"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="设备SN" prop="deviceSn">
|
|
||||||
<el-input
|
|
||||||
v-model="queryParams.deviceSn"
|
|
||||||
placeholder="请输入设备SN"
|
|
||||||
clearable
|
|
||||||
style="width: 200px"
|
|
||||||
@keyup.enter.native="handleQuery"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="处理状态" prop="handleStatus">
|
|
||||||
<el-select v-model="queryParams.handleStatus" placeholder="处理状态" clearable style="width: 160px">
|
|
||||||
<el-option
|
|
||||||
v-for="dict in dict.type.lock_handle_status"
|
|
||||||
:key="dict.value"
|
|
||||||
:label="dict.label"
|
|
||||||
:value="dict.value"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="时间范围">
|
|
||||||
<el-date-picker
|
|
||||||
v-model="dateRange"
|
|
||||||
style="width: 240px"
|
|
||||||
value-format="yyyy-MM-dd"
|
|
||||||
type="daterange"
|
|
||||||
range-separator="-"
|
|
||||||
start-placeholder="开始日期"
|
|
||||||
end-placeholder="结束日期"
|
|
||||||
></el-date-picker>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
|
||||||
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
|
|
||||||
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
|
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="mqttLogList">
|
|
||||||
<el-table-column label="日志ID" align="center" prop="logId" width="80" />
|
|
||||||
<el-table-column label="方向" align="center" prop="direction" width="120">
|
|
||||||
<template slot-scope="scope">
|
|
||||||
<el-tag v-if="scope.row.direction === '1'" type="success" size="mini">上行</el-tag>
|
|
||||||
<el-tag v-else type="warning" size="mini">下行</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="主题" align="center" prop="topic" :show-overflow-tooltip="true" />
|
|
||||||
<el-table-column label="操作类型" align="center" prop="op" width="100" />
|
|
||||||
<el-table-column label="设备SN" align="center" prop="deviceSn" :show-overflow-tooltip="true" />
|
|
||||||
<el-table-column label="处理状态" align="center" prop="handleStatus" width="100">
|
|
||||||
<template slot-scope="scope">
|
|
||||||
<dict-tag :options="dict.type.lock_handle_status" :value="scope.row.handleStatus"/>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="耗时(ms)" align="center" prop="costTime" width="90" />
|
|
||||||
<el-table-column label="接收时间" align="center" prop="recvTime" width="160">
|
|
||||||
<template slot-scope="scope">
|
|
||||||
<span>{{ parseTime(scope.row.recvTime) }}</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" align="center" width="100" class-name="small-padding fixed-width">
|
|
||||||
<template slot-scope="scope">
|
|
||||||
<el-button
|
|
||||||
size="mini"
|
|
||||||
type="text"
|
|
||||||
icon="el-icon-view"
|
|
||||||
@click="handleDetail(scope.row)"
|
|
||||||
>报文</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<pagination
|
|
||||||
v-show="total>0"
|
|
||||||
:total="total"
|
|
||||||
:page.sync="queryParams.pageNum"
|
|
||||||
:limit.sync="queryParams.pageSize"
|
|
||||||
@pagination="getList"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 报文详情 -->
|
|
||||||
<el-dialog :title="'报文详情'" :visible.sync="detailOpen" width="800px" append-to-body>
|
|
||||||
<el-descriptions :column="2" border>
|
|
||||||
<el-descriptions-item label="主题" :span="2">{{ detail.topic }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="报文序号">{{ detail.seq }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="校验和">{{ detail.chksum }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="处理说明" :span="2">{{ detail.handleMsg }}</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
<div style="margin-top: 15px">
|
|
||||||
<div style="margin-bottom: 6px; font-weight: bold">报文原文</div>
|
|
||||||
<pre style="white-space: pre-wrap; word-break: break-all; background: #f5f7fa; padding: 10px">{{ formatJson(detail.payload) }}</pre>
|
|
||||||
</div>
|
|
||||||
<div slot="footer" class="dialog-footer">
|
|
||||||
<el-button @click="detailOpen = false">关 闭</el-button>
|
|
||||||
</div>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { listMqttLog, getMqttLog } from "@/api/iot/mqttlog";
|
|
||||||
import { addDateRange } from "@/utils/ruoyi";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "MqttLog",
|
|
||||||
dicts: ['lock_handle_status'],
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
loading: true,
|
|
||||||
showSearch: true,
|
|
||||||
total: 0,
|
|
||||||
mqttLogList: [],
|
|
||||||
dateRange: [],
|
|
||||||
detailOpen: false,
|
|
||||||
detail: {},
|
|
||||||
queryParams: {
|
|
||||||
pageNum: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
direction: undefined,
|
|
||||||
topic: undefined,
|
|
||||||
deviceSn: undefined,
|
|
||||||
handleStatus: undefined
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
this.getList();
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getList() {
|
|
||||||
this.loading = true;
|
|
||||||
listMqttLog(addDateRange(this.queryParams, this.dateRange)).then(response => {
|
|
||||||
this.mqttLogList = response.rows;
|
|
||||||
this.total = response.total;
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
handleQuery() {
|
|
||||||
this.queryParams.pageNum = 1;
|
|
||||||
this.getList();
|
|
||||||
},
|
|
||||||
resetQuery() {
|
|
||||||
this.dateRange = [];
|
|
||||||
this.resetForm("queryForm");
|
|
||||||
this.handleQuery();
|
|
||||||
},
|
|
||||||
handleDetail(row) {
|
|
||||||
getMqttLog(row.logId).then(response => {
|
|
||||||
this.detail = response.data;
|
|
||||||
this.detailOpen = true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
formatJson(value) {
|
|
||||||
if (!value) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(JSON.parse(value), null, 2);
|
|
||||||
} catch (e) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@@ -12,7 +12,7 @@ async (page) => {
|
|||||||
['/index', null], ['/person/student', 'Student'], ['/person/teacher', null],
|
['/index', null], ['/person/student', 'Student'], ['/person/teacher', null],
|
||||||
['/coursemgr/plan', null], ['/equip/dormitory', null], ['/equip/accommodation', 'Accommodation'],
|
['/coursemgr/plan', null], ['/equip/dormitory', null], ['/equip/accommodation', 'Accommodation'],
|
||||||
...Object.entries({group:'Group',gateway:'Gateway',device:'Device',face:'Face',auth:'Auth',
|
...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=[];
|
const results=[];
|
||||||
for (const [path,expected] of routes) {
|
for (const [path,expected] of routes) {
|
||||||
|
|||||||
+4
-4
@@ -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.host=127.0.0.1', '--spring.data.redis.port=' + str(redis_port),
|
||||||
'--spring.data.redis.database=0', '--spring.quartz.auto-startup=false',
|
'--spring.data.redis.database=0', '--spring.quartz.auto-startup=false',
|
||||||
'--ruoyi.profile=' + str(RUNTIME / 'upload'), '--logging.file.path=' + str(RUNTIME / 'logs'),
|
'--ruoyi.profile=' + str(RUNTIME / 'upload'), '--logging.file.path=' + str(RUNTIME / 'logs'),
|
||||||
'--logging.level.com.ruoyi=INFO', '--iot.mqtt.mock=true', '--iot.mqtt.enabled=false',
|
'--logging.level.com.ruoyi=INFO', '--iot.lock-security.mock=true', '--iot.lock-security.enabled=false',
|
||||||
'--iot.mqtt.real-device-write-enabled=false', '--iot.mqtt.device-whitelist=',
|
'--iot.lock-security.real-device-write-enabled=false', '--iot.lock-security.device-whitelist=',
|
||||||
'--iot.mqtt.environment-id=round5v-local-mock', '--iot.mqtt.retry-task-enabled=false',
|
'--iot.lock-security.environment-id=round5v-local-mock', '--iot.lock-security.protocol-verified=false',
|
||||||
'--iot.mqtt.protocol-verified=false', '--iot.qa-api-enabled=false', '--iot.openapi.enabled=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.mock=true', '--iot.face.enabled=false', '--iot.face.real-http-enabled=false',
|
||||||
'--iot.face.allowed-endpoints=', '--dms.enabled=false', '--dms.mock=true',
|
'--iot.face.allowed-endpoints=', '--dms.enabled=false', '--dms.mock=true',
|
||||||
'--spring.datasource.druid.stat-view-servlet.enabled=false']
|
'--spring.datasource.druid.stat-view-servlet.enabled=false']
|
||||||
|
|||||||
@@ -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))
|
self.assertEqual('jdbc:kingbase8://192.168.139.218:4321/' + qa.DATABASE + '?characterEncoding=utf8', qa.druid_url(url))
|
||||||
|
|
||||||
def test_unsafe_inherited_overrides_removed(self):
|
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'})
|
env = qa.environment({'FUSION_QA_APP_PASSWORD': 'synthetic'})
|
||||||
self.assertNotIn('SPRING_APPLICATION_JSON', env)
|
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)
|
self.assertNotIn('JAVA_TOOL_OPTIONS', env)
|
||||||
|
|
||||||
def test_private_config_required(self):
|
def test_private_config_required(self):
|
||||||
@@ -78,8 +78,9 @@ class QaToolsTest(unittest.TestCase):
|
|||||||
self.assertEqual(2,launch.call_count)
|
self.assertEqual(2,launch.call_count)
|
||||||
name, command, env = launch.call_args.args
|
name, command, env = launch.call_args.args
|
||||||
self.assertEqual('backend',name)
|
self.assertEqual('backend',name)
|
||||||
for flag in ['--iot.mqtt.enabled=false','--iot.mqtt.mock=true', '--iot.mqtt.real-device-write-enabled=false',
|
for flag in ['--iot.lock-security.enabled=false','--iot.lock-security.mock=true',
|
||||||
'--iot.mqtt.device-whitelist=', '--iot.mqtt.retry-task-enabled=false', '--iot.qa-api-enabled=false',
|
'--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',
|
'--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']:
|
'--spring.quartz.auto-startup=false','--spring.datasource.druid.master.username=fusion_qa_app']:
|
||||||
self.assertIn(flag,command)
|
self.assertIn(flag,command)
|
||||||
|
|||||||
Reference in new issue
Block a user