mqtt对接嵌入式

This commit is contained in:
yuhaiming
2026-06-11 10:07:03 +08:00
parent 1ab8c28c36
commit c7f9df980a
56 changed files with 2698 additions and 905 deletions

36
.env.example Normal file
View File

@@ -0,0 +1,36 @@
# ==========================================
# Water 项目环境变量模板
# 复制此文件为 .env 并填入真实值
# .env 文件已加入 .gitignore不会提交到仓库
# ==========================================
# --- 数据库 ---
DB_USERNAME=root
DB_PASSWORD=123456
# --- Redis ---
REDIS_PASSWORD=
# --- Sa-Token JWT ---
# 生产环境建议使用 openssl rand -base64 64 生成
SA_TOKEN_JWT_SECRET_KEY=abcdefghijklmnopqrstuvwxyz
# --- MQTT Broker ---
MQTT_ENABLED=true
MQTT_BROKER_URL=ssl://service.reinkun.com:8883
MQTT_USERNAME=admin
MQTT_PASSWORD=yukun_mqtt
# --- 邮件 (SMTP) ---
MAIL_PASSWORD=XZVDXjRdVrjiX8N3
# --- 短信 (阿里云 SMS) ---
SMS_ACCESS_KEY_ID=LTAI5t7xHqxFKG9DwMgurvrn
SMS_ACCESS_KEY_SECRET=nNFjR8YwNJUcMrrtdc4pkpPsHmYtfp
# --- SnailJob 调度中心 ---
SNAIL_JOB_TOKEN=SJ_cKqBTPzCsWA3VyuCfFoccmuIEGXjr5KT
# --- 第三方登录 (JustAuth) ---
# Gitee / QQ / 微信 等 OAuth 凭据请通过 application-*.yml 配置
# 或使用 JUSTAUTH_ 前缀的环境变量覆盖

18
.gitignore vendored
View File

@@ -2,3 +2,21 @@
/water-common/*/target/
/water-modules/*/target/
/logs/
/water-extend/*/target/
# IDE
.idea/
*.iml
.vscode/
*.swp
*.swo
*~
# Environment files (contain secrets)
.env
.env.local
.env.*.local
# OS
.DS_Store
Thumbs.db

22
.idea/compiler.xml generated
View File

@@ -3,6 +3,11 @@
<component name="CompilerConfiguration">
<annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
</profile>
<profile name="Annotation profile for water-Vue-Plus" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
@@ -20,6 +25,18 @@
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/tools/gem/gem-api/1.0.0.Alpha3/gem-api-1.0.0.Alpha3.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/com/github/therapi/therapi-runtime-javadoc-scribe/0.15.0/therapi-runtime-javadoc-scribe-0.15.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/com/github/therapi/therapi-runtime-javadoc/0.15.0/therapi-runtime-javadoc-0.15.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/springframework/boot/spring-boot-configuration-processor/3.5.12/spring-boot-configuration-processor-3.5.12.jar" />
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus-processor/1.5.0/mapstruct-plus-processor-1.5.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus/1.5.0/mapstruct-plus-1.5.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct/1.6.3/mapstruct-1.6.3.jar" />
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus-object-convert/1.5.0/mapstruct-plus-object-convert-1.5.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/cn/easii/tutelary-repackage-javapoet/1.0.5/tutelary-repackage-javapoet-1.0.5.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/tools/gem/gem-api/1.0.0.Alpha3/gem-api-1.0.0.Alpha3.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar" />
</processorPath>
<module name="water-app" />
<module name="water-common-excel" />
@@ -55,11 +72,6 @@
<module name="water-demo" />
<module name="water-workflow" />
</profile>
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
</profile>
</annotationProcessing>
</component>
<component name="JavacSettings">

4
.idea/misc.xml generated
View File

@@ -10,7 +10,5 @@
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK" />
</project>

View File

@@ -0,0 +1,45 @@
# 2026-06-08 工作日志
## 代码质量审计 & 安全修复
### 已完成的 P0/P1 修复
1. **移除硬编码敏感凭据**P0 安全红线)
- `application-dev.yml`:邮件密码、阿里云短信 AK/SK、SnailJob Token、MySQL 密码 → 改为 `${ENV_VAR:}` 占位符
- `application.yml`MQTT Broker 密码、JWT 密钥 → 改为环境变量引用
- `application-prod.yml`SnailJob Token → 环境变量引用
2. **Git 仓库清理**
- 更新 `.gitignore`:添加 `.idea/``*.iml``.vscode/``.env`、OS 临时文件
- 创建 `.env.example` 模板文件,供团队参考配置
3. **修复 N+1 查询**AppDeviceServiceImpl.deleteWithValidByIds
- 将 for 循环逐条 delete 改为 `QueryWrapper.in("device_id", ids)` 批量删除
4. **修复 AppSchedule 重复字段**
- 移除与 BaseEntity 冲突的 `createTime` 字段定义(父类已有 @TableField fill=INSERT
5. **DromaraApplication 日志规范化**
- `System.out.println``@Slf4j` + `log.info()`
6. **DeviceDataHandler 代码清理**
- 被注释的死代码改为结构化 TODO 注释,明确后续实现路径
7. **application.yml 格式修复**
- 移除重复的 `--- # mqtt配置信息`
8. **修复 Spring 循环依赖**MqttCommandAckService
- 循环链MqttClientManager → MqttMessageDispatcher → MqttCommandAckService → MqttClientManager
- 方案MqttCommandAckService 中对 MqttClientManager 加 @Lazy,因为 publish() 仅在 retryExpiredCommands() 运行时调用,非构造阶段必须
### 待处理
- [ ] P1: 补齐 app_device 等 5 张表的 DDL 脚本task #3
- [ ] P1: 搭建单元测试框架task #6
- [ ] P2: 编写团队编码规范文档
- [ ] 确认线上环境已同步更新凭据配置
### 技术备忘
- 环境变量默认值写法:`${VAR_NAME:defaultValue}` — dev 环境保留弱默认值方便本地开发prod 必须强制覆盖
- 环境变量模板 `.env.example` 已纳入 `.gitignore` 保护

View File

@@ -0,0 +1,73 @@
# 2026-06-10 工作日志
## MQTT 分发重构 & 设备下发业务层
### 9. MqttMessageDispatcher 策略模式重构
**问题**Dispatcher 硬编码 7 个 Regex Pattern 的 if-else 链,新增 Topic 必须改 Dispatcher 类。
**方案**
- 创建 `MqttTopicHandler` 接口(`topicPattern()` + `handle(deviceNo, payload)`
- 每个 Handler 实现该接口并声明自己的 Topic 正则
- `MqttMessageDispatcher` 改为注入 `List<MqttTopicHandler>`,启动时自动收集,循环匹配
- 新增 Topic只需新增 Handler BeanDispatcher 零改动
**修复的 Bug**
- `DeviceCommandAckHandler` 原来是空壳(只打日志),现在委托到 `IDeviceCommandAckHandler``MqttCommandAckService`
- `DeviceStatusHandler` 原来存在但 Dispatcher 从未路由到它(缺少 PATTERN现已补全
- 新增 `ScheduleFinishHandler``KeyFinishHandler``DeviceCommandReceiptHandler` 替代原来只打日志的空处理
**文件变更**
| 文件 | 变更 |
|---|---|
| `app/mqtt/MqttTopicHandler.java` | **新增** 策略接口 |
| `app/mqtt/MqttMessageDispatcher.java` | **重写** 策略模式 |
| `app/handler/DeviceDataHandler.java` | 实现 MqttTopicHandler |
| `app/handler/DeviceRegisterHandler.java` | 实现 MqttTopicHandler |
| `app/handler/DeviceStatusHandler.java` | 实现 MqttTopicHandler新增 /status Topic |
| `app/handler/ErromesHandler.java` | 实现 MqttTopicHandler |
| `app/handler/DeviceCommandAckHandler.java` | 实现 MqttTopicHandler + 委托 IDeviceCommandAckHandler |
| `app/handler/ScheduleFinishHandler.java` | **新增** |
| `app/handler/KeyFinishHandler.java` | **新增** |
| `app/handler/DeviceCommandReceiptHandler.java` | **新增** |
### 10. 新增设备下发业务层 DeviceCommandService
**问题**`AppDeviceServiceImpl.switchDevice()` 直接拼装 `DeviceCommand` 并调用底层 `IDeviceCommandPublisher`,缺少统一的下发入口。
**方案**
- 创建 `IDeviceCommandService` 接口
- `DeviceCommandServiceImpl` 封装命令构造、设备校验、下发生效、浇水日志
- 提供类型化方法:`sendSwitchCommand()``sendCustomCommand()``sendCommand()`
- `AppDeviceServiceImpl.switchDevice()` 改为调用 `deviceCommandService.sendSwitchCommand()`
**文件变更**
| 文件 | 变更 |
|---|---|
| `app/service/IDeviceCommandService.java` | **新增** 接口 |
| `app/service/impl/DeviceCommandServiceImpl.java` | **新增** 实现 |
| `app/service/impl/AppDeviceServiceImpl.java` | 重构 switchDevice注入 IDeviceCommandService移除废弃的私有方法 |
### 11. 修复循环依赖根因
`MqttCommandAckService.mqttClientManager` 字段保留 `final` 导致 Lombok `@RequiredArgsConstructor` 仍将其放入构造器,`@Lazy` 注解在字段上不生效。已去掉 `final`,确保字段注入 + @Lazy 代理正确工作。
### 12. 删除冗余 /publish/status Topic
设备上传电量数据本身就证明在线,单独的 `/publish/status` Topic 没必要。删除 `DeviceStatusHandler``AppMqttDeviceStatusProperties`。策略模式下 Dispatcher 自动感知,零改动。
### 13. 修复两处循环依赖(构造器注入导致)
**大循环**6个Bean
```
DeviceMqttCommandPublisher → MqttClientManager → MqttMessageDispatcher
→ DeviceRegisterHandler → AppDeviceServiceImpl → DeviceCommandServiceImpl
→ DeviceMqttCommandPublisher闭合
```
修复:`DeviceCommandServiceImpl.commandPublisher` 去掉 `final`,改为 `@Lazy @Autowired` 字段注入。
**双向循环**2个Bean
```
AppDeviceServiceImpl ↔ DeviceCommandServiceImpl
```
修复:`AppDeviceServiceImpl.deviceCommandService` 去掉 `final`,改为 `@Lazy @Autowired` 字段注入。

View File

@@ -0,0 +1,306 @@
# MQTT 与嵌入式通信协议
本文档说明后端服务与嵌入式设备之间的 MQTT Topic、通信方向、Payload 类型和 JSON 数据格式。
当前配置位置:
- 后端订阅配置:`water-admin/src/main/resources/application.yml`
- 后端上行消息分发:`water-modules/water-app/src/main/java/org/dromara/app/mqtt`
- 后端命令下发:`water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/DeviceMqttCommandPublisher.java`
## 基础约定
| 项目 | 说明 |
| --- | --- |
| Broker | `mqtt.broker-url` |
| QoS | `mqtt.qos`,当前默认 `1` |
| 设备编号 | Topic 第一段 `{deviceNo}` |
| Topic 变量 | 文档中的 `{deviceNo}` 替换为真实设备编号,例如 `/01/publish/register` |
| JSON 编码 | UTF-8 |
| 时间格式 | `yyyy-MM-dd HH:mm:ss` |
后端会订阅以下 Topic
```yaml
- /+/publish/finish/schedule
- /+/publish/register
- /+/publish/power
- /+/publish/ack
- /+/publish/finish/key
- /+/publish/error
- /+/subscriber/cmd
```
## Topic 总览
| 功能名称 | 通信方向 | 订阅名称 / Topic | Payload 类型 | 后端处理 |
| --- | --- | --- | --- | --- |
| 排程任务完成上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/finish/schedule` | JSON | 当前记录日志 |
| 设备注册 | 设备发布,后端订阅 | `/{deviceNo}/publish/register` | JSON | 注册或更新设备 |
| 电量上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/power` | JSON | 更新设备电量 |
| 命令应答 ACK | 设备发布,后端订阅 | `/{deviceNo}/publish/ack` | JSON 或纯文本 | 清理待确认命令 |
| 按键浇水完成上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/finish/key` | JSON | 当前记录日志 |
| 硬件故障上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/error` | JSON | 当前记录日志 |
| 下发命令 | 后端发布,设备订阅 | `/{deviceNo}/subscriber/cmd` | JSON | 设备执行命令 |
## 设备注册
| 项目 | 内容 |
| --- | --- |
| 功能名称 | 设备注册 |
| 通信方向 | 设备发布,后端订阅 |
| Topic | `/{deviceNo}/publish/register` |
| Payload 类型 | JSON |
后端以 Topic 中的 `{deviceNo}` 作为设备编号payload 中的 `deviceNo` 即使传入也会被 Topic 覆盖。
示例:
```json
{
"deviceName": "一号浇水设备",
"status": "1",
"workStatus": "0",
"powerLevel": "86",
"wifiName": "office-wifi",
"deviceEm": "WATER-EM-01",
"deviceSn": "SN202606100001",
"fwVer": "1.0.0",
"macAddress": "AA:BB:CC:DD:EE:FF",
"bindToken": "123456"
}
```
字段说明:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| deviceName | string | 否 | 设备名称 |
| status | string | 否 | 设备状态:`1` 在线,`0` 离线,`2` 到期,`3` 故障;为空时后端默认 `2` |
| workStatus | string | 否 | 工作状态:`0` 休息,`1` 工作 |
| powerLevel | string | 否 | 电量 |
| wifiName | string | 否 | WiFi 名称 |
| deviceEm | string | 否 | 设备型号 |
| deviceSn | string | 否 | 设备序列号 |
| fwVer | string | 否 | 固件版本 |
| macAddress | string | 否 | MAC 地址 |
| bindToken | string | 否 | 设备绑定令牌 |
## 电量上报
| 项目 | 内容 |
| --- | --- |
| 功能名称 | 电量上报 |
| 通信方向 | 设备发布,后端订阅 |
| Topic | `/{deviceNo}/publish/power` |
| Payload 类型 | JSON |
示例:
```json
{
"powerLevel": "86"
}
```
字段说明:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| powerLevel | string | 是 | 电量值,后端当前按字符串保存 |
## 命令应答 ACK
| 项目 | 内容 |
| --- | --- |
| 功能名称 | 命令应答 |
| 通信方向 | 设备发布,后端订阅 |
| Topic | `/{deviceNo}/publish/ack` |
| Payload 类型 | JSON兼容纯文本 |
推荐 JSON
```json
{
"commandId": "9f4f1f2e8c6a4e1f8b7f3a1d2c0b9e11",
"status": "1",
"message": "receive"
}
```
字段说明:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| commandId | string | 是 | 后端下发命令时携带的命令 ID用于清理待 ACK 命令 |
| status | string | 否 | 执行状态,建议 `1` 成功,`0` 失败 |
| message | string | 否 | 设备返回消息 |
兼容格式:
```text
receive
```
说明:如果设备只返回纯文本,例如 `receive`,后端会查找该设备当前待确认命令。若只有一条 pending 命令,则按该命令完成 ACK若没有或有多条 pending 命令,只记录日志,不清理命令。嵌入式侧建议优先使用 JSON ACK并回传 `commandId`
## 下发命令
| 项目 | 内容 |
| --- | --- |
| 功能名称 | 下发命令 |
| 通信方向 | 后端发布,设备订阅 |
| Topic | `/{deviceNo}/subscriber/cmd` |
| Payload 类型 | JSON |
后端下发手动开关设备命令示例:
```json
{
"deviceNo": "01",
"workStatus": "1",
"startTime": "2026-06-10 14:35:31",
"durationMin": 20,
"commandId": "9f4f1f2e8c6a4e1f8b7f3a1d2c0b9e11",
"commandType": "switchDevice"
}
```
字段说明:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| deviceNo | string | 是 | 设备编号 |
| workStatus | string | 是 | `0` 关闭浇水,`1` 开始浇水 |
| startTime | string | 否 | 开始时间,格式 `yyyy-MM-dd HH:mm:ss` |
| durationMin | number | 是 | 持续时间,单位分钟,必须大于 0 |
| commandId | string | 是 | 后端自动生成,设备 ACK 必须原样返回 |
| commandType | string | 是 | 命令类型,手动开关设备为 `switchDevice` |
设备收到命令后,应发布 ACK 到:
```text
/{deviceNo}/publish/ack
```
推荐 ACK
```json
{
"commandId": "9f4f1f2e8c6a4e1f8b7f3a1d2c0b9e11",
"status": "1",
"message": "receive"
}
```
## 排程任务完成上报
| 项目 | 内容 |
| --- | --- |
| 功能名称 | 排程任务完成上报 |
| 通信方向 | 设备发布,后端订阅 |
| Topic | `/{deviceNo}/publish/finish/schedule` |
| Payload 类型 | JSON |
| 当前后端行为 | 写入浇水记录 |
JSON 示例:
```json
{
"deviceNo": "dcda0cfa289c",
"startWeek": 5,
"startTime": "2026-06-05 14:37",
"durationMin": 3,
"triggerType": "mqtt on"
}
```
字段说明:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| deviceNo | string | 否 | 设备编号;后端以 Topic 中的 `{deviceNo}` 为准,不一致时记录 warn |
| startWeek | number | 否 | 星期几,`1``7`;当前后端不入库 |
| startTime | string | 是 | 开始时间,支持 `yyyy-MM-dd HH:mm:ss``yyyy-MM-dd HH:mm` |
| endTime | string | 否 | 结束时间,支持 `yyyy-MM-dd HH:mm:ss``yyyy-MM-dd HH:mm` |
| durationMin | number | 否 | 预计持续时间,单位分钟;大于 `0` 时后端按 `startTime + durationMin` 计算预计结束时间 |
| triggerType | string | 否 | 设备触发来源;当前后端忽略该字段,排程完成记录固定入库为 `0` |
| scheduleId | number | 否 | 后端忽略 payload 中的该字段,按 `AppSchedulingDevice.deviceNo` 查询排程 ID查不到时写入 `0` |
后端先计算预计结束时间:`durationMin > 0` 时使用 `startTime + durationMin`,否则使用 payload 中的 `endTime`。实际入库的 `endTime` 取“消息接收时间”和“预计结束时间”中较早的时间,`durationMin``startTime` 到实际入库 `endTime` 重新计算。
## 按键浇水完成上报
| 项目 | 内容 |
| --- | --- |
| 功能名称 | 按键浇水完成上报 |
| 通信方向 | 设备发布,后端订阅 |
| Topic | `/{deviceNo}/publish/finish/key` |
| Payload 类型 | JSON |
| 当前后端行为 | 写入浇水记录 |
JSON 示例:
```json
{
"deviceNo": "dcda0cfa289c",
"startWeek": 5,
"startTime": "2026-06-05 17:43",
"durationMin": 0,
"triggerType": "key"
}
```
字段说明:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| deviceNo | string | 否 | 设备编号;后端以 Topic 中的 `{deviceNo}` 为准,不一致时记录 warn |
| startWeek | number | 否 | 星期几,`1``7`;当前后端不入库 |
| startTime | string | 是 | 开始时间,支持 `yyyy-MM-dd HH:mm:ss``yyyy-MM-dd HH:mm` |
| endTime | string | 否 | 结束时间,支持 `yyyy-MM-dd HH:mm:ss``yyyy-MM-dd HH:mm` |
| durationMin | number | 否 | 预计持续时间,单位分钟;大于 `0` 时后端按 `startTime + durationMin` 计算预计结束时间 |
| triggerType | string | 否 | 设备触发来源;当前后端忽略该字段,按键完成记录固定入库为 `1` |
后端先计算预计结束时间:`durationMin > 0` 时使用 `startTime + durationMin`,否则使用 payload 中的 `endTime`。实际入库的 `endTime` 取“消息接收时间”和“预计结束时间”中较早的时间,`durationMin``startTime` 到实际入库 `endTime` 重新计算。按键完成记录的 `scheduleId` 固定为 `0`
## 硬件故障上报
| 项目 | 内容 |
| --- | --- |
| 功能名称 | 硬件故障上报 |
| 通信方向 | 设备发布,后端订阅 |
| Topic | `/{deviceNo}/publish/error` |
| Payload 类型 | JSON |
| 当前后端行为 | 记录日志,暂未入库 |
建议 JSON
```json
{
"errorCode": "E001",
"errorType": "pump",
"message": "pump blocked",
"level": "error",
"time": "2026-06-10 10:00:00"
}
```
字段说明:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| errorCode | string | 建议 | 故障编码 |
| errorType | string | 否 | 故障类型 |
| message | string | 建议 | 故障描述 |
| level | string | 否 | 故障级别,例如 `warn``error` |
| time | string | 否 | 故障发生时间 |
## 嵌入式侧实现要点
1. 设备启动后发布注册消息到 `/{deviceNo}/publish/register`
2. 设备定时或电量变化时发布电量到 `/{deviceNo}/publish/power`
3. 设备订阅自己的命令 Topic`/{deviceNo}/subscriber/cmd`
4. 设备收到命令后立即返回 ACK 到 `/{deviceNo}/publish/ack`,推荐返回 JSON 并携带 `commandId`
5. 排程或按键浇水完成后,分别发布到 `/{deviceNo}/publish/finish/schedule``/{deviceNo}/publish/finish/key`
6. 硬件异常时发布到 `/{deviceNo}/publish/error`

View File

@@ -0,0 +1,199 @@
# Water 项目架构图
本文档按当前项目代码结构整理重点包含后端模块、智能灌溉业务、MQTT 设备通信、ACK 应答与重发链路。
## 整体模块
```mermaid
flowchart TB
APP["移动端 / 管理端 App"]
DEVICE["灌溉设备"]
BROKER["MQTT Broker<br/>TLS: ssl://service.reinkun.com:8883"]
subgraph BACKEND["water 后端服务"]
ADMIN["water-admin<br/>Spring Boot 启动入口"]
APPMOD["water-modules/water-app<br/>设备、排程、浇水记录、MQTT业务分发"]
SYSTEM["water-modules/water-system<br/>用户、角色、租户、权限"]
COMMON["water-common<br/>通用能力"]
MQTT["water-common-mqtt<br/>MQTT连接、TLS、订阅、发布、ACK重发"]
REDISCOMMON["water-common-redis<br/>Redis 工具与 Redisson"]
DBACCESS["water-common-mybatis<br/>MyBatis Plus / 数据权限"]
end
MYSQL[("MySQL<br/>业务数据")]
REDIS[("Redis<br/>缓存、设备状态、待ACK命令")]
APP -->|"HTTP REST"| ADMIN
ADMIN --> APPMOD
ADMIN --> SYSTEM
ADMIN --> COMMON
APPMOD --> DBACCESS
SYSTEM --> DBACCESS
DBACCESS --> MYSQL
APPMOD --> REDISCOMMON
MQTT --> REDISCOMMON
REDISCOMMON --> REDIS
MQTT <-->|"MQTT over TLS"| BROKER
DEVICE <-->|"MQTT over TLS"| BROKER
MQTT --> APPMOD
```
## 后端分层
```mermaid
flowchart LR
HTTP["HTTP 请求<br/>/app/v1/**"]
CONTROLLER["AppController<br/>设备、排程、日志、用户接口"]
SERVICE["Service 层<br/>IAppDeviceService / IAppScheduleService / IAppWateringLogService"]
MAPPER["Mapper 层<br/>MyBatis Plus Mapper"]
DB[("MySQL")]
HTTP --> CONTROLLER
CONTROLLER --> SERVICE
SERVICE --> MAPPER
MAPPER --> DB
CONTROLLER --> CMDPUB["IDeviceCommandPublisher<br/>设备命令发布接口"]
CMDPUB --> MQTTIMPL["DeviceMqttCommandPublisher<br/>MQTT命令实现"]
```
## MQTT 高并发消费链路
```mermaid
flowchart TB
DEVICE1["设备 A"]
DEVICE2["设备 B"]
DEVICEN["设备 N"]
BROKER["MQTT Broker"]
CLIENT["MqttAsyncClient<br/>单个后端订阅客户端"]
QUEUE["有界内存队列<br/>queue-capacity: 20000"]
CONSUMERS["mqtt-consumer-*<br/>consumer-count: 16<br/>batch-size: 100"]
DISPATCHER["MqttMessageDispatcher<br/>topic 路由"]
DATA["DeviceDataHandler<br/>/water/{deviceNo}/data"]
STATUS["DeviceStatusHandler<br/>/water/{deviceNo}/status"]
ERROR["ErromesHandler<br/>/water/{deviceNo}/erromes"]
ACK["IDeviceCommandAckHandler<br/>/water/{deviceNo}/ack"]
DEVICE1 --> BROKER
DEVICE2 --> BROKER
DEVICEN --> BROKER
BROKER --> CLIENT
CLIENT -->|"快速入队"| QUEUE
QUEUE -->|"批量拉取"| CONSUMERS
CONSUMERS --> DISPATCHER
DISPATCHER --> DATA
DISPATCHER --> STATUS
DISPATCHER --> ERROR
DISPATCHER --> ACK
```
## 设备命令 ACK 与重发
```mermaid
sequenceDiagram
participant App as 移动端 App
participant API as AppController
participant Pub as DeviceMqttCommandPublisher
participant Redis as Redis
participant MQTT as MqttClientManager
participant Broker as MQTT Broker
participant Dev as 设备
participant Ack as MqttCommandAckService
participant Retry as MqttCommandRetryTask
App->>API: switchDevice(workStatus, durationMin)
API->>Pub: send(DeviceCommand)
Pub->>Redis: 保存 pending commandId
Pub->>MQTT: publish /water/{deviceNo}/command
MQTT->>Broker: 下发命令
Broker->>Dev: 命令到达设备
alt 设备正常回复
Dev->>Broker: publish /water/{deviceNo}/ack
Broker->>MQTT: ACK 消息
MQTT->>Ack: handleAck(deviceNo, payload)
Ack->>Redis: 删除 pending保存 ack 结果
else 设备断网或未回复
Retry->>Redis: 扫描 pending 命令
Retry->>Redis: 检查设备状态缓存
alt 设备在线且未超过重试次数
Retry->>MQTT: 重新 publish 命令
MQTT->>Broker: 重新下发
else 设备离线
Retry->>Redis: 延后 nextRetryAt
else 超过最大重试次数
Retry->>Redis: 删除 pending
end
end
```
## Redis Key 规划
```mermaid
flowchart TB
REDIS[("Redis")]
STATUS["mqtt:device:status:{deviceNo}<br/>设备最新在线状态<br/>TTL: 300s"]
PENDING["mqtt:command:pending:{commandId}<br/>待ACK命令详情<br/>TTL: 86400s"]
PENDING_IDS["mqtt:command:pending:ids<br/>待ACK commandId 集合"]
ACK["mqtt:command:ack:{commandId}<br/>设备ACK结果<br/>TTL: 86400s"]
REDIS --> STATUS
REDIS --> PENDING
REDIS --> PENDING_IDS
REDIS --> ACK
```
## 核心 Topic 约定
```mermaid
flowchart LR
DEVICE["设备"]
SERVER["后端"]
DEVICE -->|"/water/{deviceNo}/data"| SERVER
DEVICE -->|"/water/{deviceNo}/status"| SERVER
DEVICE -->|"/water/{deviceNo}/erromes"| SERVER
DEVICE -->|"/water/{deviceNo}/ack"| SERVER
SERVER -->|"/water/{deviceNo}/command"| DEVICE
```
设备 ACK 示例:
```json
{
"commandId": "后端下发的commandId",
"status": "success",
"message": "ok"
}
```
## 高并发关注点
```mermaid
flowchart TB
A["上千设备并发连接"] --> B["MQTT Broker 承载连接数和 TLS 握手"]
B --> C["后端单/少量订阅客户端消费通配 topic"]
C --> D["有界队列吸收突发流量"]
D --> E["批量消费降低线程调度成本"]
E --> F["状态写 Redis避免心跳打数据库"]
F --> G["数据/告警后续建议批量落库"]
```
当前已完成的关键优化:
- MQTT 使用 `MqttAsyncClient`
- MQTT 连接支持 TLS
- MQTT 回调只入队,不直接执行业务
- 消息按批次消费
- 设备状态写 Redis 缓存
- 设备命令支持 ACK
- 未 ACK 命令支持离线延后和超时重发
- 日志异步队列已扩大
后续建议:
- 设备数据和告警数据落库改为批量写入
- 对设备命令增加业务状态表,便于前端查询命令执行状态
- MQTT Broker 使用集群或至少做连接数、会话数、消息速率监控
- 生产环境 TLS 不建议开启 `skip-verify`

View File

@@ -18,6 +18,31 @@ INSERT INTO sys_menu VALUES ('11652', '流程定义激活/挂起', '11620', '9',
INSERT INTO sys_menu VALUES ('11653', '流程实例查询', '11621', '1', '#', '', '', 1, 0, 'F', '0', '0', 'workflow:instance:query', '#', 103, 1, sysdate(), null, null, '');
INSERT INTO sys_menu VALUES ('11654', '流程变量查询', '11621', '2', '#', '', '', 1, 0, 'F', '0', '0', 'workflow:instance:variableQuery', '#', 103, 1, sysdate(), null, null, '');
INSERT INTO sys_menu VALUES ('11655', '流程变量修改', '11621', '3', '#', '', '', 1, 0, 'F', '0', '0', 'workflow:instance:variable', '#', 103, 1, sysdate(), null, null, '');
-- Device binding and device_no primary key migration.
ALTER TABLE app_device ADD COLUMN bind_token_hash varchar(255) NULL COMMENT '设备绑定令牌哈希' AFTER device_no;
ALTER TABLE app_scheduling_device ADD COLUMN device_no varchar(64) NULL COMMENT '设备编号' AFTER schedule_id;
UPDATE app_scheduling_device sd
INNER JOIN app_device d ON sd.device_id = d.id
SET sd.device_no = d.device_no
WHERE sd.device_no IS NULL OR sd.device_no = '';
UPDATE app_watering_log wl
INNER JOIN app_device d ON wl.device_id = d.id
SET wl.device_no = d.device_no
WHERE wl.device_id IS NOT NULL AND (wl.device_no IS NULL OR wl.device_no = '');
ALTER TABLE app_scheduling_device DROP COLUMN device_id;
ALTER TABLE app_watering_log DROP COLUMN device_id;
ALTER TABLE app_device MODIFY COLUMN device_no varchar(64) NOT NULL COMMENT '设备编号';
ALTER TABLE app_device MODIFY COLUMN id bigint NOT NULL;
ALTER TABLE app_device DROP PRIMARY KEY;
ALTER TABLE app_device DROP COLUMN id;
ALTER TABLE app_device ADD PRIMARY KEY (device_no);
DELETE sd1 FROM app_scheduling_device sd1
INNER JOIN app_scheduling_device sd2
ON sd1.schedule_id = sd2.schedule_id
AND sd1.device_no = sd2.device_no
AND sd1.id > sd2.id;
ALTER TABLE app_scheduling_device ADD UNIQUE KEY uk_schedule_device (schedule_id, device_no);
INSERT INTO sys_menu VALUES ('11656', '流程实例激活/挂起', '11621', '4', '#', '', '', 1, 0, 'F', '0', '0', 'workflow:instance:active', '#', 103, 1, sysdate(), null, null, '');
INSERT INTO sys_menu VALUES ('11657', '流程实例删除', '11621', '5', '#', '', '', 1, 0, 'F', '0', '0', 'workflow:instance:remove', '#', 103, 1, sysdate(), null, null, '');
INSERT INTO sys_menu VALUES ('11658', '流程实例作废', '11621', '6', '#', '', '', 1, 0, 'F', '0', '0', 'workflow:instance:invalid', '#', 103, 1, sysdate(), null, null, '');

View File

@@ -1,5 +1,6 @@
package org.dromara;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup;
@@ -9,7 +10,7 @@ import org.springframework.boot.context.metrics.buffering.BufferingApplicationSt
*
* @author Lion Li
*/
@Slf4j
@SpringBootApplication
public class DromaraApplication {
@@ -17,7 +18,7 @@ public class DromaraApplication {
SpringApplication application = new SpringApplication(DromaraApplication.class);
application.setApplicationStartup(new BufferingApplicationStartup(2048));
application.run(args);
System.out.println("(♥◠‿◠)ノ゙ water-Vue-Plus启动成功 ლ(´ڡ`ლ)゙");
log.info("(♥◠‿◠)ノ゙ water-Vue-Plus启动成功 ლ(´ڡ`ლ)゙");
}
}

View File

@@ -20,30 +20,53 @@ server:
# 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载
worker: 256
--- # mqtt配置信息
--- # mqtt配置信息
mqtt:
enabled: ${MQTT_ENABLED:true}
broker-url: ssl://service.reinkun.com:8883
username: admin
password: yukun_mqtt
client-id: dm-server-${random.int[1000,9999]}
client-id: water-server-${server.port}
qos: 1
keep-alive: 60
connection-timeout: 30
max-inflight: 5000
clean-session: false
automatic-reconnect: true
async:
core-pool-size: 16
max-pool-size: 64
consumer-count: 16
queue-capacity: 20000
batch-size: 100
offer-timeout-ms: 50
poll-timeout-ms: 100
tls:
enabled: true
skip-verify: true
skip-verify: false
command-ack:
enabled: true
max-retry-count: 3
retry-interval-ms: 5000
scan-interval-ms: 5000
pending-ttl-seconds: 86400
ack-ttl-seconds: 86400
pending-key-prefix: "mqtt:command:pending:"
ack-key-prefix: "mqtt:command:ack:"
pending-set-key: "mqtt:command:pending:ids"
device-status-cache-prefix: "mqtt:device:status:"
device-status-cache-ttl-seconds: 300
topics:
subscribe:
- /publish/+/error
- /publish/deviceInfo
- /publish/finish/schedule
- /publish/finish/key
- /publish/schedule/canceled
- /subscriber/+/schedule
- /subscriber/+/cancel
- /subscriber/+/key
publish-prefix: /water
- /+/publish/finish/schedule #排程任务完成上报
- /+/publish/register #设备注册
- /+/publish/power #电量
- /+/publish/ack #应答
- /+/publish/finish/key #按键浇水完成
- /+/publish/error #硬件故障
# - /+/subscriber/cmd #下发命令 0关闭浇水 1开始浇水
publish-prefix: ""
captcha:
# 是否启用验证码校验
enable: true
@@ -124,8 +147,8 @@ sa-token:
is-concurrent: true
# 在多人登录同一账号时是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token)
is-share: false
# jwt秘钥
jwt-secret-key: abcdefghijklmnopqrstuvwxyz
# jwt秘钥(生产环境务必通过环境变量覆盖此弱默认值)
jwt-secret-key: ${SA_TOKEN_JWT_SECRET_KEY:abcdefghijklmnopqrstuvwxyz}
# security配置
security:

View File

@@ -82,7 +82,7 @@
<!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>512</queueSize>
<queueSize>8192</queueSize>
<!-- 添加附加的appender,最多只能添加一个 -->
<appender-ref ref="file_info"/>
</appender>
@@ -92,7 +92,7 @@
<!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>512</queueSize>
<queueSize>8192</queueSize>
<!-- 添加附加的appender,最多只能添加一个 -->
<appender-ref ref="file_error"/>
</appender>

View File

@@ -6,7 +6,7 @@
<parent>
<groupId>org.dromara</groupId>
<artifactId>water-common</artifactId>
<version>5.6.0</version>
<version>${revision}</version>
</parent>
<artifactId>water-common-mqtt</artifactId>

View File

@@ -0,0 +1,52 @@
package org.dromara.mqtt;
import lombok.RequiredArgsConstructor;
import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.service.IDeviceCommandPublisher;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.mqtt.config.properties.MqttProperties;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
@RequiredArgsConstructor
public class DeviceMqttCommandPublisher implements IDeviceCommandPublisher {
private final MqttClientManager mqttClientManager;
private final MqttCommandAckService ackService;
private final MqttProperties mqttProperties;
@Override
public String send(DeviceCommand command) {
long now = System.currentTimeMillis();
if (StringUtils.isBlank(command.getCommandId())) {
command.setCommandId(UUID.randomUUID().toString().replace("-", ""));
}
if (StringUtils.isBlank(command.getTopic())) {
command.setTopic(buildCommandTopic(command.getDeviceNo()));
}
command.setCreatedAt(now);
command.setLastSentAt(now);
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
command.getPayload().put("commandId", command.getCommandId());
command.getPayload().put("commandType", command.getCommandType());
ackService.savePending(command);
mqttClientManager.publish(command.getTopic(), JsonUtils.toJsonString(command.getPayload()));
return command.getCommandId();
}
private String buildCommandTopic(String deviceNo) {
String prefix = mqttProperties.getTopics().getPublishPrefix();
if (StringUtils.isBlank(prefix)) {
return "/" + deviceNo + "/subscriber/cmd";
}
String normalizedPrefix = prefix.startsWith("/") ? prefix : "/" + prefix;
if (normalizedPrefix.endsWith("/")) {
normalizedPrefix = normalizedPrefix.substring(0, normalizedPrefix.length() - 1);
}
return normalizedPrefix + "/" + deviceNo + "/subscriber/cmd";
}
}

View File

@@ -4,91 +4,278 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.mqtt.MqttMessageDispatcher;
import org.dromara.mqtt.config.properties.MqttProperties;
import org.eclipse.paho.client.mqttv3.*;
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
import org.eclipse.paho.client.mqttv3.MqttAsyncClient;
import org.eclipse.paho.client.mqttv3.MqttCallbackExtended;
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.persist.MemoryPersistence;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
@Slf4j
@AutoConfiguration
@EnableScheduling
@EnableConfigurationProperties(MqttProperties.class)
@RequiredArgsConstructor
public class MqttClientManager implements InitializingBean, DisposableBean {
public class MqttClientManager implements DisposableBean {
private final MqttProperties props;
private final MqttMessageDispatcher dispatcher;
private MqttClient client;
@Override
public void afterPropertiesSet() throws Exception {
// mqttConnect();
}
private MqttAsyncClient client;
private ThreadPoolExecutor consumerExecutor;
private BlockingQueue<InboundMessage> messageQueue;
private volatile boolean running;
@Bean
private MqttClient mqttConnect() throws MqttException {
client = new MqttClient(props.getBrokerUrl(), props.getClientId(), new MemoryPersistence());
@ConditionalOnProperty(prefix = "mqtt", name = "enabled", havingValue = "true")
public MqttAsyncClient mqttConnect() throws MqttException {
messageQueue = new ArrayBlockingQueue<>(Math.max(1, props.getAsync().getQueueCapacity()));
consumerExecutor = createConsumerExecutor();
running = true;
startConsumers();
client = new MqttAsyncClient(props.getBrokerUrl(), props.getClientId(), new MemoryPersistence());
MqttConnectOptions options = new MqttConnectOptions();
options.setUserName(props.getUsername());
if (props.getPassword() != null) {
options.setPassword(props.getPassword().toCharArray());
}
options.setKeepAliveInterval(props.getKeepAlive());
options.setConnectionTimeout(props.getConnectionTimeout());
options.setAutomaticReconnect(true);
options.setCleanSession(false);
options.setAutomaticReconnect(props.isAutomaticReconnect());
options.setCleanSession(props.isCleanSession());
options.setMaxInflight(props.getMaxInflight());
configureTls(options);
client.setCallback(new MqttCallback() {
client.setCallback(new MqttCallbackExtended() {
@Override
public void connectionLost(Throwable cause) {
log.warn("[MQTT] 连接断开: {}", cause.getMessage());
log.warn("[MQTT] connection lost: {}", cause == null ? "" : cause.getMessage());
}
@Override
public void connectComplete(boolean reconnect, String serverURI) {
if (reconnect) {
log.info("[MQTT] reconnected: {}", serverURI);
subscribeTopics();
}
}
@Override
public void messageArrived(String topic, MqttMessage message) {
dispatcher.dispatch(topic, new String(message.getPayload()));
String payload = new String(message.getPayload(), StandardCharsets.UTF_8);
enqueue(topic, payload);
}
@Override
public void deliveryComplete(IMqttDeliveryToken token) {}
public void deliveryComplete(IMqttDeliveryToken token) {
}
});
client.connect(options);
log.info("[MQTT] 连接成功: {}", props.getBrokerUrl());
// 订阅所有配置的主题
for (String topic : props.getTopics().getSubscribe()) {
client.subscribe(topic, props.getQos());
// log.info("[MQTT] 订阅主题: {}", topic);
log.info("[MQTT] 订阅成功: {} qos={}", topic, props.getQos()); // ← 加这行
}
client.connect(options).waitForCompletion();
log.info("[MQTT] connected: {}", props.getBrokerUrl());
subscribeTopics();
return client;
}
/**
* 发布消息
*/
private void configureTls(MqttConnectOptions options) throws MqttException {
if (!isTlsEnabled()) {
return;
}
options.setHttpsHostnameVerificationEnabled(!props.getTls().isSkipVerify());
if (props.getTls().isSkipVerify()) {
try {
options.setSocketFactory(createTrustAllSslContext().getSocketFactory());
options.setSSLHostnameVerifier((hostname, session) -> true);
} catch (GeneralSecurityException e) {
throw new MqttException(e);
}
}
}
private boolean isTlsEnabled() {
return props.getTls() != null
&& props.getTls().isEnabled()
&& props.getBrokerUrl() != null
&& props.getBrokerUrl().startsWith("ssl://");
}
private SSLContext createTrustAllSslContext() throws GeneralSecurityException {
TrustManager[] trustManagers = new TrustManager[] {
new X509TrustManager() {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}
};
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagers, new SecureRandom());
return sslContext;
}
private ThreadPoolExecutor createConsumerExecutor() {
MqttProperties.Async async = props.getAsync();
int corePoolSize = Math.max(1, async.getCorePoolSize());
int maxPoolSize = Math.max(corePoolSize, async.getMaxPoolSize());
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize,
maxPoolSize,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(maxPoolSize),
mqttThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
executor.allowCoreThreadTimeOut(true);
return executor;
}
private ThreadFactory mqttThreadFactory() {
AtomicLong threadNo = new AtomicLong();
return runnable -> {
Thread thread = new Thread(runnable);
thread.setName("mqtt-consumer-" + threadNo.incrementAndGet());
thread.setDaemon(true);
return thread;
};
}
private void startConsumers() {
int consumerCount = Math.max(1, props.getAsync().getConsumerCount());
for (int i = 0; i < consumerCount; i++) {
consumerExecutor.execute(this::consumeLoop);
}
}
private void enqueue(String topic, String payload) {
InboundMessage inboundMessage = new InboundMessage(topic, payload);
try {
if (!messageQueue.offer(inboundMessage, props.getAsync().getOfferTimeoutMs(), TimeUnit.MILLISECONDS)) {
log.warn("[MQTT] inbound queue full, dropped topic={}", topic);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("[MQTT] enqueue interrupted topic={}", topic);
}
}
private void consumeLoop() {
MqttProperties.Async async = props.getAsync();
int batchSize = Math.max(1, async.getBatchSize());
long pollTimeoutMs = Math.max(1, async.getPollTimeoutMs());
List<InboundMessage> batch = new java.util.ArrayList<>(batchSize);
while (running || !messageQueue.isEmpty()) {
try {
InboundMessage first = messageQueue.poll(pollTimeoutMs, TimeUnit.MILLISECONDS);
if (first == null) {
continue;
}
batch.add(first);
messageQueue.drainTo(batch, batchSize - 1);
for (InboundMessage message : batch) {
dispatch(message);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} finally {
batch.clear();
}
}
}
private void dispatch(InboundMessage message) {
try {
dispatcher.dispatch(message.topic(), message.payload());
} catch (Exception e) {
log.error("[MQTT] dispatch failed topic={}", message.topic(), e);
}
}
private void subscribeTopics() {
try {
if (client == null || !client.isConnected()) {
return;
}
List<String> topics = props.getTopics() == null ? List.of() : props.getTopics().getSubscribe();
if (topics == null || topics.isEmpty()) {
log.warn("[MQTT] no subscribe topics configured");
return;
}
int[] qos = topics.stream().mapToInt(topic -> props.getQos()).toArray();
client.subscribe(topics.toArray(new String[0]), qos).waitForCompletion();
topics.forEach(topic -> log.info("[MQTT] subscribed: {} qos={}", topic, props.getQos()));
} catch (MqttException e) {
log.error("[MQTT] subscribe failed", e);
}
}
public void publish(String topic, String payload) {
try {
if (client == null || !client.isConnected()) {
log.warn("[MQTT] publish skipped, client not connected topic={}", topic);
return;
}
MqttMessage message = new MqttMessage(payload.getBytes(StandardCharsets.UTF_8));
message.setQos(props.getQos());
message.setRetained(false);
client.publish(topic, message);
log.info("[MQTT] 发布消息 topic={} payload={}", topic, payload);
log.info("[MQTT] published topic={} payload={}", topic, payload);
} catch (MqttException e) {
log.error("[MQTT] 发布失败 topic={}", topic, e);
log.error("[MQTT] publish failed topic={}", topic, e);
}
}
@Override
public void destroy() throws Exception {
if (client != null && client.isConnected()) {
client.disconnect();
if (client != null) {
if (client.isConnected()) {
client.disconnect().waitForCompletion(5000);
}
client.close();
}
if (consumerExecutor != null) {
running = false;
consumerExecutor.shutdown();
if (!consumerExecutor.awaitTermination(10, TimeUnit.SECONDS)) {
consumerExecutor.shutdownNow();
}
}
}
private record InboundMessage(String topic, String payload) {
}
}

View File

@@ -0,0 +1,199 @@
package org.dromara.mqtt;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.domain.mqtt.DeviceCommandAck;
import org.dromara.app.service.IDeviceCommandAckHandler;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.mqtt.config.properties.MqttProperties;
import org.redisson.api.RLock;
import org.redisson.api.RSet;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class MqttCommandAckService implements IDeviceCommandAckHandler {
// 字段注入 + @Lazy 打破 MqttClientManager → MqttMessageDispatcher → MqttCommandAckService → MqttClientManager 循环
// publish() 仅在 retryExpiredCommands() 运行时调用,构造阶段无需立即解析
// 注意:不能声明为 final否则 Lombok @RequiredArgsConstructor 会放入构造器,@Lazy 失效
@Lazy
@Autowired
private MqttClientManager mqttClientManager;
private final MqttProperties mqttProperties;
public void savePending(DeviceCommand command) {
RedisUtils.setCacheObject(pendingKey(command.getCommandId()), command, Duration.ofSeconds(mqttProperties.getCommandAck().getPendingTtlSeconds()));
pendingIds().add(command.getCommandId());
}
@Override
public void handleAck(String deviceNo, String payload) {
if (StringUtils.isBlank(payload)) {
log.warn("[MQTT] 收到空 ACK deviceNo={}", deviceNo);
return;
}
String trimmedPayload = payload.trim();
if (!trimmedPayload.startsWith("{")) {
handlePlainAck(deviceNo, trimmedPayload);
return;
}
DeviceCommandAck ack;
try {
ack = JsonUtils.parseObject(trimmedPayload, DeviceCommandAck.class);
} catch (RuntimeException e) {
log.warn("[MQTT] ACK JSON 格式错误 deviceNo={} payload={}", deviceNo, payload, e);
return;
}
if (ack == null || ack.getCommandId() == null) {
log.warn("[MQTT] ACK 缺少 commandId deviceNo={} payload={}", deviceNo, payload);
return;
}
ack.setDeviceNo(deviceNo);
RedisUtils.deleteObject(pendingKey(ack.getCommandId()));
pendingIds().remove(ack.getCommandId());
RedisUtils.setCacheObject(ackKey(ack.getCommandId()), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
refreshDeviceOnline(deviceNo);
log.info("[MQTT] command ack received deviceNo={} commandId={} status={}", deviceNo, ack.getCommandId(), ack.getStatus());
}
private void handlePlainAck(String deviceNo, String payload) {
List<DeviceCommand> pendingCommands = findPendingCommandsByDeviceNo(deviceNo);
refreshDeviceOnline(deviceNo);
if (pendingCommands.size() != 1) {
log.warn("[MQTT] 非 JSON ACK 无法唯一匹配待确认命令 deviceNo={} pendingCount={} payload={}", deviceNo, pendingCommands.size(), payload);
return;
}
DeviceCommand command = pendingCommands.get(0);
String commandId = command.getCommandId();
DeviceCommandAck ack = new DeviceCommandAck();
ack.setDeviceNo(deviceNo);
ack.setCommandId(commandId);
ack.setStatus("1");
ack.setMessage(payload);
RedisUtils.deleteObject(pendingKey(commandId));
pendingIds().remove(commandId);
RedisUtils.setCacheObject(ackKey(commandId), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
log.info("[MQTT] plain command ack received deviceNo={} commandId={} payload={}", deviceNo, commandId, payload);
}
private List<DeviceCommand> findPendingCommandsByDeviceNo(String deviceNo) {
List<DeviceCommand> commands = new ArrayList<>();
for (String commandId : pendingIds().readAll()) {
DeviceCommand command = RedisUtils.getCacheObject(pendingKey(commandId));
if (command != null && deviceNo.equals(command.getDeviceNo())) {
commands.add(command);
}
}
return commands;
}
public void retryExpiredCommands() {
long now = System.currentTimeMillis();
Collection<String> commandIds = pendingIds().readAll();
for (String commandId : commandIds) {
retryCommandIfLocked(commandId, now);
}
}
private void retryCommandIfLocked(String commandId, long now) {
RLock lock = RedisUtils.getClient().getLock(mqttProperties.getCommandAck().getRetryLockKeyPrefix() + commandId);
boolean locked = false;
try {
locked = lock.tryLock(0, mqttProperties.getCommandAck().getRetryLockTtlMs(), TimeUnit.MILLISECONDS);
if (!locked) {
return;
}
retryCommand(commandId, now);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("[MQTT] 命令重试锁等待被中断 commandId={}", commandId);
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private void retryCommand(String commandId, long now) {
DeviceCommand command = RedisUtils.getCacheObject(pendingKey(commandId));
if (command == null) {
pendingIds().remove(commandId);
return;
}
if (command.getNextRetryAt() > now) {
return;
}
if (command.getRetryCount() >= mqttProperties.getCommandAck().getMaxRetryCount()) {
RedisUtils.deleteObject(pendingKey(commandId));
pendingIds().remove(commandId);
log.warn("[MQTT] 命令重试次数已达上限 deviceNo={} commandId={}", command.getDeviceNo(), commandId);
return;
}
if (!isDeviceOnline(command.getDeviceNo())) {
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
savePending(command);
log.debug("[MQTT] command retry delayed, device offline deviceNo={} commandId={}", command.getDeviceNo(), commandId);
return;
}
command.setRetryCount(command.getRetryCount() + 1);
command.setLastSentAt(now);
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
mqttClientManager.publish(command.getTopic(), JsonUtils.toJsonString(command.getPayload()));
savePending(command);
log.info("[MQTT] command resent deviceNo={} commandId={} retry={}", command.getDeviceNo(), commandId, command.getRetryCount());
}
private boolean isDeviceOnline(String deviceNo) {
Map<String, Object> status = RedisUtils.getCacheObject(mqttProperties.getCommandAck().getDeviceStatusCachePrefix() + deviceNo);
if (status == null) {
return false;
}
Object value = status.get("status");
return value == null || !"0".equals(String.valueOf(value));
}
private void refreshDeviceOnline(String deviceNo) {
Map<String, Object> statusCache = new HashMap<>();
statusCache.put("deviceNo", deviceNo);
statusCache.put("status", "1");
statusCache.put("lastReportTime", Instant.now().toString());
RedisUtils.setCacheObject(
mqttProperties.getCommandAck().getDeviceStatusCachePrefix() + deviceNo,
statusCache,
Duration.ofSeconds(mqttProperties.getCommandAck().getDeviceStatusCacheTtlSeconds())
);
}
private RSet<String> pendingIds() {
return RedisUtils.getClient().getSet(mqttProperties.getCommandAck().getPendingSetKey());
}
private String pendingKey(String commandId) {
return mqttProperties.getCommandAck().getPendingKeyPrefix() + commandId;
}
private String ackKey(String commandId) {
return mqttProperties.getCommandAck().getAckKeyPrefix() + commandId;
}
}

View File

@@ -0,0 +1,21 @@
package org.dromara.mqtt;
import lombok.RequiredArgsConstructor;
import org.dromara.mqtt.config.properties.MqttProperties;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class MqttCommandRetryTask {
private final MqttCommandAckService ackService;
private final MqttProperties mqttProperties;
@Scheduled(fixedDelayString = "${mqtt.command-ack.scan-interval-ms:5000}")
public void retryExpiredCommands() {
if (mqttProperties.getCommandAck().isEnabled()) {
ackService.retryExpiredCommands();
}
}
}

View File

@@ -3,8 +3,8 @@ package org.dromara.mqtt.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Data
@@ -12,6 +12,7 @@ import java.util.List;
@ConfigurationProperties(prefix = "mqtt")
public class MqttProperties {
private boolean enabled = false;
private String brokerUrl;
private String username;
private String password;
@@ -19,11 +20,52 @@ public class MqttProperties {
private int qos = 1;
private int keepAlive = 60;
private int connectionTimeout = 30;
private Topics topics;
private int maxInflight = 1000;
private boolean cleanSession = false;
private boolean automaticReconnect = true;
private Topics topics = new Topics();
private Async async = new Async();
private Tls tls = new Tls();
private CommandAck commandAck = new CommandAck();
@Data
public static class Topics {
private List<String> subscribe;
private List<String> subscribe = new ArrayList<>();
private String publishPrefix;
}
@Data
public static class Async {
private int corePoolSize = 8;
private int maxPoolSize = 32;
private int consumerCount = 16;
private int queueCapacity = 5000;
private int batchSize = 100;
private long offerTimeoutMs = 50;
private long pollTimeoutMs = 100;
}
@Data
public static class Tls {
private boolean enabled = true;
private boolean skipVerify = false;
}
@Data
public static class CommandAck {
private boolean enabled = true;
private int maxRetryCount = 3;
private long retryIntervalMs = 5000;
private long scanIntervalMs = 5000;
private long pendingTtlSeconds = 86400;
private long ackTtlSeconds = 86400;
private String pendingKeyPrefix = "mqtt:command:pending:";
private String ackKeyPrefix = "mqtt:command:ack:";
private String pendingSetKey = "mqtt:command:pending:ids";
private String retryLockKeyPrefix = "lock:mqtt:command:retry:";
private long retryLockTtlMs = 30000;
private String deviceStatusCachePrefix = "mqtt:device:status:";
private long deviceStatusCacheTtlSeconds = 300;
}
}

View File

@@ -1,2 +1,5 @@
org.dromara.mqtt.MqttClientManager
org.dromara.mqtt.MqttCommandAckService
org.dromara.mqtt.DeviceMqttCommandPublisher
org.dromara.mqtt.MqttCommandRetryTask
org.dromara.app.mqtt.MqttMessageDispatcher

View File

@@ -10,10 +10,11 @@ import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.bo.*;
import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.domain.vo.*;
import org.dromara.app.service.*;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.app.service.impl.AppScheduleServiceImpl;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser;
@@ -38,7 +39,6 @@ import org.dromara.system.service.ISysUserService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.text.SimpleDateFormat;
import java.util.*;
@@ -55,8 +55,8 @@ public class AppController extends BaseController {
private final AppScheduleServiceImpl appScheduleServiceimpl;
private final IAppSchedulingDeviceService appSchedulingDeviceService;
private final ISysUserService userService;
private final IAppWateringLogService wateringLogService;
private final IAppWateringLogService appWateringLogService;
private final IDeviceCommandService deviceCommandService;
/**
*查询设备
@@ -76,7 +76,6 @@ public class AppController extends BaseController {
* 添加设备
*
*/
//@SaCheckRole("appadmin")
@ApiEncrypt
@PostMapping("/addDevice")
public R<Void> addDevice(@RequestBody String body) {
@@ -91,16 +90,27 @@ public class AppController extends BaseController {
}
}
/**
* 绑定已通过MQTT注册上线的设备
*/
@ApiEncrypt
@PostMapping("/bindDevice")
public R<AppDeviceVo> bindDevice(@RequestBody String body) {
AppDeviceBo appDevice = JsonUtils.parseObject(body, AppDeviceBo.class);
appDevice.setUserId(LoginHelper.getUserId());
return R.ok(appDeviceService.bindRegisteredDevice(appDevice));
}
/**
* 获取设备信息
详细信息
*
* @param id 主键
* @param deviceNo 主键
*/
@GetMapping("/device/{id}")
@GetMapping("/device/{deviceNo}")
public R<AppDeviceVo> getDeviceInfo(@NotNull(message = "主键不能为空")
@PathVariable Long id) {
return R.ok(appDeviceService.queryById(id));
@PathVariable String deviceNo) {
return R.ok(appDeviceService.queryById(deviceNo));
}
@@ -124,10 +134,10 @@ public class AppController extends BaseController {
* 删除设备
*/
@Log(title = "设备信息", businessType = BusinessType.DELETE)
@DeleteMapping("/deleteDevice/{ids}")
@DeleteMapping("/deleteDevice/{deviceNos}")
public R<Void> removeDevice(@NotEmpty(message = "主键不能为空")
@PathVariable Long[] ids) {
return toAjax(appDeviceService.deleteWithValidByIds(List.of(ids), true));
@PathVariable String[] deviceNos) {
return toAjax(appDeviceService.deleteWithValidByIds(List.of(deviceNos), true));
}
@@ -141,40 +151,17 @@ public class AppController extends BaseController {
public R<Void> switchDevice(@RequestBody String body) {
Map<String,String> map = JsonUtils.parseObject(body, Map.class);
try {
Long id = Long.valueOf(map.get("id"));
String deviceNo = map.get("deviceNo");
String status = map.get("workStatus");
String startTime = map.get("startTime");
int durationMin = Integer.valueOf(map.get("durationMin"));
Date date = new Date();
String durationMinValue = map.get("durationMin");
Integer durationMin = StringUtils.isBlank(durationMinValue) ? null : Integer.valueOf(durationMinValue);
//更新设备工作状态
AppDeviceBo appDeviceBo = new AppDeviceBo();
appDeviceBo.setId(id);
appDeviceBo.setWorkStatus(status);
Boolean flag = appDeviceService.updateByBo(appDeviceBo);
if (flag){
//todo 给设备发送开始浇水信息
Boolean flag = appDeviceService.switchDevice(deviceNo, status, startTime, durationMin);
// 记录信息到历史表
AppWateringLogBo appWateringLog = new AppWateringLogBo();
appWateringLog.setDeviceId(id);
appWateringLog.setUserId(LoginHelper.getUserId());
appWateringLog.setDurationMin(durationMin+"");
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
if (StringUtils.isNotEmpty(startTime)){
Date date = sdf.parse(startTime);
appWateringLog.setStartTime(date);
long time = date.getTime()+durationMin*60000L;
Date endTime = new Date(time);
appWateringLog.setEndTime(endTime);
}
appWateringLog.setTriggerType("1");
appWateringLog.setCreateTime(new Date());
appWateringLog.setScheduleId(1L);
wateringLogService.insertByBo(appWateringLog);
}
return toAjax(flag);
} catch (Exception e) {
throw new RuntimeException(e);
return R.fail(e.getMessage());
}
}
@@ -199,42 +186,15 @@ public class AppController extends BaseController {
for (AppDeviceVo appDeviceVo:appDeviceVos){
List<AppSchedulingDeviceVo> appSchedulingDeviceVos = appSchedulingDeviceService.queryByDeviveId(appDeviceVo.getId());
List<AppSchedulingDeviceVo> appSchedulingDeviceVos = appSchedulingDeviceService.findByDeviceNo(appDeviceVo.getDeviceNo());
if(appSchedulingDeviceVos.size() == 0){
appDeviceVoList.add(appDeviceVo);
}
}
retMap.put("deviceList", appDeviceVoList);
// if (appDeviceVoList.size()==0){
// retMap.put("deviceList", appDeviceVos);
// }
return R.ok(retMap);
/** //排程信息
AppScheduleVo appScheduleVo = appScheduleService.queryById(scheduleId);
if (ObjectUtil.isNotNull(appScheduleVo)&&"1".equals(appScheduleVo.getStatus())){
for (AppDeviceVo appDeviceVo:appDeviceVos){
//查询设备日程冲突
List list = appScheduleServiceimpl.checkConflict(appDeviceVo.getId(), appScheduleVo);
if (list.size()==0){
AppDeviceVoList.add(appDeviceVo);
}
// retMap.put("conflict", list);
}
retMap.put("devicelist", AppDeviceVoList);
}else {
retMap.put("devicelist", appDeviceVos);
// retMap.put("conflict", new ArrayList<>());
return R.ok(retMap);
}
return R.ok(retMap); **/
}
/**
*绑定排程跟设备
@@ -242,33 +202,62 @@ public class AppController extends BaseController {
@RepeatSubmit()
@PostMapping("/addScheduleDevice")
public R<Void> addScheduleDevice( @RequestBody String body) {
try {
Map<String, Object> map = JsonUtils.parseObject(body, Map.class);
if (map == null || map.get("scheduleId") == null) {
throw new ServiceException("scheduleId不能为空");
}
Long scheduleId = Long.valueOf(map.get("scheduleId").toString());
List<String> deviceIds= (List<String>) map.get("deviceIds");
for (String deviceId:deviceIds){
List<String> deviceNos = parseDeviceNos(map);
if (deviceNos.isEmpty()) {
throw new ServiceException("deviceNos不能为空");
}
for (String deviceNo:deviceNos){
if (StringUtils.isBlank(deviceNo)) {
continue;
}
AppSchedulingDeviceBo schedulingDeviceBo = new AppSchedulingDeviceBo();
schedulingDeviceBo.setDeviceId(Long.valueOf(deviceId));
schedulingDeviceBo.setDeviceNo(deviceNo);
schedulingDeviceBo.setScheduleId(scheduleId);
appSchedulingDeviceService.insertByBo(schedulingDeviceBo);
}
return R.ok();
} catch (RuntimeException e) {
throw new RuntimeException(e);
}
private List<String> parseDeviceNos(Map<String, Object> map) {
Object value = Optional.ofNullable(map.get("deviceNos"))
.orElseGet(() -> Optional.ofNullable(map.get("deviceNo")).orElse(map.get("deviceIds")));
if (value == null) {
return Collections.emptyList();
}
if (value instanceof Collection<?> collection) {
return collection.stream()
.filter(Objects::nonNull)
.map(Object::toString)
.filter(StringUtils::isNotBlank)
.toList();
}
String text = value.toString();
if (StringUtils.isBlank(text)) {
return Collections.emptyList();
}
return Arrays.stream(text.split(","))
.map(String::trim)
.filter(StringUtils::isNotBlank)
.toList();
}
/**
* 删除排程与设备绑定
* @param scheduleId,deviceId
* @param scheduleId,deviceNo
* @return
*/
@Log(title = "排程与设备关联", businessType = BusinessType.DELETE)
@DeleteMapping("/deleteScheduleDevice")
public R<Void> deleteScheduleDevice(@NotEmpty(message = "主键不能为空")
@RequestParam String scheduleId, @RequestParam String deviceId) {
return toAjax(appSchedulingDeviceService.deleteWithValidByschedulingIds2Ids(Long.valueOf(scheduleId),Long.valueOf(deviceId)));
@RequestParam String scheduleId, @RequestParam String deviceNo) {
return toAjax(appSchedulingDeviceService.deleteWithValidByScheduleIdAndDeviceNo(Long.valueOf(scheduleId), deviceNo));
}
@@ -323,9 +312,12 @@ public class AppController extends BaseController {
List<AppSchedulingDeviceVo> appSchedulingDeviceVos = appSchedulingDeviceService.queryList(appSchedulingDeviceBo);
List deviceList = new ArrayList();
for (AppSchedulingDeviceVo appSchedulingDeviceVo:appSchedulingDeviceVos){
AppDeviceVo appDeviceVo = appDeviceService.queryById(appSchedulingDeviceVo.getDeviceId());
AppDeviceVo appDeviceVo = appDeviceService.queryById(appSchedulingDeviceVo.getDeviceNo());
if (ObjectUtil.isNotNull(appDeviceVo)){
deviceList.add(appDeviceVo);
}
}
List detailList = new ArrayList();
for (AppScheduleDetailVo detail:list){
@@ -347,7 +339,7 @@ public class AppController extends BaseController {
appScheduleVo.setDetails(detailList);
appScheduleVo.setDeviceIds(deviceList);
appScheduleVo.setDeviceNos(deviceList);
return R.ok(appScheduleVo);
}
@@ -413,12 +405,11 @@ public class AppController extends BaseController {
List l = new ArrayList<>();
for (AppWateringLogVo vo : appWateringLogVoTableDataInfo.getRows()){
for(AppDeviceVo appDeviceVo:appDeviceVos){
if (appDeviceVo.getId().equals(vo.getDeviceId())){
if (appDeviceVo.getDeviceNo().equals(vo.getDeviceNo())){
vo.setDeviceName(appDeviceVo.getDeviceName());
vo.setDeviceNo(appDeviceVo.getDeviceNo());
}
}
// AppDeviceVo appDeviceVo = appDeviceService.queryById(vo.getDeviceId());
vo.setTriggerType(vo.getTriggerType().equals("0")?"排程":"手动");
l.add(vo);
}
@@ -459,9 +450,9 @@ public class AppController extends BaseController {
//手动浇水次数
Long manualNum = appWateringLogService.queryByTriggerTypeCount(userId, "1");
//总浇水时间
Long allTime = appWateringLogService.queryByTotalWateringTime(userId);
Long allTime = Optional.ofNullable(appWateringLogService.queryByTotalWateringTime(userId)).orElse(0L);
//平均浇水时间
Long averageTime = allTime/count;
Long averageTime = count == null || count == 0 ? 0L : allTime / count;
map.put("count",count);
map.put("monCount",monCount);
@@ -554,4 +545,17 @@ public class AppController extends BaseController {
return toAjax(userService.updateUserPas(bo));
}
@GetMapping("/test")
public R<Void> test (){
DeviceCommand deviceCommand = new DeviceCommand();
String bindDevice = deviceCommandService.sendBindDeviceCommand("01");
// String s = deviceCommandService.sendSwitchCommand("01", "1", "2026-06-10 14:35:31", 20);
return R.ok(bindDevice);
}
}

View File

@@ -66,10 +66,10 @@ public class AppDeviceController extends BaseController {
* @param id 主键
*/
@SaCheckPermission("app:device:query")
@GetMapping("/{id}")
@GetMapping("/{deviceNo}")
public R<AppDeviceVo> getInfo(@NotNull(message = "主键不能为空")
@PathVariable Long id) {
return R.ok(appDeviceService.queryById(id));
@PathVariable String deviceNo) {
return R.ok(appDeviceService.queryById(deviceNo));
}
/**
@@ -104,9 +104,9 @@ public class AppDeviceController extends BaseController {
*/
@SaCheckPermission("app:device:remove")
@Log(title = "设备信息", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
@DeleteMapping("/{deviceNos}")
public R<Void> remove(@NotEmpty(message = "主键不能为空")
@PathVariable Long[] ids) {
return toAjax(appDeviceService.deleteWithValidByIds(List.of(ids), true));
@PathVariable String[] deviceNos) {
return toAjax(appDeviceService.deleteWithValidByIds(List.of(deviceNos), true));
}
}

View File

@@ -1,21 +1,15 @@
package org.dromara.app.domain;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import java.io.Serial;
import java.util.Date;
/**
* 设备信息
对象 app_device
*
* @author Lion Li
* @date 2026-04-25
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("app_device")
@@ -24,92 +18,36 @@ public class AppDevice extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
/**
*
*/
@TableId(value = "id")
private Long id;
/**
* 设备编号
*/
@TableId(value = "device_no", type = IdType.INPUT)
private String deviceNo;
/**
* 用户id
*/
private String bindTokenHash;
private Long userId;
/**
* 设备名称
*/
private String deviceName;
/**
* 设备图片
*/
private String deviceImg;
/**
* 状态 1-工作中 0-离线 2-待机 3-故障
*/
private String status;
/**
* 工作状态 1-工作中 0-休息
*/
private String workStatus;
/**
* 电量
*/
private String powerLevel;
/**
* 电量更新时间
*/
private Date powerLevelUpdatatime;
/**
* WiFi名称
*/
private String wifiName;
/**
* WiFi密码
*/
private String wifiPassword;
/**
* 设备型号
*/
private String deviceEm;
/**
* 设备序列号
*/
private String deviceSn;
/**
* 固件版本
*/
private String fwVer;
/**
* mac地址
*/
private String macAddress;
/**
* 删除标志0代表存在 1代表删除
*/
//@TableLogic
//private String delFlag;
/**
* 到期时间
*/
private Date expirationTime;
}

View File

@@ -46,8 +46,8 @@ public class AppSchedule extends BaseEntity {
*/
private String status;
private Date createTime;
// createTime 已由父类 BaseEntity 提供,此处不再重复定义
// private Date createTime;
//private List<AppScheduleDetail> details;

View File

@@ -1,18 +1,12 @@
package org.dromara.app.domain;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import java.io.Serial;
/**
* 排程与设备关联对象 app_scheduling_device
*
* @author Lion Li
* @date 2026-04-25
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("app_scheduling_device")
@@ -21,15 +15,7 @@ public class AppSchedulingDevice extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
/**
* 排程id
*/
private Long scheduleId;
/**
* 设备id
*/
private Long deviceId;
private String deviceNo;
}

View File

@@ -37,10 +37,13 @@ public class AppWateringLog extends BaseEntity {
/**
* 设备ID
*/
private Long deviceId;
private String deviceNo;
/**
* 命令ID
*/
private String commandId;
/**
* 关联排程
*/

View File

@@ -1,42 +1,29 @@
package org.dromara.app.domain.bo;
import org.dromara.app.domain.AppDevice;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import org.dromara.common.core.validate.AddGroup;
import org.dromara.common.core.validate.EditGroup;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import lombok.EqualsAndHashCode;
import jakarta.validation.constraints.*;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import org.dromara.app.domain.AppDevice;
import org.dromara.common.core.validate.AddGroup;
import org.dromara.common.core.validate.EditGroup;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.Date;
/**
* 设备信息
业务对象 app_device
*
* @author Lion Li
* @date 2026-04-25
*/
@Data
@EqualsAndHashCode(callSuper = true)
@AutoMapper(target = AppDevice.class, reverseConvertGenerate = false)
public class AppDeviceBo extends BaseEntity {
/**
*
*/
@NotNull(message = "不能为空", groups = { EditGroup.class })
private Long id;
/**
* 设备编号
*/
@NotBlank(message = "设备编号不能为空", groups = { AddGroup.class, EditGroup.class })
private String deviceNo;
/**
* 用户id
*/
private String bindToken;
private String bindTokenHash;
@NotNull(message = "用户id不能为空", groups = { AddGroup.class, EditGroup.class })
private Long userId;
@@ -51,11 +38,12 @@ public class AppDeviceBo extends BaseEntity {
private String deviceImg;
/**
* 状态 1-工作中 0-离线 2-待机 3-故障
* 状态 1-在线 0-离线 2-到期 3-故障
*/
private String status;
/**
* 工作状态 1-工作中 0-休息
* 工作状态 0-休息 1-工作
*/
private String workStatus;
@@ -103,6 +91,4 @@ public class AppDeviceBo extends BaseEntity {
* 到期时间
*/
private Date expirationTime;
}

View File

@@ -1,22 +1,13 @@
package org.dromara.app.domain.bo;
import cn.hutool.json.JSON;
import org.dromara.app.domain.AppSchedule;
import org.dromara.app.domain.AppScheduleDetail;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import org.dromara.common.core.validate.AddGroup;
import org.dromara.common.core.validate.EditGroup;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import lombok.EqualsAndHashCode;
import jakarta.validation.constraints.*;
import java.time.LocalTime;
import java.util.Date;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonFormat;
/**
* 日程排列业务对象 app_schedule
@@ -45,7 +36,7 @@ public class AppScheduleBo extends BaseEntity {
*/
private String name;
private List<Long> deviceIds;
private List<String> deviceNos;
/**

View File

@@ -1,36 +1,24 @@
package org.dromara.app.domain.bo;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import org.dromara.common.core.validate.AddGroup;
import org.dromara.common.core.validate.EditGroup;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import lombok.EqualsAndHashCode;
import jakarta.validation.constraints.*;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.common.core.validate.AddGroup;
import org.dromara.common.core.validate.EditGroup;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* 排程与设备关联业务对象 app_scheduling_device
*
* @author Lion Li
* @date 2026-04-25
*/
@Data
@EqualsAndHashCode(callSuper = true)
@AutoMapper(target = AppSchedulingDevice.class, reverseConvertGenerate = false)
public class AppSchedulingDeviceBo extends BaseEntity {
/**
* 排程id
*/
@NotNull(message = "排程id不能为空", groups = { AddGroup.class, EditGroup.class })
private Long scheduleId;
/**
* 设备id
*/
@NotNull(message = "设备id不能为空", groups = { AddGroup.class, EditGroup.class })
private Long deviceId;
@NotBlank(message = "设备编号不能为空", groups = { AddGroup.class, EditGroup.class })
private String deviceNo;
}

View File

@@ -36,10 +36,13 @@ public class AppWateringLogBo extends BaseEntity {
/**
* 设备ID
*/
private Long deviceId;
private String deviceNo;
/**
* 命令ID
*/
private String commandId;
/**
* 关联排程
*/

View File

@@ -0,0 +1,25 @@
package org.dromara.app.domain.mqtt;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
@Data
public class DeviceCommand implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String commandId;
private String deviceNo;
private String commandType;
private String topic;
private Map<String, Object> payload = new HashMap<>();
private int retryCount;
private long createdAt;
private long lastSentAt;
private long nextRetryAt;
}

View File

@@ -0,0 +1,18 @@
package org.dromara.app.domain.mqtt;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
@Data
public class DeviceCommandAck implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String commandId;
private String deviceNo;
private String status;
private String message;
}

View File

@@ -1,28 +1,15 @@
package org.dromara.app.domain.vo;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import org.dromara.app.domain.AppDevice;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import org.dromara.common.excel.annotation.ExcelDictFormat;
import org.dromara.common.excel.convert.ExcelDictConvert;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.dromara.app.domain.AppDevice;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* 设备信息
视图对象 app_device
*
* @author Lion Li
* @date 2026-04-25
*/
@Data
@ExcelIgnoreUnannotated
@AutoMapper(target = AppDevice.class)
@@ -31,98 +18,47 @@ public class AppDeviceVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
*
*/
@ExcelProperty(value = "")
private Long id;
/**
* 设备编号
*/
@ExcelProperty(value = "设备编号")
private String deviceNo;
/**
* 用户id
*/
@ExcelProperty(value = "用户id")
private Long userId;
/**
* 设备名称
*/
@ExcelProperty(value = "设备名称")
private String deviceName;
/**
* 设备图片
*/
@ExcelProperty(value = "设备图片")
private String deviceImg;
/**
* 状态 1-工作中 0-离线 2-待机 3-故障
*/
@ExcelProperty(value = "状态 1-工作中 0-离线 2-待机 3-故障")
@ExcelProperty(value = "状态")
private String status;
/**
* 工作状态 1-工作中 0-休息
*/
private String workStatus;
/**
* 电量
*/
@ExcelProperty(value = "电量")
private String powerLevel;
/**
* 电量更新时间
*/
@ExcelProperty(value = "电量更新时间")
private Date powerLevelUpdatatime;
/**
* WiFi名称
*/
@ExcelProperty(value = "WiFi名称")
private String wifiName;
/**
* WiFi密码
*/
@ExcelProperty(value = "WiFi密码")
private String wifiPassword;
/**
* 设备型号
*/
@ExcelProperty(value = "设备型号")
private String deviceEm;
/**
* 设备序列号
*/
@ExcelProperty(value = "设备序列号")
private String deviceSn;
/**
* 固件版本
*/
@ExcelProperty(value = "固件版本")
private String fwVer;
/**
* mac地址
*/
@ExcelProperty(value = "mac地址")
private String macAddress;
/**
* 到期时间
*/
@ExcelProperty(value = "到期时间")
private Date expirationTime;
}

View File

@@ -1,20 +1,14 @@
package org.dromara.app.domain.vo;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.NotEmpty;
import org.dromara.app.domain.AppSchedule;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import org.dromara.app.domain.bo.AppScheduleBo;
import org.dromara.common.excel.annotation.ExcelDictFormat;
import org.dromara.common.excel.convert.ExcelDictConvert;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
@@ -53,7 +47,7 @@ public class AppScheduleVo implements Serializable {
/**
* 设备ids
*/
private List deviceIds;
private List deviceNos;
/**
* 状态 0-关闭 1 开启
*/

View File

@@ -1,26 +1,14 @@
package org.dromara.app.domain.vo;
import org.dromara.app.domain.AppSchedule;
import org.dromara.app.domain.AppSchedulingDevice;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import org.dromara.common.excel.annotation.ExcelDictFormat;
import org.dromara.common.excel.convert.ExcelDictConvert;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.dromara.app.domain.AppSchedulingDevice;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* 排程与设备关联视图对象 app_scheduling_device
*
* @author Lion Li
* @date 2026-04-25
*/
@Data
@ExcelIgnoreUnannotated
@AutoMapper(target = AppSchedulingDevice.class)
@@ -29,18 +17,11 @@ public class AppSchedulingDeviceVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 排程id
*/
@ExcelProperty(value = "排程id")
private Long scheduleId;
/**
* 设备id
*/
@ExcelProperty(value = "设备id")
private Long deviceId;
@ExcelProperty(value = "设备编号")
private String deviceNo;
private AppScheduleVo schedule;
}

View File

@@ -46,9 +46,14 @@ public class AppWateringLogVo implements Serializable {
* 设备ID
*/
@ExcelProperty(value = "设备ID")
private Long deviceId;
private String deviceNo;
/**
* 命令ID
*/
@ExcelProperty(value = "命令ID")
private String commandId;
/**
* 关联排程
*/

View File

@@ -0,0 +1,35 @@
package org.dromara.app.handler;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IDeviceCommandAckHandler;
import org.springframework.stereotype.Component;
import java.util.regex.Pattern;
/**
* 设备命令 ACK 处理器 — 匹配 /{deviceNo}/publish/ack。
* 委托给 {@link IDeviceCommandAckHandler}(实际由 water-common-mqtt 模块的 MqttCommandAckService 实现)。
*/
@Slf4j
@Component
public class DeviceCommandAckHandler implements MqttTopicHandler {
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/ack$");
private final IDeviceCommandAckHandler delegate;
public DeviceCommandAckHandler(IDeviceCommandAckHandler delegate) {
this.delegate = delegate;
}
@Override
public Pattern topicPattern() {
return PATTERN;
}
@Override
public void handle(String deviceNo, String payload) {
delegate.handleAck(deviceNo, payload);
}
}

View File

@@ -2,25 +2,44 @@ package org.dromara.app.handler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.common.json.utils.JsonUtils;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 设备数据上报处理器 — 匹配 /{deviceNo}/publish/power
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DeviceDataHandler {
public class DeviceDataHandler implements MqttTopicHandler {
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/power$");
private final IAppDeviceService appDeviceService;
public void handle(String deviceId, String payload) {
@Override
public Pattern topicPattern() {
return PATTERN;
}
@Override
public void handle(String deviceNo, String payload) {
try {
// DeviceDataDTO dto = JsonUtils.parseObject(payload, DeviceDataDTO.class);
// dto.setDeviceId(deviceId);
// dto.setReportTime(LocalDateTime.now());
// deviceDataService.saveDeviceData(dto);
log.info("[MQTT] 设备数据入库 deviceId={}", deviceId);
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
AppDeviceBo appDeviceBo = new AppDeviceBo();
appDeviceBo.setDeviceNo(deviceNo);
appDeviceBo.setPowerLevel(dto.get("powerLevel").toString());
appDeviceBo.setPowerLevelUpdatatime(new Date());
appDeviceService.updateByBo(appDeviceBo);
log.info("[MQTT] 设备电量更新 deviceNo={} payload={}", deviceNo, payload);
} catch (Exception e) {
log.error("[MQTT] 设备数据解析失败 deviceId={} payload={}", deviceId, payload, e);
log.error("[MQTT] 设备电量更新失败 deviceNo={} payload={}", deviceNo, payload, e);
}
}
}

View File

@@ -0,0 +1,70 @@
package org.dromara.app.handler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.common.json.utils.JsonUtils;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Device registration handler, matches /{deviceNo}/publish/register.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DeviceRegisterHandler implements MqttTopicHandler {
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/register$");
private final IAppDeviceService appDeviceService;
@Override
public Pattern topicPattern() {
return PATTERN;
}
@Override
public void handle(String deviceNo, String payload) {
try {
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
AppDeviceBo device = buildRegisterDevice(deviceNo, dto);
appDeviceService.registerByMqtt(device);
log.info("[MQTT] 设备注册 deviceNo={} payload={}", deviceNo, payload);
} catch (Exception e) {
log.error("[MQTT] 设备注册失败 deviceNo={} payload={}", deviceNo, payload, e);
}
}
private AppDeviceBo buildRegisterDevice(String topicDeviceNo, Map<String, Object> dto) {
AppDeviceBo device = new AppDeviceBo();
device.setDeviceNo(topicDeviceNo);
if (dto == null) {
return device;
}
device.setDeviceName(valueAsString(dto.get("deviceName")));
device.setPowerLevel(valueAsString(dto.get("powerLevel")));
device.setDeviceEm(valueAsString(dto.get("deviceEm")));
device.setDeviceSn(valueAsString(dto.get("deviceSn")));
device.setMacAddress(firstNotBlank(dto.get("deviceMac"), dto.get("macAddress")));
device.setFwVer(firstNotBlank(dto.get("version"), dto.get("fwVer")));
return device;
}
private String firstNotBlank(Object first, Object second) {
String firstText = valueAsString(first);
if (firstText != null && !firstText.isBlank()) {
return firstText;
}
String secondText = valueAsString(second);
return secondText != null && !secondText.isBlank() ? secondText : null;
}
private String valueAsString(Object value) {
return value == null ? null : String.valueOf(value);
}
}

View File

@@ -1,29 +0,0 @@
package org.dromara.app.handler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.json.utils.JsonUtils;
import org.springframework.stereotype.Component;
import java.util.Map;
//设备在线状态
@Slf4j
@Component
@RequiredArgsConstructor
public class DeviceStatusHandler {
public void handle(String deviceNo, String payload) {
try {
Map<String,Object> dto = JsonUtils.parseObject(payload, Map.class);
// 更新设备在线状态
// log.info("[MQTT] 设备状态更新 deviceId={} status={}", deviceId, dto.getStatus());
} catch (Exception e) {
log.error("[MQTT] 设备状态处理失败 deviceId={}", deviceNo, e);
}
}
}

View File

@@ -2,25 +2,35 @@ package org.dromara.app.handler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.common.json.utils.JsonUtils;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.regex.Pattern;
//设备推送异常告警入库
/**
* 设备异常告警处理器 — 匹配 /{deviceNo}/publish/error
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ErromesHandler {
public class ErromesHandler implements MqttTopicHandler {
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/error$");
@Override
public Pattern topicPattern() {
return PATTERN;
}
@Override
public void handle(String deviceNo, String payload) {
try {
Map<String,Object> dto = JsonUtils.parseObject(payload, Map.class);
log.info("[MQTT] 设备状态发送异常 deviceNo={}", deviceNo);
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
log.info("[MQTT] 设备异常告警 deviceNo={} payload={}", deviceNo, dto);
} catch (Exception e) {
log.error("[MQTT] 设备状态处理失败 deviceNo={}", deviceNo, e);
log.error("[MQTT] 设备异常处理失败 deviceNo={}", deviceNo, e);
}
}
}

View File

@@ -0,0 +1,150 @@
package org.dromara.app.handler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.springframework.stereotype.Component;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 按键浇水完成处理器,匹配 /{deviceNo}/publish/finish/key。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class KeyFinishHandler implements MqttTopicHandler {
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/finish/key$");
private final IAppWateringLogService appWateringLogService;
private final IAppDeviceService appDeviceService;
@Override
public Pattern topicPattern() {
return PATTERN;
}
@Override
public void handle(String deviceNo, String payload) {
try {
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
AppWateringLogBo logBo = buildWateringLog(deviceNo, dto);
appWateringLogService.insertByBo(logBo);
log.info("[MQTT] 按键浇水完成上报已保存 deviceNo={} payload={}", deviceNo, payload);
} catch (Exception e) {
log.error("[MQTT] 按键浇水完成上报处理失败 deviceNo={} payload={}", deviceNo, payload, e);
}
}
private AppWateringLogBo buildWateringLog(String topicDeviceNo, Map<String, Object> dto) {
String payloadDeviceNo = valueAsString(dto.get("deviceNo"));
if (StringUtils.isNotBlank(payloadDeviceNo) && !topicDeviceNo.equals(payloadDeviceNo)) {
log.warn("[MQTT] 按键浇水完成上报设备编号不一致 topicDeviceNo={} payloadDeviceNo={}", topicDeviceNo, payloadDeviceNo);
}
Date receivedAt = new Date();
Date startTime = parseStartTime(valueAsString(dto.get("startTime")));
Date plannedEndTime = resolvePlannedEndTime(
startTime,
parseOptionalTime(valueAsString(dto.get("endTime"))),
valueAsInteger(dto.get("durationMin"))
);
Date endTime = resolveActualEndTime(receivedAt, plannedEndTime);
Integer durationMin = calculateDurationMin(startTime, endTime);
AppWateringLogBo logBo = new AppWateringLogBo();
logBo.setDeviceNo(topicDeviceNo);
logBo.setCommandId(valueAsString(dto.get("commandId")));
logBo.setUserId(queryUserId(topicDeviceNo));
logBo.setScheduleId(0L);
logBo.setStartTime(startTime);
logBo.setDurationMin(durationMin == null ? null : String.valueOf(durationMin));
logBo.setTriggerType("1");
if ("schedule".equals(valueAsString(dto.get("triggerON")))){
logBo.setTriggerType("0");
}
logBo.setCreateTime(receivedAt);
logBo.setEndTime(endTime);
return logBo;
}
private Long queryUserId(String deviceNo) {
AppDeviceVo device = appDeviceService.queryById(deviceNo);
return device == null ? null : device.getUserId();
}
private Date parseStartTime(String startTime) {
if (StringUtils.isBlank(startTime)) {
return new Date();
}
for (String pattern : new String[]{"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm"}) {
try {
SimpleDateFormat format = new SimpleDateFormat(pattern);
format.setLenient(false);
return format.parse(startTime);
} catch (ParseException ignored) {
// Try the next supported format.
}
}
throw new IllegalArgumentException("Unsupported startTime format: " + startTime);
}
private Date parseOptionalTime(String time) {
if (StringUtils.isBlank(time)) {
return null;
}
return parseStartTime(time);
}
private Date resolvePlannedEndTime(Date startTime, Date payloadEndTime, Integer durationMin) {
if (startTime != null && durationMin != null && durationMin > 0) {
return new Date(startTime.getTime() + durationMin * 60000L);
}
return payloadEndTime;
}
private Date resolveActualEndTime(Date receivedAt, Date plannedEndTime) {
if (plannedEndTime == null) {
return receivedAt;
}
return receivedAt.before(plannedEndTime) ? receivedAt : plannedEndTime;
}
private Integer calculateDurationMin(Date startTime, Date endTime) {
if (startTime == null || endTime == null) {
return null;
}
long diffMillis = endTime.getTime() - startTime.getTime();
if (diffMillis < 0) {
log.warn("[MQTT] 按键浇水完成上报结束时间早于开始时间 startTime={} endTime={}", startTime, endTime);
return 0;
}
return (int) (diffMillis / 60000L);
}
private String valueAsString(Object value) {
return value == null ? null : String.valueOf(value);
}
private Integer valueAsInteger(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.intValue();
}
String text = String.valueOf(value);
return StringUtils.isBlank(text) ? null : Integer.valueOf(text);
}
}

View File

@@ -0,0 +1,172 @@
package org.dromara.app.handler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.springframework.stereotype.Component;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 排程浇水完成处理器,匹配 /{deviceNo}/publish/finish/schedule。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ScheduleFinishHandler implements MqttTopicHandler {
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/finish/schedule$");
private final IAppWateringLogService appWateringLogService;
private final IAppDeviceService appDeviceService;
private final AppSchedulingDeviceMapper schedulingDeviceMapper;
@Override
public Pattern topicPattern() {
return PATTERN;
}
@Override
public void handle(String deviceNo, String payload) {
try {
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
AppWateringLogBo logBo = buildWateringLog(deviceNo, dto);
appWateringLogService.insertByBo(logBo);
log.info("[MQTT] 浇水排程上报 saved deviceNo={} payload={}", deviceNo, payload);
} catch (Exception e) {
log.error("[MQTT] 浇水排程上报 failed deviceNo={} payload={}", deviceNo, payload, e);
}
}
private AppWateringLogBo buildWateringLog(String topicDeviceNo, Map<String, Object> dto) {
String payloadDeviceNo = valueAsString(dto.get("deviceNo"));
if (StringUtils.isNotBlank(payloadDeviceNo) && !topicDeviceNo.equals(payloadDeviceNo)) {
log.warn("[MQTT] 排程完成上报设备编号不一致 topicDeviceNo={} payloadDeviceNo={}", topicDeviceNo, payloadDeviceNo);
}
Date receivedAt = new Date();
Date startTime = parseStartTime(valueAsString(dto.get("startTime")));
Date plannedEndTime = resolvePlannedEndTime(
startTime,
parseOptionalTime(valueAsString(dto.get("endTime"))),
valueAsInteger(dto.get("durationMin"))
);
Date endTime = resolveActualEndTime(receivedAt, plannedEndTime);
Integer durationMin = calculateDurationMin(startTime, endTime);
AppWateringLogBo logBo = new AppWateringLogBo();
logBo.setDeviceNo(topicDeviceNo);
logBo.setCommandId(valueAsString(dto.get("commandId")));
logBo.setUserId(queryUserId(topicDeviceNo));
logBo.setScheduleId(queryScheduleId(topicDeviceNo));
logBo.setStartTime(startTime);
logBo.setDurationMin(durationMin == null ? null : String.valueOf(durationMin));
logBo.setTriggerType("0");
if ("mqtt on".equals(valueAsString(dto.get("triggerON")))){
logBo.setTriggerType("1");
}
logBo.setCreateTime(receivedAt);
logBo.setEndTime(endTime);
return logBo;
}
private Long queryUserId(String deviceNo) {
AppDeviceVo device = appDeviceService.queryById(deviceNo);
return device == null ? null : device.getUserId();
}
private Long queryScheduleId(String deviceNo) {
List<AppSchedulingDevice> schedulingDevices = schedulingDeviceMapper.selectList(
Wrappers.<AppSchedulingDevice>lambdaQuery()
.eq(AppSchedulingDevice::getDeviceNo, deviceNo)
);
if (schedulingDevices == null || schedulingDevices.isEmpty()) {
log.warn("[MQTT] 排程完成上报未找到设备绑定的排程 deviceNo={}", deviceNo);
return 0L;
}
if (schedulingDevices.size() > 1) {
log.warn("[MQTT] 排程完成上报匹配到多个排程 deviceNo={} count={}", deviceNo, schedulingDevices.size());
}
return schedulingDevices.get(0).getScheduleId();
}
private Date parseStartTime(String startTime) {
if (StringUtils.isBlank(startTime)) {
return new Date();
}
for (String pattern : new String[]{"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm"}) {
try {
SimpleDateFormat format = new SimpleDateFormat(pattern);
format.setLenient(false);
return format.parse(startTime);
} catch (ParseException ignored) {
// Try the next supported format.
}
}
throw new IllegalArgumentException("Unsupported startTime format: " + startTime);
}
private Date parseOptionalTime(String time) {
if (StringUtils.isBlank(time)) {
return null;
}
return parseStartTime(time);
}
private Date resolvePlannedEndTime(Date startTime, Date payloadEndTime, Integer durationMin) {
if (startTime != null && durationMin != null && durationMin > 0) {
return new Date(startTime.getTime() + durationMin * 60000L);
}
return payloadEndTime;
}
private Date resolveActualEndTime(Date receivedAt, Date plannedEndTime) {
if (plannedEndTime == null) {
return receivedAt;
}
return receivedAt.before(plannedEndTime) ? receivedAt : plannedEndTime;
}
private Integer calculateDurationMin(Date startTime, Date endTime) {
if (startTime == null || endTime == null) {
return null;
}
long diffMillis = endTime.getTime() - startTime.getTime();
if (diffMillis < 0) {
log.warn("[MQTT] 排程完成上报结束时间早于开始时间 startTime={} endTime={}", startTime, endTime);
return 0;
}
return (int) (diffMillis / 60000L);
}
private String valueAsString(Object value) {
return value == null ? null : String.valueOf(value);
}
private Integer valueAsInteger(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.intValue();
}
String text = String.valueOf(value);
return StringUtils.isBlank(text) ? null : Integer.valueOf(text);
}
}

View File

@@ -5,6 +5,8 @@ import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.common.mybatis.annotation.DataColumn;
import org.dromara.common.mybatis.annotation.DataPermission;
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/**
* 设备信息
@@ -15,4 +17,36 @@ Mapper接口
*/
public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo> {
@Update("""
<script>
update app_device
<set>
user_id = #{userId},
bind_token_hash = null,
<if test="deviceName != null and deviceName != ''">
device_name = #{deviceName},
</if>
<if test="status != null and status != ''">
status = #{status},
</if>
</set>
where device_no = #{deviceNo}
and (user_id is null or user_id = #{userId})
</script>
""")
int bindIfAvailable(@Param("deviceNo") String deviceNo,
@Param("userId") Long userId,
@Param("deviceName") String deviceName,
@Param("status") String status);
@Update("""
update app_device
set work_status = #{workStatus}
where device_no = #{deviceNo}
and user_id = #{userId}
""")
int updateWorkStatusByUser(@Param("deviceNo") String deviceNo,
@Param("userId") Long userId,
@Param("workStatus") String workStatus);
}

View File

@@ -1,42 +1,49 @@
package org.dromara.app.mqtt;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.handler.DeviceDataHandler;
import org.dromara.app.handler.DeviceStatusHandler;
import org.dromara.app.handler.ErromesHandler;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.regex.Matcher;
/**
* MQTT 上行消息分发器(策略模式)。
* <p>
* 启动时自动收集所有 {@link MqttTopicHandler} Bean按 Topic 正则匹配分发。
* 新增 Topic 只需新增 Handler 实现类,零改动此处。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MqttMessageDispatcher {
private final DeviceDataHandler dataHandler;
private final DeviceStatusHandler statusHandler;
private final ErromesHandler erroHandler;
// 路由规则:匹配 topic 分发到对应 Handler
private static final String DATA_PATTERN = "/water/([^/]+)/data";
private static final String STATUS_PATTERN = "/water/([^/]+)/status";
private static final String ERROMES_PATTERN = "/water/([^/]+)/erromes";
private final List<MqttTopicHandler> handlers;
public MqttMessageDispatcher(List<MqttTopicHandler> handlers) {
this.handlers = handlers;
log.info("[MQTT] dispatcher initialized with {} handlers", handlers.size());
for (MqttTopicHandler handler : handlers) {
log.info("[MQTT] └ registered: {} -> {}", handler.getClass().getSimpleName(), handler.topicPattern());
}
}
/**
* 分发消息到匹配的处理器。
*
* @param topic MQTT Topic
* @param payload 消息体
*/
public void dispatch(String topic, String payload) {
log.debug("[MQTT] 收到消息 topic={} 内容: {}", topic,payload);
if (topic.matches(DATA_PATTERN)) {
String deviceNo = extract(topic, DATA_PATTERN);
dataHandler.handle(deviceNo, payload);
} else if (topic.matches(STATUS_PATTERN)) {
String deviceNo = extract(topic, STATUS_PATTERN);
statusHandler.handle(deviceNo, payload);
}else if (topic.matches(ERROMES_PATTERN)) {
String deviceNo = extract(topic, STATUS_PATTERN);
erroHandler.handle(deviceNo, payload);
} else {
log.warn("[MQTT] 未知 topic: {} 内容: {}", topic,payload);
log.debug("[MQTT] received topic={} payload={}", topic, payload);
for (MqttTopicHandler handler : handlers) {
Matcher matcher = handler.topicPattern().matcher(topic);
if (matcher.matches()) {
String deviceNo = matcher.group(1);
handler.handle(deviceNo, payload);
return;
}
}
private String extract(String topic, String pattern) {
return topic.replaceAll(pattern, "$1");
log.warn("[MQTT] no handler for topic: {} payload={}", topic, payload);
}
}

View File

@@ -0,0 +1,28 @@
package org.dromara.app.mqtt;
import java.util.regex.Pattern;
/**
* MQTT 消息 Topic 处理器策略接口。
* <p>
* 所有上行消息处理器实现此接口,由 {@link MqttMessageDispatcher} 自动发现并路由。
* 新增 Topic 只需新增实现类并注册为 Spring Bean无需修改 Dispatcher。
*
* @author water team
*/
public interface MqttTopicHandler {
/**
* 返回该处理器匹配的 Topic 正则。
* 必须包含一个捕获组用于提取 deviceNo。
*/
Pattern topicPattern();
/**
* 处理匹配到的消息。
*
* @param deviceNo 从 Topic 正则中提取的设备编号
* @param payload 消息体JSON
*/
void handle(String deviceNo, String payload);
}

View File

@@ -27,7 +27,7 @@ public interface IAppDeviceService {
* @return 设备信息
*/
AppDeviceVo queryById(Long id);
AppDeviceVo queryById(String deviceNo);
/**
* 分页查询设备信息
@@ -70,13 +70,31 @@ public interface IAppDeviceService {
*/
Boolean updateByBo(AppDeviceBo bo);
/**
* MQTT设备注册或刷新设备基础信息
*
* @param bo 设备信息
* @return 是否保存成功
*/
Boolean registerByMqtt(AppDeviceBo bo);
/**
* 将已注册设备绑定到当前登录用户
*
* @param bo 设备信息
* @return 设备信息
*/
AppDeviceVo bindRegisteredDevice(AppDeviceBo bo);
Boolean switchDevice(String deviceNo, String workStatus, String startTime, Integer durationMin);
/**
* 校验并批量删除设备信息
信息
*
* @param ids 待删除的主键集合
* @param deviceNos 待删除的主键集合
* @param isValid 是否进行有效性校验
* @return 是否删除成功
*/
Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
Boolean deleteWithValidByIds(Collection<String> deviceNos, Boolean isValid);
}

View File

@@ -1,82 +1,31 @@
package org.dromara.app.service;
import jakarta.validation.constraints.NotEmpty;
import org.dromara.app.domain.vo.AppSchedulingDeviceVo;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.app.domain.vo.AppSchedulingDeviceVo;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import java.util.Collection;
import java.util.List;
/**
* 排程与设备关联Service接口
*
* @author Lion Li
* @date 2026-04-25
*/
public interface IAppSchedulingDeviceService {
/**
* 查询排程与设备关联
*
* @param schedulingId 主键
* @return 排程与设备关联
*/
AppSchedulingDeviceVo queryById(Long schedulingId);
/**
* 分页查询排程与设备关联列表
*
* @param bo 查询条件
* @param pageQuery 分页参数
* @return 排程与设备关联分页列表
*/
TableDataInfo<AppSchedulingDeviceVo> queryPageList(AppSchedulingDeviceBo bo, PageQuery pageQuery);
/**
* 查询符合条件的排程与设备关联列表
*
* @param bo 查询条件
* @return 排程与设备关联列表
*/
List<AppSchedulingDeviceVo> queryList(AppSchedulingDeviceBo bo);
/**
* 新增排程与设备关联
*
* @param bo 排程与设备关联
* @return 是否新增成功
*/
Boolean insertByBo(AppSchedulingDeviceBo bo);
/**
* 修改排程与设备关联
*
* @param bo 排程与设备关联
* @return 是否修改成功
*/
Boolean updateByBo(AppSchedulingDeviceBo bo);
/**
* 校验并批量删除排程与设备关联信息
*
* @param ids 待删除的主键集合
* @param isValid 是否进行有效性校验
* @return 是否删除成功
*/
Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
/**
* 查找某一设备的所有日程
* @param deviceId
* @return
*/
List<AppSchedulingDeviceVo> findByDeviceId(Long deviceId);
List<AppSchedulingDeviceVo> findByDeviceNo(String deviceNo);
Boolean deleteWithValidByschedulingIds2Ids(@NotEmpty(message = "主键不能为空") Long schedulingId, Long deviceId);
Boolean deleteWithValidByScheduleIdAndDeviceNo(@NotEmpty(message = "主键不能为空") Long scheduleId, String deviceNo);
AppSchedulingDeviceVo queryById2DeviveId(Long scheduleId, Long id);
List<AppSchedulingDeviceVo> queryByDeviveId(Long id);
AppSchedulingDeviceVo queryByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo);
}

View File

@@ -6,6 +6,7 @@ import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.mybatis.core.page.PageQuery;
import java.util.Collection;
import java.util.Date;
import java.util.List;
/**
@@ -57,6 +58,16 @@ public interface IAppWateringLogService {
*/
Boolean updateByBo(AppWateringLogBo bo);
/**
* 结束当前手动浇水记录。
*
* @param deviceNo 设备编号
* @param userId 用户ID
* @param endTime 结束时间
* @return 是否更新成功
*/
Boolean finishManualLog(String deviceNo, Long userId, Date endTime);
/**
* 校验并批量删除浇水记录信息
*

View File

@@ -0,0 +1,6 @@
package org.dromara.app.service;
public interface IDeviceCommandAckHandler {
void handleAck(String deviceNo, String payload);
}

View File

@@ -0,0 +1,8 @@
package org.dromara.app.service;
import org.dromara.app.domain.mqtt.DeviceCommand;
public interface IDeviceCommandPublisher {
String send(DeviceCommand command);
}

View File

@@ -0,0 +1,45 @@
package org.dromara.app.service;
import org.dromara.app.domain.mqtt.DeviceCommand;
import java.util.Map;
/**
* 设备命令下发业务层接口。
* <p>
* 封装命令构造、设备校验、下发与日志记录,避免各业务方直接拼装 {@link DeviceCommand}。
*
* @author water team
*/
public interface IDeviceCommandService {
/**
* 通用命令下发。
*
* @param command 命令对象commandId 为空时自动生成)
* @return commandId
*/
String sendCommand(DeviceCommand command);
/**
* 下发设备开关/浇水命令。
*
* @param deviceNo 设备编号
* @param workStatus 目标工作状态
* @param startTime 开始时间(可为 null
* @param durationMin 持续时间(分钟)
* @return commandId
*/
String sendSwitchCommand(String deviceNo, String workStatus, String startTime, Integer durationMin);
/**
* 下发自定义命令。
*
* @param deviceNo 设备编号
* @param commandType 命令类型
* @param extra 额外参数
* @return commandId
*/
String sendCustomCommand(String deviceNo, String commandType, Map<String, Object> extra);
String sendBindDeviceCommand(String deviceNo);
}

View File

@@ -1,39 +1,40 @@
package org.dromara.app.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.common.core.utils.MapstructUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mybatis.annotation.DataColumn;
import org.dromara.common.mybatis.annotation.DataPermission;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.mybatis.core.page.PageQuery;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.stereotype.Service;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.AppWateringLog;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mapper.AppWateringLogMapper;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.MapstructUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Collection;
/**
* 设备信息
Service业务层处理
*
* @author Lion Li
* @date 2026-04-25
*/
@Slf4j
@RequiredArgsConstructor
@Service
@@ -41,29 +42,19 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
private final AppDeviceMapper baseMapper;
private final AppSchedulingDeviceMapper schedulingDeviceMapper;
private final AppWateringLogMapper wateringLogMapper;
/**
* 查询设备信息
// @Lazy 打破 AppDeviceServiceImpl ↔ DeviceCommandServiceImpl 双向构造器循环
@Lazy
@Autowired
private IDeviceCommandService deviceCommandService;
private final IAppWateringLogService wateringLogService;
*
* @param id 主键
* @return 设备信息
*/
@Override
public AppDeviceVo queryById(Long id){
return baseMapper.selectVoById(id);
public AppDeviceVo queryById(String deviceNo) {
return baseMapper.selectVoById(deviceNo);
}
/**
* 分页查询设备信息
列表
*
* @param bo 查询条件
* @param pageQuery 分页参数
* @return 设备信息
分页列表
*/
@Override
public TableDataInfo<AppDeviceVo> queryPageList(AppDeviceBo bo, PageQuery pageQuery) {
LambdaQueryWrapper<AppDevice> lqw = buildQueryWrapper(bo);
@@ -71,14 +62,6 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
return TableDataInfo.build(result);
}
/**
* 查询符合条件的设备信息
列表
*
* @param bo 查询条件
* @return 设备信息
列表
*/
@Override
public List<AppDeviceVo> queryList(AppDeviceBo bo) {
LambdaQueryWrapper<AppDevice> lqw = buildQueryWrapper(bo);
@@ -88,7 +71,7 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
private LambdaQueryWrapper<AppDevice> buildQueryWrapper(AppDeviceBo bo) {
Map<String, Object> params = bo.getParams();
LambdaQueryWrapper<AppDevice> lqw = Wrappers.lambdaQuery();
lqw.orderByAsc(AppDevice::getId);
lqw.orderByAsc(AppDevice::getDeviceNo);
lqw.eq(StringUtils.isNotBlank(bo.getDeviceNo()), AppDevice::getDeviceNo, bo.getDeviceNo());
lqw.eq(bo.getUserId() != null, AppDevice::getUserId, bo.getUserId());
lqw.like(StringUtils.isNotBlank(bo.getDeviceName()), AppDevice::getDeviceName, bo.getDeviceName());
@@ -106,67 +89,127 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
return lqw;
}
/**
* 新增设备信息
*
* @param bo 设备信息
* @return 是否新增成功
*/
@Override
public Boolean insertByBo(AppDeviceBo bo) {
// fillBindTokenHash(bo);
AppDevice add = MapstructUtils.convert(bo, AppDevice.class);
validEntityBeforeSave(add);
boolean flag = baseMapper.insert(add) > 0;
if (flag) {
bo.setId(add.getId());
}
return flag;
return baseMapper.insert(add) > 0;
}
/**
* 修改设备信息
*
* @param bo 设备信息
* @return 是否修改成功
*/
@Override
public Boolean updateByBo(AppDeviceBo bo) {
// fillBindTokenHash(bo);
AppDevice update = MapstructUtils.convert(bo, AppDevice.class);
validEntityBeforeSave(update);
return baseMapper.updateById(update) > 0;
}
/**
* 保存前的数据校验
*/
private void validEntityBeforeSave(AppDevice entity){
//TODO 做一些数据校验,如唯一约束
@Override
public Boolean registerByMqtt(AppDeviceBo bo) {
if (StringUtils.isBlank(bo.getDeviceNo())) {
throw new ServiceException("设备编号不能为空");
}
AppDevice exists = baseMapper.selectById(bo.getDeviceNo());
AppDevice device = MapstructUtils.convert(bo, AppDevice.class);
device.setPowerLevelUpdatatime(new Date());
// applyRegisterBindToken(device, bo, exists);
if (StringUtils.isBlank(device.getStatus())) {
device.setStatus("2");
device.setWorkStatus("2");
}
if (exists == null) {
try {
return baseMapper.insert(device) > 0;
} catch (org.springframework.dao.DuplicateKeyException ignored) {
return baseMapper.updateById(device) > 0;
}
}
return baseMapper.updateById(device) > 0;
}
@Override
public AppDeviceVo bindRegisteredDevice(AppDeviceBo bo) {
if (StringUtils.isBlank(bo.getDeviceNo())) {
throw new ServiceException("设备编号不能为空");
}
Long userId = LoginHelper.getUserId();
AppDevice exists = baseMapper.selectById(bo.getDeviceNo());
if (exists == null) {
throw new ServiceException("设备未上线注册,请先完成配网");
}
if (exists.getUserId() != null && !exists.getUserId().equals(userId)) {
throw new ServiceException("设备已被其他用户绑定");
}
// validBindToken(bo.getBindToken(), exists);
String status = StringUtils.isBlank(bo.getStatus()) ? exists.getStatus() : bo.getStatus();
String deviceName = StringUtils.isBlank(bo.getDeviceName()) ? exists.getDeviceName() : bo.getDeviceName();
int rows = baseMapper.bindIfAvailable(exists.getDeviceNo(), userId, deviceName, status);
if (rows <= 0) {
AppDevice current = baseMapper.selectById(bo.getDeviceNo());
if (current == null) {
throw new ServiceException("设备未上线注册,请先完成配网");
}
if (current.getUserId() != null && !current.getUserId().equals(userId)) {
throw new ServiceException("设备已被其他用户绑定");
}
throw new ServiceException("设备绑定失败,请重试");
}
//下发设备已绑定状态
deviceCommandService.sendBindDeviceCommand(exists.getDeviceNo());
return baseMapper.selectVoById(exists.getDeviceNo());
}
@Override
@Transactional(rollbackFor = Exception.class)
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public Boolean switchDevice(String deviceNo, String workStatus, String startTime, Integer durationMin) {
if (StringUtils.isBlank(deviceNo)) {
throw new ServiceException("设备编号不能为空");
}
if (StringUtils.isBlank(workStatus)) {
throw new ServiceException("设备工作状态不能为空");
}
if ("1".equals(workStatus) && (durationMin == null || durationMin <= 0)) {
throw new ServiceException("持续时间必须大于0");
}
Long userId = LoginHelper.getUserId();
int rows = baseMapper.updateWorkStatusByUser(deviceNo, userId, workStatus);
if (rows <= 0) {
throw new ServiceException("设备不存在或无权操作");
}
AppDeviceVo device = baseMapper.selectVoById(deviceNo);
if (ObjectUtil.isNotNull(device) && StringUtils.isNotBlank(device.getDeviceNo())) {
// 通过业务层统一下发命令(含浇水日志记录)
String commandId = deviceCommandService.sendSwitchCommand(deviceNo, workStatus, startTime, durationMin);
log.info("[DEVICE] switch command sent deviceNo={} commandId={}", deviceNo, commandId);
}
return true;
}
private void validEntityBeforeSave(AppDevice entity) {
if (StringUtils.isBlank(entity.getDeviceNo())) {
throw new ServiceException("设备编号不能为空");
}
}
/**
* 校验并批量删除设备信息
信息
*
* @param ids 待删除的主键集合
* @param isValid 是否进行有效性校验
* @return 是否删除成功
*/
@Override
@Transactional
public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
if(isValid){
//TODO 做一些业务上的校验,判断是否需要校验
}
boolean flag = baseMapper.deleteByIds(ids) > 0;
if (flag){
for (Long id :ids){
schedulingDeviceMapper.delete(new QueryWrapper<AppSchedulingDevice>().eq("device_id",id));
}
public Boolean deleteWithValidByIds(Collection<String> deviceNos, Boolean isValid) {
boolean flag = baseMapper.deleteByIds(deviceNos) > 0;
if (flag) {
schedulingDeviceMapper.delete(
new QueryWrapper<AppSchedulingDevice>().in("device_no", deviceNos)
);
wateringLogMapper.delete(new QueryWrapper<AppWateringLog>().in("device_no", deviceNos));
}
return flag;
}

View File

@@ -1,43 +1,36 @@
package org.dromara.app.service.impl;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppSchedule;
import org.dromara.app.domain.AppScheduleDetail;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.domain.vo.AppScheduleDetailVo;
import org.dromara.app.domain.vo.AppSchedulingDeviceVo;
import org.dromara.app.domain.bo.AppScheduleBo;
import org.dromara.app.domain.vo.AppScheduleVo;
import org.dromara.app.mapper.AppScheduleDetailMapper;
import org.dromara.app.mapper.AppScheduleMapper;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.service.IAppScheduleService;
import org.dromara.common.core.utils.MapstructUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.mybatis.core.page.PageQuery;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.stereotype.Service;
import org.dromara.app.domain.bo.AppScheduleBo;
import org.dromara.app.domain.vo.AppScheduleVo;
import org.dromara.app.domain.AppScheduleDetail;
import org.dromara.app.domain.AppSchedule;
import org.dromara.app.mapper.AppScheduleMapper;
import org.dromara.app.service.IAppScheduleService;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 日程排列Service业务层处理
*
* @author Lion Li
* @date 2026-04-25
*/
@Slf4j
@RequiredArgsConstructor
@Service
@@ -47,24 +40,11 @@ public class AppScheduleServiceImpl implements IAppScheduleService {
private final AppScheduleDetailMapper scheduleDetailMapper;
private final AppSchedulingDeviceMapper schedulingDeviceMapper;
/**
* 查询日程排列
*
* @param id 主键
* @return 日程排列
*/
@Override
public AppScheduleVo queryById(Long id){
public AppScheduleVo queryById(Long id) {
return baseMapper.selectVoById(id);
}
/**
* 分页查询日程排列列表
*
* @param bo 查询条件
* @param pageQuery 分页参数
* @return 日程排列分页列表
*/
@Override
public TableDataInfo<AppScheduleVo> queryPageList(AppScheduleBo bo, PageQuery pageQuery) {
LambdaQueryWrapper<AppSchedule> lqw = buildQueryWrapper(bo);
@@ -72,12 +52,6 @@ public class AppScheduleServiceImpl implements IAppScheduleService {
return TableDataInfo.build(result);
}
/**
* 查询符合条件的日程排列列表
*
* @param bo 查询条件
* @return 日程排列列表
*/
@Override
public List<AppScheduleVo> queryList(AppScheduleBo bo) {
LambdaQueryWrapper<AppSchedule> lqw = buildQueryWrapper(bo);
@@ -94,197 +68,143 @@ public class AppScheduleServiceImpl implements IAppScheduleService {
return lqw;
}
/**
* 新增日程排列
*
* @param bo 日程排列
* @return 是否新增成功
*/
@Override
@Transactional
public AppScheduleVo insertByBo(AppScheduleBo bo) {
AppSchedule add = MapstructUtils.convert(bo, AppSchedule.class);
add.setUserId(LoginHelper.getUserId());
validEntityBeforeSave(add);
//创建排程
boolean flag = baseMapper.insert(add) > 0;
if (flag){
if (!flag) {
return new AppScheduleVo();
}
AppScheduleVo vo = MapstructUtils.convert(add, AppScheduleVo.class);
vo.setDetails(saveDetails(add.getId(), bo.getDetails()));
return vo;
}
@Override
public Boolean updateStatusByBo(AppScheduleBo bo) {
AppSchedule update = MapstructUtils.convert(bo, AppSchedule.class);
return baseMapper.updateById(update) > 0;
}
@Override
@Transactional
public Map<String, Object> updateByBo(AppScheduleBo bo) {
AppSchedule update = MapstructUtils.convert(bo, AppSchedule.class);
validEntityBeforeSave(update);
Map<String, Object> repMap = new HashMap<>();
Boolean flag = baseMapper.updateById(update) > 0;
repMap.put("flag", flag);
repMap.put("list", new ArrayList<>());
repMap.put("appSchedule", "");
if (!flag) {
return repMap;
}
AppScheduleVo appScheduleVo = baseMapper.selectVoById(update.getId());
appScheduleVo.setDetails(updateDetails(bo.getDetails()));
repMap.put("appSchedule", appScheduleVo);
return repMap;
}
private void validEntityBeforeSave(AppSchedule entity) {
// TODO business validation
}
@Override
@Transactional
public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
boolean flag = baseMapper.deleteByIds(ids) > 0;
if (flag) {
for (Long id : ids) {
schedulingDeviceMapper.delete(new QueryWrapper<AppSchedulingDevice>().eq("schedule_id", id));
scheduleDetailMapper.delete(new QueryWrapper<AppScheduleDetail>().eq("schedule_id", id));
}
}
return flag;
}
public List checkConflict(String deviceNo, AppScheduleVo newSchedule) {
List<AppSchedulingDevice> existingBindings = schedulingDeviceMapper.selectList(
new QueryWrapper<AppSchedulingDevice>().eq("device_no", deviceNo)
);
List conflicts = new ArrayList<>();
List<AppScheduleDetail> scheduleDetails = scheduleDetailMapper.selectList(
new QueryWrapper<AppScheduleDetail>().eq("schedule_id", newSchedule.getId())
);
for (AppScheduleDetail newDay : scheduleDetails) {
for (AppSchedulingDevice existing : existingBindings) {
AppScheduleVo appScheduleVo = baseMapper.selectVoById(existing.getScheduleId());
if (ObjectUtil.isNotNull(appScheduleVo) && !"0".equals(appScheduleVo.getStatus())) {
List<AppScheduleDetail> details = scheduleDetailMapper.selectList(
new QueryWrapper<AppScheduleDetail>().eq("schedule_id", existing.getScheduleId())
);
for (AppScheduleDetail existingDay : details) {
if (newDay.getWeekday().equals(existingDay.getWeekday())) {
// TODO time overlap validation
}
}
}
}
}
return conflicts;
}
private List saveDetails(Long scheduleId, List<AppScheduleBo.AppScheduleDetail> details) {
List list = new ArrayList<>();
//创建详情
for (AppScheduleBo.AppScheduleDetail item:bo.getDetails()){
if (details == null) {
return list;
}
for (AppScheduleBo.AppScheduleDetail item : details) {
AppScheduleDetail detail = new AppScheduleDetail();
detail.setScheduleId(add.getId());
detail.setScheduleId(scheduleId);
detail.setWeekday(item.getWeekday());
if (ObjectUtil.isNotNull(item.getTimeData())){
if (ObjectUtil.isNotNull(item.getTimeData())) {
detail.setTimeData(JsonUtils.toJsonString(item.getTimeData()));
// detail.setEndTime(item.getStartTime().plusMinutes(item.getDurationMin()));
}
detail.setZones(item.getZones());
detail.setTriggerType(item.getTriggerType());
detail.setStatus(item.getStatus());
scheduleDetailMapper.insert(detail);
List timeSlots = new ArrayList();
for (AppScheduleBo.TimeSlot timeSlot:item.getTimeData()){
timeSlots.add(timeSlot);
list.add(toDetailMap(detail, item.getTimeData()));
}
Map map = new HashMap();
map.put("id",detail.getId() );
map.put("weekday",detail.getWeekday() );
map.put("timeData",timeSlots );
map.put("triggerType",detail.getTriggerType() );
map.put("status",detail.getStatus() );
map.put("createTime",detail.getCreateTime() );
list.add(map);
}
vo.setDetails(list);
return vo;
return list;
}
return new AppScheduleVo();
}
@Override
public Boolean updateStatusByBo(AppScheduleBo bo) {
AppSchedule update = MapstructUtils.convert(bo, AppSchedule.class);
return baseMapper.updateById(update)>0;
}
/**
* 修改日程排列
*
* @param bo 日程排列
* @return 是否修改成功
*/
@Override
@Transactional
public Map<String,Object> updateByBo(AppScheduleBo bo) {
AppSchedule update = MapstructUtils.convert(bo, AppSchedule.class);
validEntityBeforeSave(update);
Map<String,Object> repMap = new HashMap();
Boolean flag = baseMapper.updateById(update)>0;
repMap.put("flag", flag);
repMap.put("list", new ArrayList<>());
repMap.put("appSchedule", "");
if (flag){
AppScheduleVo appScheduleVo = baseMapper.selectVoById(update.getId());
//修改详情
private List updateDetails(List<AppScheduleBo.AppScheduleDetail> details) {
List detailList = new ArrayList<>();
for (AppScheduleBo.AppScheduleDetail item:bo.getDetails()){
if (details == null) {
return detailList;
}
for (AppScheduleBo.AppScheduleDetail item : details) {
AppScheduleDetail detail = new AppScheduleDetail();
detail.setWeekday(item.getWeekday());
detail.setId(item.getId());
detail.setWeekday(item.getWeekday());
detail.setTimeData(JsonUtils.toJsonString(item.getTimeData()));
detail.setZones(item.getZones());
detail.setTriggerType(item.getTriggerType());
detail.setStatus(item.getStatus());
scheduleDetailMapper.updateById(detail);
List timeSlots = new ArrayList();
for (AppScheduleBo.TimeSlot timeSlot:item.getTimeData()){
timeSlots.add(timeSlot);
detailList.add(toDetailMap(detail, item.getTimeData()));
}
Map map = new HashMap();
map.put("id",detail.getId() );
map.put("weekday",detail.getWeekday() );
map.put("timeData",timeSlots );
map.put("triggerType",detail.getTriggerType() );
map.put("status",detail.getStatus() );
map.put("createTime",detail.getCreateTime() );
detailList.add(map);
}
appScheduleVo.setDetails(detailList);
repMap.put("appSchedule", appScheduleVo);
return repMap;
}
return repMap;
return detailList;
}
/**
* 保存前的数据校验
*/
private void validEntityBeforeSave(AppSchedule entity){
//TODO 做一些数据校验,如唯一约束
private Map<String, Object> toDetailMap(AppScheduleDetail detail, List<AppScheduleBo.TimeSlot> timeData) {
Map<String, Object> map = new HashMap<>();
map.put("id", detail.getId());
map.put("weekday", detail.getWeekday());
map.put("timeData", timeData == null ? new ArrayList<>() : new ArrayList<>(timeData));
map.put("triggerType", detail.getTriggerType());
map.put("status", detail.getStatus());
map.put("createTime", detail.getCreateTime());
return map;
}
/**
* 校验并批量删除日程排列信息
*
* @param ids 待删除的主键集合
* @param isValid 是否进行有效性校验
* @return 是否删除成功
*/
@Override
@Transactional
public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
boolean flag = false;
try {
flag = baseMapper.deleteByIds(ids) > 0;
if (flag){
for (Long id :ids){
schedulingDeviceMapper.delete(new QueryWrapper<AppSchedulingDevice>().eq("schedule_id",id ));
scheduleDetailMapper.delete(new QueryWrapper<AppScheduleDetail>().eq("schedule_id", id));
}
}
return flag;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 检查新日程与设备已绑定日程是否存在时间冲突
*/
public List checkConflict(Long deviceId, AppScheduleVo newSchedule) {
// 1. 获取该设备所有已绑定的日程
List<AppSchedulingDevice> existingBindings = schedulingDeviceMapper.selectList(new QueryWrapper<AppSchedulingDevice>().eq("device_id", deviceId));
List conflicts = new ArrayList<>();
//新日程的详情列
List<AppScheduleDetail> scheduleDetails = scheduleDetailMapper.selectList(new QueryWrapper<AppScheduleDetail>().eq("schedule_id", newSchedule.getId()));
// 2. 对新日程的每一天,与已存在的每一天逐一比较
for (AppScheduleDetail newDay : scheduleDetails) {
//此设备日程
for (AppSchedulingDevice existing : existingBindings){
AppScheduleVo appScheduleVo = baseMapper.selectVoById(existing.getScheduleId());
if (ObjectUtil.isNotNull(appScheduleVo) && !"0".equals(appScheduleVo.getStatus())){
List<AppScheduleDetail> details = scheduleDetailMapper.selectList(new QueryWrapper<AppScheduleDetail>().eq("schedule_id", existing.getScheduleId()));
for (AppScheduleDetail existingDay:details ){
// 3. 同一天才检查时间重叠
if (newDay.getWeekday() == existingDay.getWeekday()) {
// if (timeOverlap(newDay, existingDay)) {
//
// Map map = new HashMap();
// map.put("scheduleName", appScheduleVo.getName());
// map.put("newDay", newDay);
// map.put("existingDay", existingDay);
// conflicts.add(map);
// }
}
}
}
}
}
return conflicts; // 空列表 = 无冲突
}
/**
* 判断两段时间是否重叠
* 重叠条件start1 < end2 && start2 < end1
*/
// public boolean timeOverlap(AppScheduleDetail a, AppScheduleDetail b) {
// LocalTime bendTime = b.getStartTime().plusMinutes(b.getDurationMin());
// LocalTime aendTime = a.getStartTime().plusMinutes(a.getDurationMin());
//
// return a.getStartTime().isBefore(bendTime)
// && b.getStartTime().isBefore(aendTime);
// }
}

View File

@@ -1,31 +1,27 @@
package org.dromara.app.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.dromara.common.core.utils.MapstructUtils;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.mybatis.core.page.PageQuery;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
import org.dromara.app.domain.vo.AppSchedulingDeviceVo;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.service.IAppSchedulingDeviceService;
import org.dromara.common.core.utils.MapstructUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Collection;
/**
* 排程与设备关联Service业务层处理
*
* @author Lion Li
* @date 2026-04-25
*/
@Slf4j
@RequiredArgsConstructor
@Service
@@ -33,24 +29,11 @@ public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceServi
private final AppSchedulingDeviceMapper baseMapper;
/**
* 查询排程与设备关联
*
* @param schedulingId 主键
* @return 排程与设备关联
*/
@Override
public AppSchedulingDeviceVo queryById(Long schedulingId){
public AppSchedulingDeviceVo queryById(Long schedulingId) {
return baseMapper.selectVoById(schedulingId);
}
/**
* 分页查询排程与设备关联列表
*
* @param bo 查询条件
* @param pageQuery 分页参数
* @return 排程与设备关联分页列表
*/
@Override
public TableDataInfo<AppSchedulingDeviceVo> queryPageList(AppSchedulingDeviceBo bo, PageQuery pageQuery) {
LambdaQueryWrapper<AppSchedulingDevice> lqw = buildQueryWrapper(bo);
@@ -58,12 +41,6 @@ public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceServi
return TableDataInfo.build(result);
}
/**
* 查询符合条件的排程与设备关联列表
*
* @param bo 查询条件
* @return 排程与设备关联列表
*/
@Override
public List<AppSchedulingDeviceVo> queryList(AppSchedulingDeviceBo bo) {
LambdaQueryWrapper<AppSchedulingDevice> lqw = buildQueryWrapper(bo);
@@ -74,33 +51,24 @@ public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceServi
Map<String, Object> params = bo.getParams();
LambdaQueryWrapper<AppSchedulingDevice> lqw = Wrappers.lambdaQuery();
lqw.eq(bo.getScheduleId() != null, AppSchedulingDevice::getScheduleId, bo.getScheduleId());
lqw.eq(bo.getDeviceId() != null, AppSchedulingDevice::getDeviceId, bo.getDeviceId());
lqw.eq(StringUtils.isNotBlank(bo.getDeviceNo()), AppSchedulingDevice::getDeviceNo, bo.getDeviceNo());
return lqw;
}
/**
* 新增排程与设备关联
*
* @param bo 排程与设备关联
* @return 是否新增成功
*/
@Override
public Boolean insertByBo(AppSchedulingDeviceBo bo) {
if (queryByScheduleIdAndDeviceNo(bo.getScheduleId(), bo.getDeviceNo()) != null) {
return true;
}
AppSchedulingDevice add = MapstructUtils.convert(bo, AppSchedulingDevice.class);
validEntityBeforeSave(add);
boolean flag = baseMapper.insert(add) > 0;
if (flag) {
bo.setScheduleId(add.getScheduleId());
try {
return baseMapper.insert(add) > 0;
} catch (DuplicateKeyException ignored) {
return true;
}
return flag;
}
/**
* 修改排程与设备关联
*
* @param bo 排程与设备关联
* @return 是否修改成功
*/
@Override
public Boolean updateByBo(AppSchedulingDeviceBo bo) {
AppSchedulingDevice update = MapstructUtils.convert(bo, AppSchedulingDevice.class);
@@ -108,46 +76,34 @@ public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceServi
return baseMapper.updateById(update) > 0;
}
/**
* 保存前的数据校验
*/
private void validEntityBeforeSave(AppSchedulingDevice entity){
//TODO 做一些数据校验,如唯一约束
private void validEntityBeforeSave(AppSchedulingDevice entity) {
// TODO 业务校验
}
/**
* 校验并批量删除排程与设备关联信息
*
* @param ids 待删除的主键集合
* @param isValid 是否进行有效性校验
* @return 是否删除成功
*/
@Override
public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
if(isValid){
//TODO 做一些业务上的校验,判断是否需要校验
if (isValid) {
// TODO 业务校验
}
return baseMapper.deleteByIds(ids) > 0;
}
@Override
public AppSchedulingDeviceVo queryById2DeviveId(Long scheduleId, Long id) {
return baseMapper.selectVoOne(new QueryWrapper<AppSchedulingDevice>().eq("schedule_id",scheduleId).eq("device_id",id));
public AppSchedulingDeviceVo queryByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo) {
return baseMapper.selectVoOne(new QueryWrapper<AppSchedulingDevice>()
.eq("schedule_id", scheduleId)
.eq("device_no", deviceNo));
}
@Override
public List<AppSchedulingDeviceVo> queryByDeviveId(Long id) {
return baseMapper.selectVoList(new QueryWrapper<AppSchedulingDevice>().eq("device_id",id));
public Boolean deleteWithValidByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo) {
return baseMapper.delete(new QueryWrapper<AppSchedulingDevice>()
.eq("schedule_id", scheduleId)
.eq("device_no", deviceNo)) > 0;
}
@Override
public Boolean deleteWithValidByschedulingIds2Ids(Long schedulingId, Long deviceId) {
return baseMapper.delete(new QueryWrapper<AppSchedulingDevice>().eq("schedule_id",schedulingId).eq("device_id",deviceId)) > 0;
}
@Override
public List<AppSchedulingDeviceVo> findByDeviceId(Long deviceId) {
return baseMapper.selectVoList(new QueryWrapper<AppSchedulingDevice>().eq("device_id",deviceId));
public List<AppSchedulingDeviceVo> findByDeviceNo(String deviceNo) {
return baseMapper.selectVoList(new QueryWrapper<AppSchedulingDevice>().eq("device_no", deviceNo));
}
}

View File

@@ -18,6 +18,7 @@ import org.dromara.app.domain.AppWateringLog;
import org.dromara.app.mapper.AppWateringLogMapper;
import org.dromara.app.service.IAppWateringLogService;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Collection;
@@ -77,7 +78,8 @@ public class AppWateringLogServiceImpl implements IAppWateringLogService {
LambdaQueryWrapper<AppWateringLog> lqw = Wrappers.lambdaQuery();
lqw.orderByAsc(AppWateringLog::getId);
lqw.eq(bo.getUserId() != null, AppWateringLog::getUserId, bo.getUserId());
lqw.eq(bo.getDeviceId() != null, AppWateringLog::getDeviceId, bo.getDeviceId());
lqw.eq(StringUtils.isNotBlank(bo.getDeviceNo()), AppWateringLog::getDeviceNo, bo.getDeviceNo());
lqw.eq(StringUtils.isNotBlank(bo.getCommandId()), AppWateringLog::getCommandId, bo.getCommandId());
lqw.eq(bo.getScheduleId() != null, AppWateringLog::getScheduleId, bo.getScheduleId());
lqw.eq(bo.getStartTime() != null, AppWateringLog::getStartTime, bo.getStartTime());
lqw.eq(bo.getEndTime() != null, AppWateringLog::getEndTime, bo.getEndTime());
@@ -100,6 +102,9 @@ public class AppWateringLogServiceImpl implements IAppWateringLogService {
@Override
public Boolean insertByBo(AppWateringLogBo bo) {
AppWateringLog add = MapstructUtils.convert(bo, AppWateringLog.class);
if (add.getScheduleId() == null) {
add.setScheduleId(0L);
}
validEntityBeforeSave(add);
boolean flag = baseMapper.insert(add) > 0;
if (flag) {
@@ -121,6 +126,36 @@ public class AppWateringLogServiceImpl implements IAppWateringLogService {
return baseMapper.updateById(update) > 0;
}
@Override
public Boolean finishManualLog(String deviceNo, Long userId, Date endTime) {
if (StringUtils.isBlank(deviceNo) || userId == null || endTime == null) {
return false;
}
AppWateringLog activeLog = baseMapper.selectOne(
Wrappers.<AppWateringLog>lambdaQuery()
.eq(AppWateringLog::getDeviceNo, deviceNo)
.eq(AppWateringLog::getUserId, userId)
.eq(AppWateringLog::getTriggerType, "1")
.isNull(AppWateringLog::getEndTime)
.orderByDesc(AppWateringLog::getStartTime)
.orderByDesc(AppWateringLog::getCreateTime)
.orderByDesc(AppWateringLog::getId)
.last("limit 1")
);
if (activeLog == null) {
return false;
}
activeLog.setEndTime(endTime);
if (activeLog.getStartTime() != null) {
long durationMillis = endTime.getTime() - activeLog.getStartTime().getTime();
long durationMin = Math.max(durationMillis, 0L) / 60000L;
activeLog.setDurationMin(String.valueOf(durationMin));
}
return baseMapper.updateById(activeLog) > 0;
}
/**
* 保存前的数据校验
*/

View File

@@ -0,0 +1,208 @@
package org.dromara.app.service.impl;
import cn.hutool.core.util.ObjectUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.app.service.IDeviceCommandPublisher;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* 设备命令下发业务实现。
* <p>
* 负责命令构造、设备校验、下发生效,以及手动操作的浇水日志记录。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DeviceCommandServiceImpl implements IDeviceCommandService {
// 字段注入 + @Lazy 打破DeviceMqttCommandPublisher → MqttClientManager → MqttMessageDispatcher
// → DeviceRegisterHandler → AppDeviceServiceImpl → DeviceCommandServiceImpl → IDeviceCommandPublisher 循环
// commandPublisher 仅在 sendCommand/sendSwitchCommand 运行时调用,构造阶段无需立即解析
@Lazy
@Autowired
private IDeviceCommandPublisher commandPublisher;
private final IAppDeviceService deviceService;
private final IAppWateringLogService wateringLogService;
// ========================= 通用下发 =========================
@Override
public String sendCommand(DeviceCommand command) {
validate(command);
String commandId = commandPublisher.send(command);
log.info("[CMD] command sent deviceNo={} commandType={} commandId={}",
command.getDeviceNo(), command.getCommandType(), commandId);
return commandId;
}
// ========================= 类型化下发 =========================
@Override
public String sendSwitchCommand(String deviceNo, String workStatus, String startTime, Integer durationMin) {
assertDeviceExists(deviceNo);
assertSwitchStatus(workStatus);
if ("1".equals(workStatus)) {
assertDurationPositive(durationMin);
}
DeviceCommand command = new DeviceCommand();
command.setDeviceNo(deviceNo);
command.setCommandType("switchDevice");
command.getPayload().put("deviceNo", deviceNo);
command.getPayload().put("cmd", workStatus);
command.getPayload().put("startTime", startTime);
if (durationMin != null) {
command.getPayload().put("durationMin", durationMin);
}
String commandId = commandPublisher.send(command);
log.info("[CMD] switch command sent deviceNo={} workStatus={} durationMin={} commandId={}",
deviceNo, workStatus, durationMin, commandId);
Long userId = LoginHelper.getUserId();
if ("1".equals(workStatus)) {
AppWateringLogBo logBo = buildManualStartLog(deviceNo, userId, commandId, startTime, durationMin);
wateringLogService.insertByBo(logBo);
} else {
boolean updated = wateringLogService.finishManualLog(deviceNo, userId, new Date());
if (!updated) {
log.warn("[CMD] no active manual watering log found deviceNo={} userId={} stopCommandId={}",
deviceNo, userId, commandId);
}
}
return commandId;
}
@Override
public String sendCustomCommand(String deviceNo, String commandType, Map<String, Object> extra) {
assertDeviceExists(deviceNo);
DeviceCommand command = new DeviceCommand();
command.setDeviceNo(deviceNo);
command.setCommandType(commandType);
if (extra != null) {
command.getPayload().putAll(extra);
}
return sendCommand(command);
}
@Override
public String sendBindDeviceCommand(String deviceNo) {
AppDeviceVo device = deviceService.queryById(deviceNo);
if (ObjectUtil.isNull(device)) {
throw new ServiceException("设备不存在:" + deviceNo);
}
DeviceCommand command = new DeviceCommand();
command.setDeviceNo(deviceNo);
command.setCommandType("bindDevice");
Map<String, Object> objectObjectHashMap = new HashMap<>();
objectObjectHashMap.put("bindStatus", true);
objectObjectHashMap.put("deviceNo", "01");
objectObjectHashMap.put("cmd", -1);
command.getPayload().putAll(objectObjectHashMap);
return sendCommand(command);
}
// ========================= 校验 =========================
private void validate(DeviceCommand command) {
if (StringUtils.isBlank(command.getDeviceNo())) {
throw new ServiceException("设备编号不能为空");
}
if (StringUtils.isBlank(command.getCommandType())) {
throw new ServiceException("命令类型不能为空");
}
assertDeviceExists(command.getDeviceNo());
}
private void assertDeviceExists(String deviceNo) {
AppDeviceVo device = deviceService.queryById(deviceNo);
if (ObjectUtil.isNull(device)) {
throw new ServiceException("设备不存在:" + deviceNo);
}
if (device.getUserId() == null) {
throw new ServiceException("设备未绑定用户,无法下发命令:" + deviceNo);
}
}
private void assertSwitchStatus(String workStatus) {
if (!"0".equals(workStatus) && !"1".equals(workStatus)) {
throw new ServiceException("设备工作状态只能为0或1");
}
}
private void assertDurationPositive(Integer durationMin) {
if (durationMin == null || durationMin <= 0) {
throw new ServiceException("持续时间必须大于0");
}
}
private AppWateringLogBo buildManualStartLog(
String deviceNo,
Long userId,
String commandId,
String startTime,
Integer durationMin
) {
AppWateringLogBo logBo = new AppWateringLogBo();
logBo.setDeviceNo(deviceNo);
logBo.setCommandId(commandId);
logBo.setUserId(userId);
logBo.setScheduleId(0L);
logBo.setDurationMin(String.valueOf(durationMin));
logBo.setTriggerType("1"); // 手动
logBo.setCreateTime(new Date());
if (StringUtils.isNotEmpty(startTime)) {
logBo.setStartTime(parseStartTime(startTime));
} else {
logBo.setStartTime(new Date());
}
return logBo;
}
private Date parseStartTime(String startTime) {
if (startTime.matches("^\\d{2}:\\d{2}$")) {
try {
Date parsedTime = new SimpleDateFormat("HH:mm").parse(startTime);
Calendar parsed = Calendar.getInstance();
parsed.setTime(parsedTime);
Calendar today = Calendar.getInstance();
today.set(Calendar.HOUR_OF_DAY, parsed.get(Calendar.HOUR_OF_DAY));
today.set(Calendar.MINUTE, parsed.get(Calendar.MINUTE));
today.set(Calendar.SECOND, 0);
today.set(Calendar.MILLISECOND, 0);
return today.getTime();
} catch (ParseException e) {
throw new ServiceException("开始时间格式错误,期望 HH:mm 或 yyyy-MM-dd HH:mm:ss");
}
}
try {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(startTime);
} catch (ParseException e) {
throw new ServiceException("开始时间格式错误,期望 HH:mm 或 yyyy-MM-dd HH:mm:ss");
}
}
}