diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..51c3850 --- /dev/null +++ b/.env.example @@ -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_ 前缀的环境变量覆盖 diff --git a/.gitignore b/.gitignore index 89cc69f..44c6418 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.idea/compiler.xml b/.idea/compiler.xml index b9f46fe..2617d3d 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -3,6 +3,11 @@ + + + + + @@ -20,6 +25,18 @@ + + + + + + + + + + + + @@ -55,11 +72,6 @@ - - - - - diff --git a/.idea/misc.xml b/.idea/misc.xml index 6bb3e7b..c031a4c 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -10,7 +10,5 @@ - - - + \ No newline at end of file diff --git a/.workbuddy/memory/2026-06-08.md b/.workbuddy/memory/2026-06-08.md new file mode 100644 index 0000000..96f5950 --- /dev/null +++ b/.workbuddy/memory/2026-06-08.md @@ -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` 保护 diff --git a/.workbuddy/memory/2026-06-10.md b/.workbuddy/memory/2026-06-10.md new file mode 100644 index 0000000..8ad2426 --- /dev/null +++ b/.workbuddy/memory/2026-06-10.md @@ -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`,启动时自动收集,循环匹配 +- 新增 Topic:只需新增 Handler Bean,Dispatcher 零改动 + +**修复的 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` 字段注入。 diff --git a/docs/mqtt-embedded-communication.md b/docs/mqtt-embedded-communication.md new file mode 100644 index 0000000..7771ac3 --- /dev/null +++ b/docs/mqtt-embedded-communication.md @@ -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`。 diff --git a/docs/project-architecture.md b/docs/project-architecture.md new file mode 100644 index 0000000..061e93c --- /dev/null +++ b/docs/project-architecture.md @@ -0,0 +1,199 @@ +# Water 项目架构图 + +本文档按当前项目代码结构整理,重点包含后端模块、智能灌溉业务、MQTT 设备通信、ACK 应答与重发链路。 + +## 整体模块 + +```mermaid +flowchart TB + APP["移动端 / 管理端 App"] + DEVICE["灌溉设备"] + BROKER["MQTT Broker
TLS: ssl://service.reinkun.com:8883"] + + subgraph BACKEND["water 后端服务"] + ADMIN["water-admin
Spring Boot 启动入口"] + APPMOD["water-modules/water-app
设备、排程、浇水记录、MQTT业务分发"] + SYSTEM["water-modules/water-system
用户、角色、租户、权限"] + COMMON["water-common
通用能力"] + MQTT["water-common-mqtt
MQTT连接、TLS、订阅、发布、ACK重发"] + REDISCOMMON["water-common-redis
Redis 工具与 Redisson"] + DBACCESS["water-common-mybatis
MyBatis Plus / 数据权限"] + end + + MYSQL[("MySQL
业务数据")] + REDIS[("Redis
缓存、设备状态、待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 请求
/app/v1/**"] + CONTROLLER["AppController
设备、排程、日志、用户接口"] + SERVICE["Service 层
IAppDeviceService / IAppScheduleService / IAppWateringLogService"] + MAPPER["Mapper 层
MyBatis Plus Mapper"] + DB[("MySQL")] + + HTTP --> CONTROLLER + CONTROLLER --> SERVICE + SERVICE --> MAPPER + MAPPER --> DB + + CONTROLLER --> CMDPUB["IDeviceCommandPublisher
设备命令发布接口"] + CMDPUB --> MQTTIMPL["DeviceMqttCommandPublisher
MQTT命令实现"] +``` + +## MQTT 高并发消费链路 + +```mermaid +flowchart TB + DEVICE1["设备 A"] + DEVICE2["设备 B"] + DEVICEN["设备 N"] + BROKER["MQTT Broker"] + CLIENT["MqttAsyncClient
单个后端订阅客户端"] + QUEUE["有界内存队列
queue-capacity: 20000"] + CONSUMERS["mqtt-consumer-*
consumer-count: 16
batch-size: 100"] + DISPATCHER["MqttMessageDispatcher
topic 路由"] + + DATA["DeviceDataHandler
/water/{deviceNo}/data"] + STATUS["DeviceStatusHandler
/water/{deviceNo}/status"] + ERROR["ErromesHandler
/water/{deviceNo}/erromes"] + ACK["IDeviceCommandAckHandler
/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}
设备最新在线状态
TTL: 300s"] + PENDING["mqtt:command:pending:{commandId}
待ACK命令详情
TTL: 86400s"] + PENDING_IDS["mqtt:command:pending:ids
待ACK commandId 集合"] + ACK["mqtt:command:ack:{commandId}
设备ACK结果
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` diff --git a/script/sql/update/update_5.5.3_5.6.0.sql b/script/sql/update/update_5.5.3_5.6.0.sql index 1f52349..ae453ea 100644 --- a/script/sql/update/update_5.5.3_5.6.0.sql +++ b/script/sql/update/update_5.5.3_5.6.0.sql @@ -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, ''); diff --git a/water-admin/src/main/java/org/dromara/DromaraApplication.java b/water-admin/src/main/java/org/dromara/DromaraApplication.java index 4d4e179..0590dd7 100644 --- a/water-admin/src/main/java/org/dromara/DromaraApplication.java +++ b/water-admin/src/main/java/org/dromara/DromaraApplication.java @@ -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启动成功 ლ(´ڡ`ლ)゙"); } } diff --git a/water-admin/src/main/resources/application.yml b/water-admin/src/main/resources/application.yml index 49a1730..eb7cb19 100644 --- a/water-admin/src/main/resources/application.yml +++ b/water-admin/src/main/resources/application.yml @@ -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: diff --git a/water-admin/src/main/resources/logback-plus.xml b/water-admin/src/main/resources/logback-plus.xml index 2d786f9..abd2e89 100644 --- a/water-admin/src/main/resources/logback-plus.xml +++ b/water-admin/src/main/resources/logback-plus.xml @@ -82,7 +82,7 @@ 0 - 512 + 8192 @@ -92,7 +92,7 @@ 0 - 512 + 8192 diff --git a/water-common/water-common-mqtt/pom.xml b/water-common/water-common-mqtt/pom.xml index ad56cf9..884968f 100644 --- a/water-common/water-common-mqtt/pom.xml +++ b/water-common/water-common-mqtt/pom.xml @@ -6,7 +6,7 @@ org.dromara water-common - 5.6.0 + ${revision} water-common-mqtt @@ -40,4 +40,4 @@ water-app - \ No newline at end of file + diff --git a/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/DeviceMqttCommandPublisher.java b/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/DeviceMqttCommandPublisher.java new file mode 100644 index 0000000..07bb527 --- /dev/null +++ b/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/DeviceMqttCommandPublisher.java @@ -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"; + } +} diff --git a/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttClientManager.java b/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttClientManager.java index a9a07cd..6ab46c6 100644 --- a/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttClientManager.java +++ b/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttClientManager.java @@ -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 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()); - options.setPassword(props.getPassword().toCharArray()); + 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 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 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) { } } diff --git a/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttCommandAckService.java b/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttCommandAckService.java new file mode 100644 index 0000000..e1b3f4b --- /dev/null +++ b/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttCommandAckService.java @@ -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 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 findPendingCommandsByDeviceNo(String deviceNo) { + List 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 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 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 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 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; + } +} diff --git a/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttCommandRetryTask.java b/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttCommandRetryTask.java new file mode 100644 index 0000000..05dea8a --- /dev/null +++ b/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttCommandRetryTask.java @@ -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(); + } + } +} diff --git a/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/config/properties/MqttProperties.java b/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/config/properties/MqttProperties.java index 09c2d4b..916ad85 100644 --- a/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/config/properties/MqttProperties.java +++ b/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/config/properties/MqttProperties.java @@ -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 subscribe; + private List 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; + } + } diff --git a/water-common/water-common-mqtt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/water-common/water-common-mqtt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 58de90e..3901242 100644 --- a/water-common/water-common-mqtt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/water-common/water-common-mqtt/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,2 +1,5 @@ org.dromara.mqtt.MqttClientManager -org.dromara.app.mqtt.MqttMessageDispatcher \ No newline at end of file +org.dromara.mqtt.MqttCommandAckService +org.dromara.mqtt.DeviceMqttCommandPublisher +org.dromara.mqtt.MqttCommandRetryTask +org.dromara.app.mqtt.MqttMessageDispatcher diff --git a/water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java b/water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java index f2d6730..56d8310 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java @@ -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 addDevice(@RequestBody String body) { @@ -91,16 +90,27 @@ public class AppController extends BaseController { } } + /** + * 绑定已通过MQTT注册上线的设备 + */ + @ApiEncrypt + @PostMapping("/bindDevice") + public R 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 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 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 switchDevice(@RequestBody String body) { Map 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 appSchedulingDeviceVos = appSchedulingDeviceService.queryByDeviveId(appDeviceVo.getId()); + List 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 addScheduleDevice( @RequestBody String body) { - try { - Map map = JsonUtils.parseObject(body, Map.class); - Long scheduleId = Long.valueOf(map.get("scheduleId").toString()); - List deviceIds= (List) map.get("deviceIds"); - for (String deviceId:deviceIds){ - AppSchedulingDeviceBo schedulingDeviceBo = new AppSchedulingDeviceBo(); - schedulingDeviceBo.setDeviceId(Long.valueOf(deviceId)); - schedulingDeviceBo.setScheduleId(scheduleId); - appSchedulingDeviceService.insertByBo(schedulingDeviceBo); - } - return R.ok(); - } catch (RuntimeException e) { - throw new RuntimeException(e); + Map 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 deviceNos = parseDeviceNos(map); + if (deviceNos.isEmpty()) { + throw new ServiceException("deviceNos不能为空"); + } + + for (String deviceNo:deviceNos){ + if (StringUtils.isBlank(deviceNo)) { + continue; + } + AppSchedulingDeviceBo schedulingDeviceBo = new AppSchedulingDeviceBo(); + schedulingDeviceBo.setDeviceNo(deviceNo); + schedulingDeviceBo.setScheduleId(scheduleId); + appSchedulingDeviceService.insertByBo(schedulingDeviceBo); + } + return R.ok(); + } + + private List parseDeviceNos(Map 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 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,8 +312,11 @@ public class AppController extends BaseController { List appSchedulingDeviceVos = appSchedulingDeviceService.queryList(appSchedulingDeviceBo); List deviceList = new ArrayList(); for (AppSchedulingDeviceVo appSchedulingDeviceVo:appSchedulingDeviceVos){ - AppDeviceVo appDeviceVo = appDeviceService.queryById(appSchedulingDeviceVo.getDeviceId()); - deviceList.add(appDeviceVo); + AppDeviceVo appDeviceVo = appDeviceService.queryById(appSchedulingDeviceVo.getDeviceNo()); + if (ObjectUtil.isNotNull(appDeviceVo)){ + deviceList.add(appDeviceVo); + } + } List detailList = new ArrayList(); @@ -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 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); + } + } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/controller/AppDeviceController.java b/water-modules/water-app/src/main/java/org/dromara/app/controller/AppDeviceController.java index 2f8a2ee..1d0386a 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/controller/AppDeviceController.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/controller/AppDeviceController.java @@ -66,10 +66,10 @@ public class AppDeviceController extends BaseController { * @param id 主键 */ @SaCheckPermission("app:device:query") - @GetMapping("/{id}") + @GetMapping("/{deviceNo}") public R 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 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)); } } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/AppDevice.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/AppDevice.java index ff946ea..c0781d1 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/domain/AppDevice.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/AppDevice.java @@ -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; - - } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/AppSchedule.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/AppSchedule.java index fd1e726..b0ee4c5 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/domain/AppSchedule.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/AppSchedule.java @@ -46,8 +46,8 @@ public class AppSchedule extends BaseEntity { */ private String status; - - private Date createTime; + // createTime 已由父类 BaseEntity 提供,此处不再重复定义 + // private Date createTime; //private List details; diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/AppSchedulingDevice.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/AppSchedulingDevice.java index eda5777..c39d028 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/domain/AppSchedulingDevice.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/AppSchedulingDevice.java @@ -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; } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/AppWateringLog.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/AppWateringLog.java index b408e95..aa96ad1 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/domain/AppWateringLog.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/AppWateringLog.java @@ -37,10 +37,13 @@ public class AppWateringLog extends BaseEntity { /** * 设备ID */ - private Long deviceId; - private String deviceNo; + /** + * 命令ID + */ + private String commandId; + /** * 关联排程 */ diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppDeviceBo.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppDeviceBo.java index 4efc1aa..52ca084 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppDeviceBo.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppDeviceBo.java @@ -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; - - } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppScheduleBo.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppScheduleBo.java index 03e6aa4..32fec8f 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppScheduleBo.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppScheduleBo.java @@ -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 deviceIds; + private List deviceNos; /** diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppSchedulingDeviceBo.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppSchedulingDeviceBo.java index 80c8ac2..0fccd69 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppSchedulingDeviceBo.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppSchedulingDeviceBo.java @@ -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; } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppWateringLogBo.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppWateringLogBo.java index 86d55d5..72627de 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppWateringLogBo.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppWateringLogBo.java @@ -36,10 +36,13 @@ public class AppWateringLogBo extends BaseEntity { /** * 设备ID */ - private Long deviceId; - private String deviceNo; + /** + * 命令ID + */ + private String commandId; + /** * 关联排程 */ diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/mqtt/DeviceCommand.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/mqtt/DeviceCommand.java new file mode 100644 index 0000000..0d99429 --- /dev/null +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/mqtt/DeviceCommand.java @@ -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 payload = new HashMap<>(); + private int retryCount; + private long createdAt; + private long lastSentAt; + private long nextRetryAt; +} diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/mqtt/DeviceCommandAck.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/mqtt/DeviceCommandAck.java new file mode 100644 index 0000000..ffffadb --- /dev/null +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/mqtt/DeviceCommandAck.java @@ -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; +} diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppDeviceVo.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppDeviceVo.java index 8182d4f..8cb8167 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppDeviceVo.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppDeviceVo.java @@ -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; - - } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppScheduleVo.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppScheduleVo.java index aeb3460..1e7dc5e 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppScheduleVo.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppScheduleVo.java @@ -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 开启 */ diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppSchedulingDeviceVo.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppSchedulingDeviceVo.java index 1721133..f2a8ad1 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppSchedulingDeviceVo.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppSchedulingDeviceVo.java @@ -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; - } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppWateringLogVo.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppWateringLogVo.java index cd49200..a5ddcd5 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppWateringLogVo.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppWateringLogVo.java @@ -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; + /** * 关联排程 */ diff --git a/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceCommandAckHandler.java b/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceCommandAckHandler.java new file mode 100644 index 0000000..8d9beee --- /dev/null +++ b/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceCommandAckHandler.java @@ -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); + } +} diff --git a/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceDataHandler.java b/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceDataHandler.java index 195bafd..d954d20 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceDataHandler.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceDataHandler.java @@ -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 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); } } } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceRegisterHandler.java b/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceRegisterHandler.java new file mode 100644 index 0000000..d6a4e3f --- /dev/null +++ b/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceRegisterHandler.java @@ -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 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 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); + } +} diff --git a/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceStatusHandler.java b/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceStatusHandler.java deleted file mode 100644 index 9f7fe31..0000000 --- a/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceStatusHandler.java +++ /dev/null @@ -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 dto = JsonUtils.parseObject(payload, Map.class); - // 更新设备在线状态 - -// log.info("[MQTT] 设备状态更新 deviceId={} status={}", deviceId, dto.getStatus()); - } catch (Exception e) { - log.error("[MQTT] 设备状态处理失败 deviceId={}", deviceNo, e); - } - } -} diff --git a/water-modules/water-app/src/main/java/org/dromara/app/handler/ErromesHandler.java b/water-modules/water-app/src/main/java/org/dromara/app/handler/ErromesHandler.java index c7f0cbc..b5b0ebb 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/handler/ErromesHandler.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/handler/ErromesHandler.java @@ -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 dto = JsonUtils.parseObject(payload, Map.class); - - log.info("[MQTT] 设备状态发送异常 deviceNo={}", deviceNo); + Map 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); } } } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/handler/KeyFinishHandler.java b/water-modules/water-app/src/main/java/org/dromara/app/handler/KeyFinishHandler.java new file mode 100644 index 0000000..2604c11 --- /dev/null +++ b/water-modules/water-app/src/main/java/org/dromara/app/handler/KeyFinishHandler.java @@ -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 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 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); + } +} diff --git a/water-modules/water-app/src/main/java/org/dromara/app/handler/ScheduleFinishHandler.java b/water-modules/water-app/src/main/java/org/dromara/app/handler/ScheduleFinishHandler.java new file mode 100644 index 0000000..fc62255 --- /dev/null +++ b/water-modules/water-app/src/main/java/org/dromara/app/handler/ScheduleFinishHandler.java @@ -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 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 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 schedulingDevices = schedulingDeviceMapper.selectList( + Wrappers.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); + } + +} diff --git a/water-modules/water-app/src/main/java/org/dromara/app/mapper/AppDeviceMapper.java b/water-modules/water-app/src/main/java/org/dromara/app/mapper/AppDeviceMapper.java index 3aa9c91..701ab0b 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/mapper/AppDeviceMapper.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/mapper/AppDeviceMapper.java @@ -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 { + @Update(""" + + """) + 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); + } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/mqtt/MqttMessageDispatcher.java b/water-modules/water-app/src/main/java/org/dromara/app/mqtt/MqttMessageDispatcher.java index 45c377f..0151156 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/mqtt/MqttMessageDispatcher.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/mqtt/MqttMessageDispatcher.java @@ -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 上行消息分发器(策略模式)。 + *

+ * 启动时自动收集所有 {@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 handlers; - 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); + public MqttMessageDispatcher(List 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()); } } - private String extract(String topic, String pattern) { - return topic.replaceAll(pattern, "$1"); + /** + * 分发消息到匹配的处理器。 + * + * @param topic MQTT Topic + * @param payload 消息体 + */ + public void dispatch(String topic, String 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; + } + } + + log.warn("[MQTT] no handler for topic: {} payload={}", topic, payload); } } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/mqtt/MqttTopicHandler.java b/water-modules/water-app/src/main/java/org/dromara/app/mqtt/MqttTopicHandler.java new file mode 100644 index 0000000..2f7533d --- /dev/null +++ b/water-modules/water-app/src/main/java/org/dromara/app/mqtt/MqttTopicHandler.java @@ -0,0 +1,28 @@ +package org.dromara.app.mqtt; + +import java.util.regex.Pattern; + +/** + * MQTT 消息 Topic 处理器策略接口。 + *

+ * 所有上行消息处理器实现此接口,由 {@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); +} diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/IAppDeviceService.java b/water-modules/water-app/src/main/java/org/dromara/app/service/IAppDeviceService.java index 971c974..799d8ab 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/service/IAppDeviceService.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/IAppDeviceService.java @@ -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 ids, Boolean isValid); + Boolean deleteWithValidByIds(Collection deviceNos, Boolean isValid); } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/IAppSchedulingDeviceService.java b/water-modules/water-app/src/main/java/org/dromara/app/service/IAppSchedulingDeviceService.java index 9129540..279537c 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/service/IAppSchedulingDeviceService.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/IAppSchedulingDeviceService.java @@ -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 queryPageList(AppSchedulingDeviceBo bo, PageQuery pageQuery); - /** - * 查询符合条件的排程与设备关联列表 - * - * @param bo 查询条件 - * @return 排程与设备关联列表 - */ List 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 ids, Boolean isValid); - /** - * 查找某一设备的所有日程 - * @param deviceId - * @return - */ - List findByDeviceId(Long deviceId); + List 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 queryByDeviveId(Long id); + AppSchedulingDeviceVo queryByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo); } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/IAppWateringLogService.java b/water-modules/water-app/src/main/java/org/dromara/app/service/IAppWateringLogService.java index de9ad7f..a361fe6 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/service/IAppWateringLogService.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/IAppWateringLogService.java @@ -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); + /** * 校验并批量删除浇水记录信息 * diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/IDeviceCommandAckHandler.java b/water-modules/water-app/src/main/java/org/dromara/app/service/IDeviceCommandAckHandler.java new file mode 100644 index 0000000..af9c0fb --- /dev/null +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/IDeviceCommandAckHandler.java @@ -0,0 +1,6 @@ +package org.dromara.app.service; + +public interface IDeviceCommandAckHandler { + + void handleAck(String deviceNo, String payload); +} diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/IDeviceCommandPublisher.java b/water-modules/water-app/src/main/java/org/dromara/app/service/IDeviceCommandPublisher.java new file mode 100644 index 0000000..cdb610e --- /dev/null +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/IDeviceCommandPublisher.java @@ -0,0 +1,8 @@ +package org.dromara.app.service; + +import org.dromara.app.domain.mqtt.DeviceCommand; + +public interface IDeviceCommandPublisher { + + String send(DeviceCommand command); +} diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/IDeviceCommandService.java b/water-modules/water-app/src/main/java/org/dromara/app/service/IDeviceCommandService.java new file mode 100644 index 0000000..8e489e0 --- /dev/null +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/IDeviceCommandService.java @@ -0,0 +1,45 @@ +package org.dromara.app.service; + +import org.dromara.app.domain.mqtt.DeviceCommand; + +import java.util.Map; + +/** + * 设备命令下发业务层接口。 + *

+ * 封装命令构造、设备校验、下发与日志记录,避免各业务方直接拼装 {@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 extra); + String sendBindDeviceCommand(String deviceNo); +} diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppDeviceServiceImpl.java b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppDeviceServiceImpl.java index f215bf8..0afcaf4 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppDeviceServiceImpl.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppDeviceServiceImpl.java @@ -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 queryPageList(AppDeviceBo bo, PageQuery pageQuery) { LambdaQueryWrapper lqw = buildQueryWrapper(bo); @@ -71,14 +62,6 @@ public class AppDeviceServiceImpl implements IAppDeviceService { return TableDataInfo.build(result); } - /** - * 查询符合条件的设备信息 -列表 - * - * @param bo 查询条件 - * @return 设备信息 -列表 - */ @Override public List queryList(AppDeviceBo bo) { LambdaQueryWrapper lqw = buildQueryWrapper(bo); @@ -88,7 +71,7 @@ public class AppDeviceServiceImpl implements IAppDeviceService { private LambdaQueryWrapper buildQueryWrapper(AppDeviceBo bo) { Map params = bo.getParams(); LambdaQueryWrapper 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 ids, Boolean isValid) { - if(isValid){ - //TODO 做一些业务上的校验,判断是否需要校验 - } - boolean flag = baseMapper.deleteByIds(ids) > 0; - if (flag){ - for (Long id :ids){ - schedulingDeviceMapper.delete(new QueryWrapper().eq("device_id",id)); - } - + public Boolean deleteWithValidByIds(Collection deviceNos, Boolean isValid) { + boolean flag = baseMapper.deleteByIds(deviceNos) > 0; + if (flag) { + schedulingDeviceMapper.delete( + new QueryWrapper().in("device_no", deviceNos) + ); + wateringLogMapper.delete(new QueryWrapper().in("device_no", deviceNos)); } return flag; } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppScheduleServiceImpl.java b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppScheduleServiceImpl.java index dc4be0d..15b5984 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppScheduleServiceImpl.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppScheduleServiceImpl.java @@ -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 queryPageList(AppScheduleBo bo, PageQuery pageQuery) { LambdaQueryWrapper lqw = buildQueryWrapper(bo); @@ -72,12 +52,6 @@ public class AppScheduleServiceImpl implements IAppScheduleService { return TableDataInfo.build(result); } - /** - * 查询符合条件的日程排列列表 - * - * @param bo 查询条件 - * @return 日程排列列表 - */ @Override public List queryList(AppScheduleBo bo) { LambdaQueryWrapper 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){ - AppScheduleVo vo = MapstructUtils.convert(add, AppScheduleVo.class); - List list = new ArrayList<>(); - //创建详情 - for (AppScheduleBo.AppScheduleDetail item:bo.getDetails()){ - AppScheduleDetail detail = new AppScheduleDetail(); - detail.setScheduleId(add.getId()); - detail.setWeekday(item.getWeekday()); - 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); - } - 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; + boolean flag = baseMapper.insert(add) > 0; + if (!flag) { + return new AppScheduleVo(); } - 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; + return baseMapper.updateById(update) > 0; } - /** - * 修改日程排列 - * - * @param bo 日程排列 - * @return 是否修改成功 - */ @Override @Transactional - public Map updateByBo(AppScheduleBo bo) { + public Map updateByBo(AppScheduleBo bo) { AppSchedule update = MapstructUtils.convert(bo, AppSchedule.class); validEntityBeforeSave(update); - Map repMap = new HashMap(); - Boolean flag = baseMapper.updateById(update)>0; + Map 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()); - //修改详情 - List detailList = new ArrayList<>(); - for (AppScheduleBo.AppScheduleDetail item:bo.getDetails()){ - AppScheduleDetail detail = new AppScheduleDetail(); - detail.setWeekday(item.getWeekday()); - detail.setId(item.getId()); - 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); - } - 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); + 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 做一些数据校验,如唯一约束 + private void validEntityBeforeSave(AppSchedule entity) { + // TODO business validation } - /** - * 校验并批量删除日程排列信息 - * - * @param ids 待删除的主键集合 - * @param isValid 是否进行有效性校验 - * @return 是否删除成功 - */ @Override @Transactional public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { - - boolean flag = false; - try { - flag = baseMapper.deleteByIds(ids) > 0; - if (flag){ - for (Long id :ids){ - schedulingDeviceMapper.delete(new QueryWrapper().eq("schedule_id",id )); - scheduleDetailMapper.delete(new QueryWrapper().eq("schedule_id", id)); - } + boolean flag = baseMapper.deleteByIds(ids) > 0; + if (flag) { + for (Long id : ids) { + schedulingDeviceMapper.delete(new QueryWrapper().eq("schedule_id", id)); + scheduleDetailMapper.delete(new QueryWrapper().eq("schedule_id", id)); } - return flag; - } catch (Exception e) { - throw new RuntimeException(e); } - + return flag; } - /** - * 检查新日程与设备已绑定日程是否存在时间冲突 - */ - public List checkConflict(Long deviceId, AppScheduleVo newSchedule) { - // 1. 获取该设备所有已绑定的日程 - List existingBindings = schedulingDeviceMapper.selectList(new QueryWrapper().eq("device_id", deviceId)); - + public List checkConflict(String deviceNo, AppScheduleVo newSchedule) { + List existingBindings = schedulingDeviceMapper.selectList( + new QueryWrapper().eq("device_no", deviceNo) + ); List conflicts = new ArrayList<>(); - //新日程的详情列 - List scheduleDetails = scheduleDetailMapper.selectList(new QueryWrapper().eq("schedule_id", newSchedule.getId())); - // 2. 对新日程的每一天,与已存在的每一天逐一比较 + List scheduleDetails = scheduleDetailMapper.selectList( + new QueryWrapper().eq("schedule_id", newSchedule.getId()) + ); + for (AppScheduleDetail newDay : scheduleDetails) { - //此设备日程 - for (AppSchedulingDevice existing : existingBindings){ + for (AppSchedulingDevice existing : existingBindings) { AppScheduleVo appScheduleVo = baseMapper.selectVoById(existing.getScheduleId()); - if (ObjectUtil.isNotNull(appScheduleVo) && !"0".equals(appScheduleVo.getStatus())){ - List details = scheduleDetailMapper.selectList(new QueryWrapper().eq("schedule_id", existing.getScheduleId())); + if (ObjectUtil.isNotNull(appScheduleVo) && !"0".equals(appScheduleVo.getStatus())) { + List details = scheduleDetailMapper.selectList( + new QueryWrapper().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); -// } + for (AppScheduleDetail existingDay : details) { + if (newDay.getWeekday().equals(existingDay.getWeekday())) { + // TODO time overlap validation } } } } } - - return conflicts; // 空列表 = 无冲突 + 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); -// } + private List saveDetails(Long scheduleId, List details) { + List list = new ArrayList<>(); + if (details == null) { + return list; + } + for (AppScheduleBo.AppScheduleDetail item : details) { + AppScheduleDetail detail = new AppScheduleDetail(); + detail.setScheduleId(scheduleId); + detail.setWeekday(item.getWeekday()); + if (ObjectUtil.isNotNull(item.getTimeData())) { + detail.setTimeData(JsonUtils.toJsonString(item.getTimeData())); + } + detail.setZones(item.getZones()); + detail.setTriggerType(item.getTriggerType()); + detail.setStatus(item.getStatus()); + scheduleDetailMapper.insert(detail); + list.add(toDetailMap(detail, item.getTimeData())); + } + return list; + } + private List updateDetails(List details) { + List detailList = new ArrayList<>(); + if (details == null) { + return detailList; + } + for (AppScheduleBo.AppScheduleDetail item : details) { + AppScheduleDetail detail = new AppScheduleDetail(); + 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); + detailList.add(toDetailMap(detail, item.getTimeData())); + } + return detailList; + } + + private Map toDetailMap(AppScheduleDetail detail, List timeData) { + Map 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; + } } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImpl.java b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImpl.java index b4c495e..71c3361 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImpl.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImpl.java @@ -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 queryPageList(AppSchedulingDeviceBo bo, PageQuery pageQuery) { LambdaQueryWrapper lqw = buildQueryWrapper(bo); @@ -58,12 +41,6 @@ public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceServi return TableDataInfo.build(result); } - /** - * 查询符合条件的排程与设备关联列表 - * - * @param bo 查询条件 - * @return 排程与设备关联列表 - */ @Override public List queryList(AppSchedulingDeviceBo bo) { LambdaQueryWrapper lqw = buildQueryWrapper(bo); @@ -74,33 +51,24 @@ public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceServi Map params = bo.getParams(); LambdaQueryWrapper 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 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().eq("schedule_id",scheduleId).eq("device_id",id)); + public AppSchedulingDeviceVo queryByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo) { + return baseMapper.selectVoOne(new QueryWrapper() + .eq("schedule_id", scheduleId) + .eq("device_no", deviceNo)); } @Override - public List queryByDeviveId(Long id) { - return baseMapper.selectVoList(new QueryWrapper().eq("device_id",id)); + public Boolean deleteWithValidByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo) { + return baseMapper.delete(new QueryWrapper() + .eq("schedule_id", scheduleId) + .eq("device_no", deviceNo)) > 0; } @Override - public Boolean deleteWithValidByschedulingIds2Ids(Long schedulingId, Long deviceId) { - return baseMapper.delete(new QueryWrapper().eq("schedule_id",schedulingId).eq("device_id",deviceId)) > 0; - } - - - @Override - public List findByDeviceId(Long deviceId) { - return baseMapper.selectVoList(new QueryWrapper().eq("device_id",deviceId)); + public List findByDeviceNo(String deviceNo) { + return baseMapper.selectVoList(new QueryWrapper().eq("device_no", deviceNo)); } } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppWateringLogServiceImpl.java b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppWateringLogServiceImpl.java index 0e1184a..f333015 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppWateringLogServiceImpl.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppWateringLogServiceImpl.java @@ -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 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.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; + } + /** * 保存前的数据校验 */ diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/impl/DeviceCommandServiceImpl.java b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/DeviceCommandServiceImpl.java new file mode 100644 index 0000000..1f74345 --- /dev/null +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/DeviceCommandServiceImpl.java @@ -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; + +/** + * 设备命令下发业务实现。 + *

+ * 负责命令构造、设备校验、下发生效,以及手动操作的浇水日志记录。 + */ +@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 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 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"); + } + } +}