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:
buliping committed 2026-11-10 16:06:23 +08:00
1 parent ee629f2f3e
commit 5675053d09
54 files changed
+690 -4734

No files matched your search

+276
View File
@@ -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` 六类文件**不在删除范围**;删除会直接破坏保留功能或导致其它模块编译失败。
---
*文档结束。*
+259
View File
@@ -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`(会周期性重发至真实设备,属生产变更,需审批)。
---
*文档结束。如需补充时序图或按模块拆分,可在此基础上扩展。*
-8
View File
@@ -32,7 +32,6 @@
<jaxb-api.version>2.3.1</jaxb-api.version>
<springdoc.version>3.1.0</springdoc.version>
<kingbase.version>9.0.0</kingbase.version>
<paho.version>1.2.5</paho.version>
</properties>
<!-- 依赖声明 -->
@@ -145,13 +144,6 @@
<version>${kingbase.version}</version>
</dependency>
<!-- Eclipse Paho MQTT客户端 -->
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<version>${paho.version}</version>
</dependency>
<!-- 定时任务-->
<dependency>
<groupId>com.ruoyi</groupId>
@@ -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>:<端口>)
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
face:
# 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
# 必须与 iot.lock-security.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
environment-id: railway-iot-01
endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
#endpoint: http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
@@ -192,14 +192,14 @@ iot:
avatar_profile: http://192.168.1.106:8096
face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU
mqtt:
# 开关,false 时不创建任何连接
# 门禁写操作安全策略(原 MQTT 配置中与传输无关的策略项,MQTT 已整体移除)
lock-security:
# 策略总开关,false 时禁止任何真实设备写操作
enabled: true
mock: false
protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开)
retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false)
protocol-verified: true # 真实协议证据已审批
environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock)
real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开)
real-device-write-enabled: true # 真实写操作总开关(同时约束人脸 HTTP 设备授权)
device-whitelist: [
'807104B5', '80745D79','80745D95','80745D6F',
'80745DA3','80745D78','80745D89','80745D93',
@@ -218,43 +218,3 @@ iot:
'80745DA9','80745D91','80745D6B','80745DA6',
'80745D81','80745D9E','80745D83','80745DA0',
'E8F7AFF69483']
# MQTT服务器地址,ssl 场景使用 ssl://host:8883
broker-url: tcp://192.168.1.202:1883
#broker-url: tcp://192.168.1.230:1883
# 客户端ID,同一服务器下必须唯一;为空时自动生成
client-id:
username: admin3
password: admin3
# false 时服务端保留离线消息,需配合固定 client-id
clean-session: true
connection-timeout: 30
keep-alive-interval: 60
# 断线自动重连
automatic-reconnect: true
max-reconnect-delay: 30000
# 连接健康检查周期(毫秒),0 表示不做定时重连
health-check-interval: 30000
# 发布消息等待服务端确认时间(毫秒),0 表示不等待
publish-timeout: 3000
# 消息持久化目录,为空使用内存
persistence-directory:
default-qos: 1
default-retained: false
# 只订阅上行主题(下行由平台发布,不消费自己发出的指令)
topics:
- topic: ydlink/+/thing/onoff_line # 网关上下线
qos: 1
- topic: ydlink/+/thing/property/set_response # 属性设置响应
qos: 1
- topic: ydlink/+/thing/action/execute_response # 远程开关锁响应
qos: 1
- topic: ydlink/+/thing/data/batch_report # 授权下发结果
qos: 1
- topic: ydlink/+/thing/event/trigger # 开门记录上报
qos: 1
# 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定)
# will:
# topic: ydlink/status
# payload: offline
# qos: 1
# retained: true
@@ -176,7 +176,7 @@ iot:
#base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>)
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
face:
# 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
# 必须与 iot.lock-security.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
environment-id: railway-iot-01
#endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
endpoint: http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
@@ -191,14 +191,13 @@ iot:
enabled: true
api-key: please-change-me
path-prefix: /openapi/iot
mqtt:
# 开关,false 时不创建任何连接
# 门禁写操作安全策略(原 MQTT 配置中与传输无关的策略项,MQTT 已整体移除)
lock-security:
enabled: true
mock: false
protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开)
retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false)
environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock)
real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开)
protocol-verified: true
environment-id: railway-iot-01
real-device-write-enabled: true
device-whitelist: [
'807104B5', '80745D79','80745D95','80745D6F',
'80745DA3','80745D78','80745D89','80745D93',
@@ -217,43 +216,3 @@ iot:
'80745DA9','80745D91','80745D6B','80745DA6',
'80745D81','80745D9E','80745D83','80745DA0',
'E8F7AFF69483']
# MQTT服务器地址,ssl 场景使用 ssl://host:8883
#broker-url: tcp://192.168.1.202:1883
broker-url: tcp://192.168.1.230:1883
# 客户端ID,同一服务器下必须唯一;为空时自动生成
client-id:
username: admin3
password: admin3
# false 时服务端保留离线消息,需配合固定 client-id
clean-session: true
connection-timeout: 30
keep-alive-interval: 60
# 断线自动重连
automatic-reconnect: true
max-reconnect-delay: 30000
# 连接健康检查周期(毫秒),0 表示不做定时重连
health-check-interval: 30000
# 发布消息等待服务端确认时间(毫秒),0 表示不等待
publish-timeout: 3000
# 消息持久化目录,为空使用内存
persistence-directory:
default-qos: 1
default-retained: false
# 只订阅上行主题(下行由平台发布,不消费自己发出的指令)
topics:
- topic: ydlink/+/thing/onoff_line # 网关上下线
qos: 1
- topic: ydlink/+/thing/property/set_response # 属性设置响应
qos: 1
- topic: ydlink/+/thing/action/execute_response # 远程开关锁响应
qos: 1
- topic: ydlink/+/thing/data/batch_report # 授权下发结果
qos: 1
- topic: ydlink/+/thing/event/trigger # 开门记录上报
qos: 1
# 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定)
# will:
# topic: ydlink/status
# payload: offline
# qos: 1
# retained: true
@@ -176,7 +176,7 @@ iot:
#base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>)
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
face:
# 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
# 必须与 iot.lock-security.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
environment-id: railway-iot-01
#endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
endpoint: http://192.168.106.24:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
@@ -191,14 +191,13 @@ iot:
avatar_profile: http://192.168.106.24:8096
face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU
mqtt:
# 开关,false 时不创建任何连接
# 门禁写操作安全策略(原 MQTT 配置中与传输无关的策略项,MQTT 已整体移除)
lock-security:
enabled: true
mock: false
protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开)
retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false)
environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock)
real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开)
protocol-verified: true
environment-id: railway-iot-01
real-device-write-enabled: true
device-whitelist: [
'807104B5', '80745D79','80745D95','80745D6F',
'80745DA3','80745D78','80745D89','80745D93',
@@ -217,43 +216,3 @@ iot:
'80745DA9','80745D91','80745D6B','80745DA6',
'80745D81','80745D9E','80745D83','80745DA0',
'E8F7AFF69483']
# MQTT服务器地址,ssl 场景使用 ssl://host:8883
#broker-url: tcp://192.168.1.202:1883
broker-url: tcp://192.168.106.24:1883
# 客户端ID,同一服务器下必须唯一;为空时自动生成
client-id:
username: admin3
password: admin3
# false 时服务端保留离线消息,需配合固定 client-id
clean-session: true
connection-timeout: 30
keep-alive-interval: 60
# 断线自动重连
automatic-reconnect: true
max-reconnect-delay: 30000
# 连接健康检查周期(毫秒),0 表示不做定时重连
health-check-interval: 30000
# 发布消息等待服务端确认时间(毫秒),0 表示不等待
publish-timeout: 3000
# 消息持久化目录,为空使用内存
persistence-directory:
default-qos: 1
default-retained: false
# 只订阅上行主题(下行由平台发布,不消费自己发出的指令)
topics:
- topic: ydlink/+/thing/onoff_line # 网关上下线
qos: 1
- topic: ydlink/+/thing/property/set_response # 属性设置响应
qos: 1
- topic: ydlink/+/thing/action/execute_response # 远程开关锁响应
qos: 1
- topic: ydlink/+/thing/data/batch_report # 授权下发结果
qos: 1
- topic: ydlink/+/thing/event/trigger # 开门记录上报
qos: 1
# 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定)
# will:
# topic: ydlink/status
# payload: offline
# qos: 1
# retained: true
@@ -176,7 +176,7 @@ iot:
#base-url: http://localhost:8096 # 相对路径拼接后端 base URL 用;本地联调用默认值即可,生产环境改成公网地址(如 http://<公网IP>:<端口>)
# 人脸特征提取服务(数据库 lock_service_config 中 config_type=02 的配置优先级更高)
face:
# 必须与 iot.mqtt.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
# 必须与 iot.lock-security.environment-id 一致,否则 requireFaceIdentity 判定环境不一致而拒绝人脸提取授权
environment-id: railway-iot-01
endpoint: http://192.168.1.202:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
#endpoint: http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData
@@ -192,14 +192,13 @@ iot:
avatar_profile: http://192.168.1.106:8096
face_api_key: To_eIlokUB6vf6HPSFugljLaAZh38AOI3al5xwb8rFU
mqtt:
# 开关,false 时不创建任何连接
# 门禁写操作安全策略(原 MQTT 配置中与传输无关的策略项,MQTT 已整体移除)
lock-security:
enabled: true
mock: false
protocol-verified: true # 真实协议证据已审批,允许真实下发(默认 false,必须显式开)
retry-task-enabled: false # 开启命令补偿调度(每 60s 捞 READY 指令兜底下发,默认 false)
environment-id: railway-iot-01 # 真实环境标识,必须不含 "mock"(避免回退到默认 local-mock)
real-device-write-enabled: true # 真实下行总开关(默认 false,必须显式开)
protocol-verified: true
environment-id: railway-iot-01
real-device-write-enabled: true
device-whitelist: [
'807104B5', '80745D79','80745D95','80745D6F',
'80745DA3','80745D78','80745D89','80745D93',
@@ -218,43 +217,3 @@ iot:
'80745DA9','80745D91','80745D6B','80745DA6',
'80745D81','80745D9E','80745D83','80745DA0',
'E8F7AFF69483']
# MQTT服务器地址,ssl 场景使用 ssl://host:8883
broker-url: tcp://192.168.1.202:1883
#broker-url: tcp://192.168.1.230:1883
# 客户端ID,同一服务器下必须唯一;为空时自动生成
client-id:
username: admin3
password: admin3
# false 时服务端保留离线消息,需配合固定 client-id
clean-session: true
connection-timeout: 30
keep-alive-interval: 60
# 断线自动重连
automatic-reconnect: true
max-reconnect-delay: 30000
# 连接健康检查周期(毫秒),0 表示不做定时重连
health-check-interval: 30000
# 发布消息等待服务端确认时间(毫秒),0 表示不等待
publish-timeout: 3000
# 消息持久化目录,为空使用内存
persistence-directory:
default-qos: 1
default-retained: false
# 只订阅上行主题(下行由平台发布,不消费自己发出的指令)
topics:
- topic: ydlink/+/thing/onoff_line # 网关上下线
qos: 1
- topic: ydlink/+/thing/property/set_response # 属性设置响应
qos: 1
- topic: ydlink/+/thing/action/execute_response # 远程开关锁响应
qos: 1
- topic: ydlink/+/thing/data/batch_report # 授权下发结果
qos: 1
- topic: ydlink/+/thing/event/trigger # 开门记录上报
qos: 1
# 遗嘱消息:无法区分主动断开与异常掉线,门禁场景建议关闭(离线由 GatewayOfflineCheckTask 判定)
# will:
# topic: ydlink/status
# payload: offline
# qos: 1
# retained: true
+1 -8
View File
@@ -12,19 +12,12 @@
<artifactId>ruoyi-iot</artifactId>
<description>
iot设备接入(MQTT)
iot设备数据与开放接口
</description>
<dependencies>
<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>
<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>
* &#64;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;
}
}
@@ -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);
}
}
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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 ? "网关未登记,已忽略" : "已更新为在线";
}
}
@@ -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;
}
}
}
@@ -7,7 +7,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.ruoyi.iot.config.IotMqttProperties;
import com.ruoyi.iot.config.IotLockSecurityProperties;
import com.ruoyi.iot.lock.LockConstants;
import com.ruoyi.iot.lock.domain.*;
import com.ruoyi.iot.lock.mapper.*;
@@ -17,14 +17,14 @@ import com.ruoyi.iot.lock.protocol.*;
@Component
public class RealIotSecurityGate {
private static final Logger log = LoggerFactory.getLogger(RealIotSecurityGate.class);
@Autowired private IotMqttProperties properties;
@Autowired private IotLockSecurityProperties properties;
@Autowired private LockDeviceMapper deviceMapper;
@Autowired private LockGatewayMapper gatewayMapper;
@Autowired private LockGroupMapper groupMapper;
@Autowired private LockAuthMapper authMapper;
@Autowired private LockCommandMapper commandMapper;
public RealIotSecurityGate() { }
public RealIotSecurityGate(IotMqttProperties properties) { this.properties = properties; }
public RealIotSecurityGate(IotLockSecurityProperties properties) { this.properties = properties; }
public boolean isMock() { return properties != null && properties.isMock(); }
private static final java.util.Set<String> REAL_MODE_CONTROL = new java.util.HashSet<>(java.util.Arrays.asList(
LockConstants.CMD_BIND, LockConstants.CMD_UNBIND, LockConstants.CMD_SYNC_TIME,
@@ -6,7 +6,9 @@ import com.ruoyi.iot.lock.domain.LockCommand;
/**
* 下行命令服务
* <p>
* 负责命令生成与落库、发布到MQTT、响应匹配、超时判定与重试补偿。
* 负责命令生成与落库、响应匹配、超时判定与重试补偿。
* <p>
* 说明:MQTT 传输层已移除,命令落库后不再向外下发,派发时统一置为失败(TRANSPORT_DISABLED)。
*
* @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);
}
@@ -6,7 +6,6 @@ import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.uuid.IdUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.task.TaskExecutor;
import org.springframework.stereotype.Service;
import com.ruoyi.iot.lock.LockConstants;
@@ -15,7 +14,6 @@ import com.ruoyi.iot.lock.mapper.*;
import com.ruoyi.iot.lock.protocol.*;
import com.ruoyi.iot.lock.security.*;
import com.ruoyi.iot.lock.service.*;
import com.ruoyi.iot.service.IMqttService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -27,14 +25,10 @@ public class LockCommandServiceImpl implements ILockCommandService {
/** 默认命令超时时间(秒) */
private static final int DEFAULT_TIMEOUT_SECONDS = 30;
@Autowired
private LockMqttLogMapper mqttLogMapper;
@Autowired private LockCommandMapper commandMapper;
@Autowired private LockAuthMapper authMapper;
@Autowired private LockExecutionTransactions transactions;
@Autowired private RealIotSecurityGate securityGate;
@Lazy @Autowired private IMqttService mqttService;
@Autowired @Qualifier("threadPoolTaskExecutor") private TaskExecutor taskExecutor;
private static final Logger log = LoggerFactory.getLogger(LockCommandServiceImpl.class);
@@ -64,8 +58,6 @@ public class LockCommandServiceImpl implements ILockCommandService {
command.setMaxRetry(command.getMaxRetry() == null ? DEFAULT_MAX_RETRY : command.getMaxRetry());
command.setReqTime(new Date());
commandMapper.insertCommand(command);
// 报文留痕(下行)
recordLog(command);
// Kingbase 下 useGeneratedKeys 不会回填自增主键,按唯一 intent_key 取回真实 command_id
LockCommand persisted = commandMapper.selectCommandByIntentKey(command.getIntentKey());
final Long dispatchedId = persisted != null ? persisted.getCommandId() : command.getCommandId();
@@ -90,29 +82,6 @@ public class LockCommandServiceImpl implements ILockCommandService {
return LockConstants.CMD_AUTH_PUSH.equals(command.getCommandType())
|| LockConstants.CMD_AUTH_CLEAR.equals(command.getCommandType());
}
/**
* 下行报文留痕
*/
private void recordLog(LockCommand command)
{
try
{
LockMqttLog logEntity = new LockMqttLog();
logEntity.setDirection(LockConstants.DIRECTION_DOWN);
logEntity.setTopic(command.getTopic());
logEntity.setPayload(command.getPayload());
logEntity.setSeq(command.getCommandNo());
logEntity.setRecvTime(command.getReqTime());
logEntity.setHandleStatus(StringUtils.equals(command.getCommandStatus(), LockConstants.STATUS_FAIL)
? LockConstants.HANDLE_FAIL
: LockConstants.HANDLE_SUCCESS);
mqttLogMapper.insertMqttLog(logEntity);
}
catch (Exception e)
{
log.warn("下行报文留痕失败, commandNo={}", command.getCommandNo());
}
}
// Always acquire the authorization lock before the command lock.
private LockAuth lockAuth(LockCommand command) {
return authCommand(command) && command.getBizId() != null
@@ -166,47 +135,19 @@ public class LockCommandServiceImpl implements ILockCommandService {
command.setErrorMsg("POLICY_BLOCKED_NOT_ATTEMPTED"); commandMapper.updateCommand(command);
return null;
}
if (!mqttService.isConnected()) {
log.warn("[TRACE-DISPATCH] cid={} isConnected=false -> TRANSPORT_DISCONNECTED", commandId);
command.setCommandStatus(LockConstants.STATUS_FAIL);
command.setErrorMsg("TRANSPORT_DISCONNECTED_NOT_ATTEMPTED");
commandMapper.updateCommand(command); return null;
}
command.setAttemptStartedAt(new Date());
transition(command, ExecutionStates.DISPATCHING, LockConstants.STATUS_PENDING, "DISPATCH_CLAIM_COMMITTED");
project(command, ExecutionStates.DISPATCHING, LockConstants.SYNC_PENDING, "DISPATCH_CLAIM_COMMITTED");
return command;
// MQTT 传输层已移除:命令不再下发到真实设备,直接置为失败,避免状态悬挂
log.warn("[TRACE-DISPATCH] cid={} MQTT transport removed -> TRANSPORT_DISABLED", commandId);
command.setCommandStatus(LockConstants.STATUS_FAIL);
command.setErrorMsg("TRANSPORT_DISABLED");
commandMapper.updateCommand(command);
return null;
});
if (claimed == null) return false;
try {
mqttService.publishCommand(claimed);
transactions.committed(() -> {
lockAuth(claimed);
LockCommand current = commandMapper.selectCommandByIdForUpdate(commandId);
if (ExecutionStates.DISPATCHING.equals(current.getExecutionState())) {
transition(current, ExecutionStates.AWAITING_ACK, LockConstants.STATUS_SENT, "AWAITING_DEVICE_CONFIRMATION");
project(current, ExecutionStates.AWAITING_ACK, LockConstants.SYNC_SENT, "AWAITING_DEVICE_CONFIRMATION");
}
return null;
});
} catch (RuntimeException uncertain) {
transactions.committed(() -> {
lockAuth(claimed);
LockCommand current = commandMapper.selectCommandByIdForUpdate(commandId);
if (ExecutionStates.DISPATCHING.equals(current.getExecutionState())) {
transition(current, ExecutionStates.MANUAL_REVIEW, LockConstants.STATUS_TIMEOUT, "TRANSPORT_OUTCOME_UNKNOWN_NO_AUTOREPLAY");
project(current, ExecutionStates.MANUAL_REVIEW, LockConstants.SYNC_FAIL, "TRANSPORT_OUTCOME_UNKNOWN_NO_AUTOREPLAY");
}
return null;
});
}
return true;
return false;
}
@Override public int retryPending() {
int sent = 0;
for (LockCommand command : commandMapper.selectReadyCommands()) if (dispatch(command.getCommandId())) sent++;
return sent;
// MQTT 传输层已移除,不再存在可补偿下发的命令
return 0;
}
@Override public void retry(Long commandId) {
LockCommand command = commandMapper.selectCommandById(commandId);
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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') &gt;= to_char(to_date(#{params.beginTime},'yyyy-mm-dd'),'yyyymmdd')</when>
<otherwise>and date_format(recv_time,'%Y%m%d') &gt;= 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') &lt;= to_char(to_date(#{params.endTime},'yyyy-mm-dd'),'yyyymmdd')</when>
<otherwise>and date_format(recv_time,'%Y%m%d') &lt;= 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 &lt; to_date(#{keepDate},'yyyy-mm-dd')</when>
<otherwise>recv_time &lt; #{keepDate}</otherwise>
</choose>
</delete>
</mapper>
@@ -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);
}
}
@@ -65,7 +65,7 @@ public class FaceExtractClientMockTest
props.setMock(false); props.setEnabled(true); props.setRealHttpEnabled(true);
props.setEnvironmentId("isolated-test-real");
props.setAllowedEndpoints(java.util.Arrays.asList(props.getEndpoint()));
com.ruoyi.iot.config.IotMqttProperties mqtt = new com.ruoyi.iot.config.IotMqttProperties();
com.ruoyi.iot.config.IotLockSecurityProperties mqtt = new com.ruoyi.iot.config.IotLockSecurityProperties();
mqtt.setEnabled(true); mqtt.setRealDeviceWriteEnabled(true); mqtt.setEnvironmentId(props.getEnvironmentId());
mqtt.setDeviceWhitelist(java.util.Arrays.asList("TEST0001"));
Field gateField = FaceExtractClient.class.getDeclaredField("securityGate"); gateField.setAccessible(true);
@@ -2,20 +2,19 @@ package com.ruoyi.iot.lock.security;
import static org.junit.Assert.*;
import java.util.*;
import org.junit.Test;
import com.ruoyi.iot.config.IotMqttProperties;
import com.ruoyi.iot.core.MqttClientManager;
import com.ruoyi.iot.config.IotLockSecurityProperties;
import com.ruoyi.iot.lock.domain.LockAuth;
public class RealIotSecurityGateTest {
@Test public void defaultsAreClosed() {
IotMqttProperties p=new IotMqttProperties();
IotLockSecurityProperties p=new IotLockSecurityProperties();
RealIotSecurityGate g=new RealIotSecurityGate(p);
assertFalse(p.isEnabled());assertFalse(p.isRealDeviceWriteEnabled());assertFalse(p.isRetryTaskEnabled());
assertFalse(p.isEnabled());assertFalse(p.isRealDeviceWriteEnabled());
assertTrue(p.getDeviceWhitelist().isEmpty());assertFalse(g.checkDeviceWriteAllowed("TEST0001","0011"));
assertFalse(g.sameOrigin(new LockAuth()));
}
@Test public void explicitIdentityOnly() {
IotMqttProperties p=new IotMqttProperties();p.setEnabled(true);p.setRealDeviceWriteEnabled(true);
IotLockSecurityProperties p=new IotLockSecurityProperties();p.setEnabled(true);p.setRealDeviceWriteEnabled(true);
RealIotSecurityGate g=new RealIotSecurityGate(p);
assertFalse(g.checkDeviceWriteAllowed("TEST0001","0011"));
p.setDeviceWhitelist(Arrays.asList("TEST0001"));
@@ -24,17 +23,13 @@ public class RealIotSecurityGateTest {
assertFalse(g.checkDeviceWriteAllowed("UNKNOWN","0011"));
}
@Test public void provenanceIsStableAcrossGateRestartButNotAcrossEnvironments() {
IotMqttProperties p=new IotMqttProperties();p.setMock(true);p.setEnvironmentId("test-mock");
IotLockSecurityProperties p=new IotLockSecurityProperties();p.setMock(true);p.setEnvironmentId("test-mock");
RealIotSecurityGate first=new RealIotSecurityGate(p);LockAuth a=new LockAuth();first.initializeIntent(a,ExecutionStates.REVOKE);
a.setIntentAt(new Date(0));assertTrue(new RealIotSecurityGate(p).sameOrigin(a));
p.setMock(false);p.setEnvironmentId("test-real");assertFalse(new RealIotSecurityGate(p).sameOrigin(a));
}
@Test public void personNumberIsStrict() {
RealIotSecurityGate g=new RealIotSecurityGate(new IotMqttProperties());
RealIotSecurityGate g=new RealIotSecurityGate(new IotLockSecurityProperties());
assertTrue(g.isValidPersonNo("ZZ000001"));assertFalse(g.isValidPersonNo("ZZ1"));assertFalse(g.isValidPersonNo("ZZ000001 "));
}
@Test(expected=SecurityException.class) public void rawRealPublicationAlwaysDenied() {
IotMqttProperties p=new IotMqttProperties();p.setRealDeviceWriteEnabled(true);
new MqttClientManager(p,null).publish("ydlink/TEST/thing/data/batch","{}",1,false);
}
}
@@ -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"));
}
}
@@ -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 {}
}
@@ -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");
}
}
}
@@ -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");
}
}
@@ -22,7 +22,6 @@ import com.ruoyi.iot.lock.domain.LockFace;
import com.ruoyi.iot.lock.mapper.LockAuthMapper;
import com.ruoyi.iot.lock.mapper.LockCommandMapper;
import com.ruoyi.iot.lock.mapper.LockDeviceMapper;
import com.ruoyi.iot.lock.mqtt.handler.CommandRespHandler;
import com.ruoyi.iot.lock.protocol.YdlinkFrameDTO;
import com.ruoyi.iot.lock.protocol.YdlinkOp;
import com.ruoyi.iot.lock.protocol.YdlinkTopicDTO;
@@ -121,7 +120,7 @@ public class TrainIotLockAuthBridgeMockTest
public void rollback(org.springframework.transaction.TransactionStatus s) { }
});
setField(bridgeService, "executionTransactions", transactions);
com.ruoyi.iot.config.IotMqttProperties executionProps = new com.ruoyi.iot.config.IotMqttProperties();
com.ruoyi.iot.config.IotLockSecurityProperties executionProps = new com.ruoyi.iot.config.IotLockSecurityProperties();
executionProps.setMock(true); executionProps.setEnvironmentId("bridge-fixture-mock");
com.ruoyi.iot.lock.security.RealIotSecurityGate executionGate = new com.ruoyi.iot.lock.security.RealIotSecurityGate(executionProps);
setField(executionGate, "deviceMapper", createMockDeviceMapper());
-18
View File
@@ -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'
})
}
-183
View File
@@ -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>
+1 -1
View File
@@ -12,7 +12,7 @@ async (page) => {
['/index', null], ['/person/student', 'Student'], ['/person/teacher', null],
['/coursemgr/plan', null], ['/equip/dormitory', null], ['/equip/accommodation', 'Accommodation'],
...Object.entries({group:'Group',gateway:'Gateway',device:'Device',face:'Face',auth:'Auth',
accesslog:'AccessLog',command:'Command',mqttlog:'MqttLog',config:'ServiceConfig'}).map(([r,c])=>['/iot/'+r,c])
accesslog:'AccessLog',command:'Command',config:'ServiceConfig'}).map(([r,c])=>['/iot/'+r,c])
];
const results=[];
for (const [path,expected] of routes) {
+4 -4
View File
@@ -172,10 +172,10 @@ def start(config, env, backend_port, redis_port):
'--spring.data.redis.host=127.0.0.1', '--spring.data.redis.port=' + str(redis_port),
'--spring.data.redis.database=0', '--spring.quartz.auto-startup=false',
'--ruoyi.profile=' + str(RUNTIME / 'upload'), '--logging.file.path=' + str(RUNTIME / 'logs'),
'--logging.level.com.ruoyi=INFO', '--iot.mqtt.mock=true', '--iot.mqtt.enabled=false',
'--iot.mqtt.real-device-write-enabled=false', '--iot.mqtt.device-whitelist=',
'--iot.mqtt.environment-id=round5v-local-mock', '--iot.mqtt.retry-task-enabled=false',
'--iot.mqtt.protocol-verified=false', '--iot.qa-api-enabled=false', '--iot.openapi.enabled=false',
'--logging.level.com.ruoyi=INFO', '--iot.lock-security.mock=true', '--iot.lock-security.enabled=false',
'--iot.lock-security.real-device-write-enabled=false', '--iot.lock-security.device-whitelist=',
'--iot.lock-security.environment-id=round5v-local-mock', '--iot.lock-security.protocol-verified=false',
'--iot.qa-api-enabled=false', '--iot.openapi.enabled=false',
'--iot.face.mock=true', '--iot.face.enabled=false', '--iot.face.real-http-enabled=false',
'--iot.face.allowed-endpoints=', '--dms.enabled=false', '--dms.mock=true',
'--spring.datasource.druid.stat-view-servlet.enabled=false']
+5 -4
View File
@@ -39,10 +39,10 @@ class QaToolsTest(unittest.TestCase):
self.assertEqual('jdbc:kingbase8://192.168.139.218:4321/' + qa.DATABASE + '?characterEncoding=utf8', qa.druid_url(url))
def test_unsafe_inherited_overrides_removed(self):
with patch.dict(os.environ, {'SPRING_APPLICATION_JSON': '{"danger":true}', 'IOT_MQTT_ENABLED': 'true', 'JAVA_TOOL_OPTIONS': '-Dunsafe=true'}):
with patch.dict(os.environ, {'SPRING_APPLICATION_JSON': '{"danger":true}', 'IOT_LOCK_SECURITY_ENABLED': 'true', 'JAVA_TOOL_OPTIONS': '-Dunsafe=true'}):
env = qa.environment({'FUSION_QA_APP_PASSWORD': 'synthetic'})
self.assertNotIn('SPRING_APPLICATION_JSON', env)
self.assertNotIn('IOT_MQTT_ENABLED', env)
self.assertNotIn('IOT_LOCK_SECURITY_ENABLED', env)
self.assertNotIn('JAVA_TOOL_OPTIONS', env)
def test_private_config_required(self):
@@ -78,8 +78,9 @@ class QaToolsTest(unittest.TestCase):
self.assertEqual(2,launch.call_count)
name, command, env = launch.call_args.args
self.assertEqual('backend',name)
for flag in ['--iot.mqtt.enabled=false','--iot.mqtt.mock=true', '--iot.mqtt.real-device-write-enabled=false',
'--iot.mqtt.device-whitelist=', '--iot.mqtt.retry-task-enabled=false', '--iot.qa-api-enabled=false',
for flag in ['--iot.lock-security.enabled=false','--iot.lock-security.mock=true',
'--iot.lock-security.real-device-write-enabled=false','--iot.lock-security.device-whitelist=',
'--iot.lock-security.protocol-verified=false', '--iot.qa-api-enabled=false',
'--iot.openapi.enabled=false','--iot.face.real-http-enabled=false','--dms.enabled=false',
'--spring.quartz.auto-startup=false','--spring.datasource.druid.master.username=fusion_qa_app']:
self.assertIn(flag,command)