浇水bug修改

This commit is contained in:
yuhaiming
2026-07-13 14:22:11 +08:00
parent 1e1f1042fe
commit 86e511f418
50 changed files with 4976 additions and 280 deletions

12
.idea/compiler.xml generated
View File

@@ -25,6 +25,18 @@
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" /> <entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/tools/gem/gem-api/1.0.0.Alpha3/gem-api-1.0.0.Alpha3.jar" /> <entry name="$PROJECT_DIR$/../../repository/org/mapstruct/tools/gem/gem-api/1.0.0.Alpha3/gem-api-1.0.0.Alpha3.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar" /> <entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/com/github/therapi/therapi-runtime-javadoc-scribe/0.15.0/therapi-runtime-javadoc-scribe-0.15.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/com/github/therapi/therapi-runtime-javadoc/0.15.0/therapi-runtime-javadoc-0.15.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/springframework/boot/spring-boot-configuration-processor/3.5.12/spring-boot-configuration-processor-3.5.12.jar" />
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus-processor/1.5.0/mapstruct-plus-processor-1.5.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus/1.5.0/mapstruct-plus-1.5.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct/1.6.3/mapstruct-1.6.3.jar" />
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus-object-convert/1.5.0/mapstruct-plus-object-convert-1.5.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/cn/easii/tutelary-repackage-javapoet/1.0.5/tutelary-repackage-javapoet-1.0.5.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/tools/gem/gem-api/1.0.0.Alpha3/gem-api-1.0.0.Alpha3.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar" />
</processorPath> </processorPath>
<module name="water-app" /> <module name="water-app" />
<module name="water-common-excel" /> <module name="water-common-excel" />

547
docs/mqtt-spec.md Normal file
View File

@@ -0,0 +1,547 @@
# Water MQTT 通信协议说明文档
## 1. 系统架构概览
```
┌─────────────┐ MQTT Broker ┌─────────────────┐
│ 物理设备 │ ◄══════════════════► │ Water 后端服务 │
│ (ESP32等) │ (SSL/TCP连接) │ (Spring Boot) │
└─────────────┘ └─────────────────┘
│ │
│ 上行 (publish) │ 下行 (subscriber)
│ /{identity}/publish/xxx │ /{deviceNo}/subscriber/xxx
│ │
▼ ▼
设备注册、数据上报、 命令下发、排程同步、
浇水完成、异常告警、ACK 设备绑定/解绑/初始化
```
### 核心组件
| 组件 | 包路径 | 职责 |
|------|--------|------|
| `MqttClientManager` | `org.dromara.mqtt` | MQTT 客户端连接管理、消息收发 |
| `MqttMessageDispatcher` | `org.dromara.app.mqtt` | 上行消息路由分发(策略模式) |
| `MqttTopicHandler` | `org.dromara.app.mqtt` | 上行消息处理器接口 |
| `DeviceMqttCommandPublisher` | `org.dromara.mqtt` | 下行命令发布器 |
| `DeviceCommandServiceImpl` | `org.dromara.app.service.impl` | 命令构造与业务编排 |
| `MqttCommandAckService` | `org.dromara.mqtt` | 命令 ACK 确认与重试 |
| `DeviceIdentityResolver` | `org.dromara.app.handler` | 设备标识解析MAC/设备编号 → deviceNo |
---
## 2. Topic 约定
### 2.1 上行 Topic设备 → 服务端)
服务端通过通配符订阅(如 `+/publish/#`),由 `MqttMessageDispatcher` 按正则分发到对应 Handler。
| Topic 模式 | 正则 | 处理器 | 说明 |
|------------|------|--------|------|
| `/{identity}/publish/register` | `^/([^/]+)/publish/register$` | `DeviceRegisterHandler` | 设备注册/上线 |
| `/{identity}/publish/power` | `^/([^/]+)/publish/power$` | `DeviceDataHandler` | 电量数据上报 |
| `/{identity}/publish/finish/key` | `^/([^/]+)/publish/finish/key$` | `KeyFinishHandler` | 按键/手动浇水完成 |
| `/{identity}/publish/finish/schedule` | `^/([^/]+)/publish/finish/schedule$` | `ScheduleFinishHandler` | 排程浇水完成 |
| `/{identity}/publish/error` | `^/([^/]+)/publish/error$` | `ErromesHandler` | 设备异常告警 |
| `/{identity}/publish/ack` | `^/([^/]+)/publish/ack$` | `DeviceCommandAckHandler` | 命令执行确认 |
> `{identity}` 可以是设备编号deviceNo或 MAC 地址,由 `DeviceIdentityResolver` 统一解析为 deviceNo。
### 2.2 下行 Topic服务端 → 设备)
| Topic 模式 | 用途 | 构造方式 |
|------------|------|---------|
| `/{deviceNo}/subscriber/cmd` | 通用命令下发 | `DeviceMqttCommandPublisher.buildCommandTopic()` |
| `/{deviceNo}/subscriber/schedule` | 排程专用下发 | `DeviceCommandServiceImpl.buildScheduleTopic()` |
> 可通过 `mqtt.topics.publish-prefix` 配置前缀,如配置为 `/water`,则实际 Topic 为 `/water/{deviceNo}/subscriber/cmd`。
> `deviceNo` 统一为小写格式。
---
## 3. 消息格式
### 3.1 下行命令通用结构DeviceCommand
所有下行命令通过 `DeviceCommand` 对象构造,最终以 `payload` 字段的 JSON 发送到 MQTT。
```java
// DeviceCommand 字段
String commandId; // 命令唯一标识UUID自动生成
String deviceNo; // 设备编号
String deviceMac; // 设备 MAC 地址
String commandType; // 命令类型
String topic; // 发送目标 Topic可自定义默认自动构建
Map<String, Object> payload; // 命令负载(最终序列化为 JSON 发送)
int retryCount; // 当前重试次数
long createdAt; // 创建时间戳
long lastSentAt; // 最后发送时间戳
long nextRetryAt; // 下次重试时间戳
```
发送时自动注入到 payload 的字段:
```json
{
"commandId": "自动生成的UUID",
"commandType": "命令类型",
// ... 其他业务字段
}
```
### 3.2 上行 ACK 结构DeviceCommandAck
```json
{
"commandId": "对应下发命令的commandId",
"status": "1",
"message": "可选的文本消息"
}
```
> 也支持非 JSON 格式的纯文本 ACK当设备只有一条待确认命令时可自动匹配
---
## 4. 下行命令类型详解
### 4.1 设备开关命令 — `switchDevice`
**触发**: 用户手动开启/关闭设备浇水
**接口**: `PUT /app/v1/switchDevice`
**Topic**: `/{deviceNo}/subscriber/cmd`
```json
{
"commandId": "xxx",
"commandType": "switchDevice",
"deviceNo": "01",
"cmd": "1",
"startTime": "2026-06-25 14:35:00",
"durationMin": 20
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `cmd` | String | `"1"` = 开启浇水,`"0"` = 停止浇水 |
| `startTime` | String | 开始时间(`yyyy-MM-dd HH:mm:ss``HH:mm` |
| `durationMin` | Integer | 持续时间(分钟),开启时必填且 > 0 |
**附加行为**:
- 开启时自动创建手动浇水日志记录
- 停止时自动结束进行中的手动浇水日志
---
### 4.2 设备绑定命令 — `bindDevice`
**触发**: 用户在 APP 绑定设备
**接口**: `POST /app/v1/addDevice`
**Topic**: `/{deviceNo}/subscriber/cmd`
```json
{
"commandId": "xxx",
"commandType": "bindDevice",
"bindStatus": true,
"deviceNo": "01",
"cmd": -1
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `bindStatus` | Boolean | `true` = 已绑定 |
| `deviceNo` | String | 分配给设备的编号 |
| `cmd` | Integer | `-1` = 非浇水指令 |
---
### 4.3 设备初始化/解绑命令 — `initDevice`
**触发**: 用户删除/解绑设备
**接口**: `DELETE /app/v1/deleteDevice/{deviceNos}`
**Topic**: `/{deviceNo}/subscriber/cmd`
```json
{
"commandId": "xxx",
"commandType": "initDevice",
"deviceNo": "01",
"initStatus": true,
"cmd": "init"
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `initStatus` | Boolean | `true` = 需要恢复出厂设置 |
| `cmd` | String | `"init"` = 初始化指令 |
**附加行为**: 服务端同时清除用户绑定、排程关联、浇水日志。
---
### 4.4 设备编号下发 — `registerDeviceNo`
**触发**: 设备通过 MQTT 注册上线后,服务端自动回复
**接口**: 无(`DeviceRegisterHandler` 自动触发)
**Topic**: `/{deviceMac}/subscriber/cmd`
> 这是唯一保留 MAC 作为 Topic 第一段的下行命令;其余下行命令统一使用 `deviceNo`。
```json
{
"commandId": "xxx",
"commandType": "registerDeviceNo",
"deviceNo": "01",
"deviceMac": "aa:bb:cc:dd:ee:ff"
}
```
---
### 4.5 排程绑定命令 — `bindSchedule`
**触发**: 用户绑定排程到设备 / 修改排程内容 / 修改排程状态
**接口**: `POST /app/v1/addScheduleDevice` · `PUT /app/v1/updataschedule` · `PUT /app/v1/editScheduleStatus`
**Topic**: `/{deviceNo}/subscriber/schedule`(排程专用 Topic
```json
{
"commandId": "xxx",
"commandType": "bindSchedule",
"cmd": -1,
"deviceNo": "01",
"schedule": {
"id": 1,
"name": "每日浇水",
"status": "1"
},
"details": [
{
"id": 10,
"weekday": "1",
"timeData": [
{ "startTime": "08:00", "durationMin": 15 },
{ "startTime": "18:00", "durationMin": 10 }
],
"triggerType": "0",
"status": "1"
}
]
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `schedule.id` | Long | 排程 ID |
| `schedule.name` | String | 排程名称 |
| `schedule.status` | String | `"1"` = 启用,`"0"` = 停用 |
| `details[].weekday` | String | 星期几(`1`-`7` |
| `details[].timeData` | Array | 时间段列表 |
| `details[].triggerType` | String | 触发类型 |
| `details[].status` | String | 明细状态 |
---
### 4.6 排程解绑命令 — `unbindSchedule`
**触发**: 用户删除排程与设备的绑定关系 / 删除排程
**接口**: `DELETE /app/v1/deleteScheduleDevice` · `DELETE /app/v1/deleteschedule/{ids}`
**Topic**: `/{deviceNo}/subscriber/schedule`
```json
{
"commandId": "xxx",
"commandType": "unbindSchedule",
"cmd": -1,
"deviceNo": "01",
"scheduleId": 1,
"unbind": true
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `scheduleId` | Long | 要解绑的排程 ID |
| `unbind` | Boolean | `true` = 解绑 |
---
### 4.7 自定义命令 — 任意 `commandType`
**触发**: 通过 `sendCustomCommand` 接口下发
**Topic**: `/{deviceNo}/subscriber/cmd`
```json
{
"commandId": "xxx",
"commandType": "自定义类型",
// ... 自定义扩展字段
}
```
---
## 5. 上行消息类型详解
### 5.1 设备注册 — `/{identity}/publish/register`
设备上电或重连后发送注册消息。
```json
{
"deviceName": "花园浇水器",
"deviceMac": "aa:bb:cc:dd:ee:ff",
"powerLevel": "85",
"deviceEm": "型号",
"deviceSn": "序列号",
"version": "1.0.0"
}
```
**服务端处理**:
1. 通过 MAC 或设备编号解析入库设备
2. 更新设备注册信息(名称、电量、固件版本等)
3. 刷新设备在线状态到 Redis
4. 自动回复设备编号(`registerDeviceNo` 命令)
---
### 5.2 电量上报 — `/{identity}/publish/power`
```json
{
"powerLevel": "78"
}
```
**服务端处理**: 更新设备电量 + 刷新在线状态。
---
### 5.3 按键浇水完成 — `/{identity}/publish/finish/key`
```json
{
"deviceNo": "01",
"commandId": "对应的命令ID",
"startTime": "2026-06-25 08:00:00",
"endTime": "2026-06-25 08:15:00",
"durationMin": 15,
"triggerON": "schedule 或其他"
}
```
**服务端处理**:
- `triggerON = "schedule"` → 排程触发,调用 `confirmScheduleLog`
- 其他 → 手动触发,调用 `insertByBo` 创建新记录
---
### 5.4 排程浇水完成 — `/{identity}/publish/finish/schedule`
```json
{
"deviceNo": "01",
"commandId": "对应的命令ID",
"startTime": "2026-06-25 08:00:00",
"endTime": "2026-06-25 08:15:00",
"durationMin": 15,
"triggerON": "mqtt on 或其他"
}
```
**服务端处理**: 调用 `confirmScheduleLog` 记录排程浇水日志。
`triggerON = "mqtt on"` 时标记为手动触发类型。
---
### 5.5 设备异常告警 — `/{identity}/publish/error`
```json
{
"errorCode": "E001",
"message": "水泵故障"
}
```
**服务端处理**: 当前仅日志记录,未做告警推送。
---
### 5.6 命令 ACK — `/{identity}/publish/ack`
```json
{
"commandId": "对应的命令ID",
"status": "1",
"message": "执行成功"
}
```
**服务端处理**:
1. 从 Redis 移除 pending 命令
2. 缓存 ACK 结果
3. 刷新设备在线状态
---
## 6. 命令可靠性机制
### 6.1 命令生命周期
```
构造命令 → 存入 Redis(pending) → 发布到 MQTT → 等待 ACK
↓ ↓
定时扫描 收到 ACK
↓ ↓
超时未确认? 从 pending 移除
↓ 存入 Redis(ack)
重试(最多 3 次)
达到上限? → 放弃并记录日志
```
### 6.2 重试策略
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `maxRetryCount` | 3 | 最大重试次数 |
| `retryIntervalMs` | 5000 | 重试间隔ms |
| `scanIntervalMs` | 5000 | 定时扫描间隔ms |
| `pendingTtlSeconds` | 86400 | pending 命令 TTL |
| `ackTtlSeconds` | 86400 | ACK 结果缓存 TTL |
### 6.3 离线处理
- 重试前检查设备在线状态Redis 缓存)
- 设备离线时不执行 MQTT 发布,仅递增重试计数
- 达到最大重试次数后自动放弃
### 6.4 Redis 键规则
| Key 模式 | 说明 |
|---------|------|
| `mqtt:command:pending:{commandId}` | 待确认命令 |
| `mqtt:command:ack:{commandId}` | ACK 确认结果 |
| `mqtt:command:pending:ids` | 待确认命令 ID 集合Set |
| `lock:mqtt:command:retry:{commandId}` | 命令操作分布式锁 |
| `mqtt:device:status:{deviceNo}` | 设备在线状态缓存 |
| `lock:mqtt:device:status:{deviceNo}` | 设备状态写入锁 |
---
## 7. 接口 → MQTT 命令映射表
| 接口 | 路由 | MQTT 命令类型 | 备注 |
|------|------|--------------|------|
| 绑定设备 | `POST /addDevice` | `bindDevice` | Service 层下发 |
| 解绑设备 | `DELETE /deleteDevice/{deviceNos}` | `initDevice` | Service 层下发(每台设备) |
| 开关设备 | `PUT /switchDevice` | `switchDevice` | Service 层下发 + 浇水日志 |
| 绑定排程设备 | `POST /addScheduleDevice` | `bindSchedule` | Controller 层下发 |
| 解绑排程设备 | `DELETE /deleteScheduleDevice` | `unbindSchedule` | Controller 层下发 |
| 修改排程状态 | `PUT /editScheduleStatus` | `bindSchedule` | 向所有绑定设备重新下发 |
| 修改排程内容 | `PUT /updataschedule` | `bindSchedule` | 向所有绑定设备重新下发 |
| 删除排程 | `DELETE /deleteschedule/{ids}` | `unbindSchedule` | 删除前通知所有绑定设备 |
| 设备注册(自动) | — | `registerDeviceNo` | 设备上线后自动回复 |
---
## 8. 配置参考
```yaml
mqtt:
enabled: true
broker-url: ssl://your-broker:8883
client-id: water-server-01
username: server
password: xxxxxx
qos: 1
keep-alive: 60
connection-timeout: 30
max-inflight: 1000
clean-session: false
automatic-reconnect: true
tls:
enabled: true
skip-verify: false
topics:
subscribe:
- "+/publish/#"
publish-prefix: "" # 为空时 Topic = /{deviceNo}/subscriber/cmd
async:
core-pool-size: 8
max-pool-size: 32
consumer-count: 16
queue-capacity: 5000
batch-size: 100
offer-timeout-ms: 50
poll-timeout-ms: 100
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"
retry-lock-key-prefix: "lock:mqtt:command:retry:"
retry-lock-ttl-ms: 30000
device-status-cache-prefix: "mqtt:device:status:"
device-status-cache-ttl-seconds: 300
```
---
## 9. 扩展指南
### 新增上行消息处理器
1. 实现 `MqttTopicHandler` 接口
2. 添加 `@Component` 注解
3. 定义 `topicPattern()` 返回匹配正则(必须包含一个捕获组提取设备标识)
4. 实现 `handle(deviceIdentity, payload)` 方法
```java
@Component
public class MyNewHandler implements MqttTopicHandler {
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/mytype$");
@Override
public Pattern topicPattern() { return PATTERN; }
@Override
public void handle(String deviceIdentity, String payload) {
// 处理逻辑
}
}
```
> 无需修改 `MqttMessageDispatcher`,新 Handler 注册为 Bean 后自动发现。
### 新增下行命令类型
1.`IDeviceCommandService` 接口添加方法签名
2.`DeviceCommandServiceImpl` 实现命令构造与下发
3. 在 Controller 调用新方法
---
## 10. 设备在线状态检测
设备在线状态通过 Redis 缓存管理:
- **写入时机**: 设备注册、电量上报、ACK 确认时刷新
- **缓存格式**: `{ "deviceNo": "01", "status": "1", "lastReportTime": "2026-06-25T14:30:00Z" }`
- **TTL**: 默认 300 秒5 分钟)
- **离线判定**: 缓存过期 = 设备离线
- **并发保护**: 使用分布式锁(`lock:mqtt:device:status:{deviceNo}`)防止并发写入

View File

@@ -0,0 +1,267 @@
# Bind Schedule Device Dispatch Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** When the app binds a schedule to one or more devices, immediately publish that schedule's detail payload to each bound device.
**Architecture:** Keep the change inside the existing app module flow. `AppController.addScheduleDevice` remains the entry point, persists the relation as it does today, then reuses the existing `IDeviceCommandService.sendScheduleBindCommand(...)` path to publish a `bindSchedule` command with a small payload assembled from current schedule-detail data. Shared payload shaping should stay private to `AppController` unless implementation pressure proves otherwise.
**Tech Stack:** Java 17, Spring Boot 3, existing app controller/service layer, MQTT command publishing through `IDeviceCommandService`, Maven
## Global Constraints
- Only touch the schedule-device binding flow in `water-modules/water-app`
- Reuse existing `IDeviceCommandService.sendScheduleBindCommand(...)`
- Do not modify MQTT publisher, ACK handling, or topic routing
- Published payload must contain `deviceNo` and `details`
- `details` must follow the current device-facing schedule detail structure used by `buildScheduleBindPayload`
- Binding uses synchronous failure semantics: command publish failure must fail the API call
- Keep changes ASCII unless the target file already uses non-ASCII
---
### Task 1: Add a focused regression test for schedule-device bind dispatch
**Files:**
- Create: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java`
- Modify: `water-modules/water-app/pom.xml`
**Interfaces:**
- Consumes: `AppController.addScheduleDevice(String body)`
- Produces: a regression test proving that binding a device triggers `deviceCommandService.sendScheduleBindCommand(deviceNo, payload)`
- [ ] **Step 1: Add the test dependency baseline for the app module**
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
```
- [ ] **Step 2: Write the failing controller regression test**
```java
package org.dromara.app.controller;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.domain.vo.AppScheduleDetailVo;
import org.dromara.app.domain.vo.AppScheduleVo;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppScheduleDetailService;
import org.dromara.app.service.IAppScheduleService;
import org.dromara.app.service.IAppSchedulingDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.common.core.domain.R;
import org.dromara.system.service.ISysUserService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AppControllerTest {
@Mock private IAppDeviceService appDeviceService;
@Mock private IAppScheduleService appScheduleService;
@Mock private IAppScheduleDetailService appScheduleDetailService;
@Mock private org.dromara.app.service.impl.AppScheduleServiceImpl appScheduleServiceimpl;
@Mock private IAppSchedulingDeviceService appSchedulingDeviceService;
@Mock private ISysUserService userService;
@Mock private IAppWateringLogService appWateringLogService;
@Mock private IDeviceCommandService deviceCommandService;
@InjectMocks
private AppController controller;
@Test
void addScheduleDevice_dispatchesSchedulePayloadAfterBinding() {
AppScheduleVo schedule = new AppScheduleVo();
schedule.setId(10L);
schedule.setUserId(99L);
schedule.setName("Morning");
schedule.setStatus("1");
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(99L);
AppScheduleDetailVo detail = new AppScheduleDetailVo();
detail.setId(101L);
detail.setWeekday("1");
detail.setTimeData("[{\"startTime\":\"08:00\",\"durationMin\":15}]");
detail.setTriggerType("0");
detail.setStatus("1");
when(appScheduleService.queryById(10L)).thenReturn(schedule);
when(appDeviceService.queryById("D01")).thenReturn(device);
when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of(detail));
when(appSchedulingDeviceService.insertByBo(any(AppSchedulingDeviceBo.class))).thenReturn(true);
when(deviceCommandService.sendScheduleBindCommand(eq("D01"), anyMap())).thenReturn("cmd-1");
R<Void> result = controller.addScheduleDevice("{\"scheduleId\":10,\"deviceNos\":[\"D01\"]}");
assertThat(result.getCode()).isEqualTo(200);
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), payloadCaptor.capture());
Map<String, Object> payload = payloadCaptor.getValue();
assertThat(payload.get("deviceNo")).isEqualTo("D01");
assertThat(payload).containsKey("details").doesNotContainKeys("cmd", "schedule");
}
}
```
- [ ] **Step 3: Run the test to verify it fails for the expected reason**
Run: `mvn -pl water-modules/water-app -DskipTests=false -Dtest=AppControllerTest test`
Expected: FAIL because `sendScheduleBindCommand(...)` is never invoked and payload assertions cannot be satisfied yet
- [ ] **Step 4: Commit the failing test baseline**
```bash
git add water-modules/water-app/pom.xml water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java
git commit -m "test: cover schedule bind device dispatch"
```
### Task 2: Implement schedule payload assembly and dispatch in AppController
**Files:**
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java`
- Test: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java`
**Interfaces:**
- Consumes: `IAppScheduleService.queryById(Long)`, `IAppScheduleDetailService.queryByScheduleIdByStatus(Long)`, `IDeviceCommandService.sendScheduleBindCommand(String, Map<String, Object>)`
- Produces: `addScheduleDevice(...)` publishes a `bindSchedule` command after each successful relation insert
- [ ] **Step 1: Implement the minimum controller change to make the test pass**
```java
for (String deviceNo : deviceNos) {
if (StringUtils.isBlank(deviceNo)) {
continue;
}
assertDeviceOwned(deviceNo);
AppSchedulingDeviceBo schedulingDeviceBo = new AppSchedulingDeviceBo();
schedulingDeviceBo.setDeviceNo(deviceNo);
schedulingDeviceBo.setScheduleId(scheduleId);
appSchedulingDeviceService.insertByBo(schedulingDeviceBo);
deviceCommandService.sendScheduleBindCommand(deviceNo, buildScheduleBindPayload(scheduleId, deviceNo));
}
```
```java
private Map<String, Object> buildScheduleBindPayload(Long scheduleId, String deviceNo) {
List<AppScheduleDetailVo> details = appScheduleDetailService.queryByScheduleIdByStatus(scheduleId);
List<Map<String, Object>> detailPayload = new ArrayList<>();
for (AppScheduleDetailVo detail : details) {
List<Object> timeSlots = new ArrayList<>();
JSONArray timeArray = JSONUtil.parseArray(detail.getTimeData());
for (Object timeSlot : timeArray) {
timeSlots.add(timeSlot);
}
Map<String, Object> item = new HashMap<>();
item.put("weekday", detail.getWeekday());
item.put("timeData", timeSlots);
item.put("triggerType", detail.getTriggerType());
item.put("status", detail.getStatus());
detailPayload.add(item);
}
Map<String, Object> payload = new HashMap<>();
payload.put("deviceNo", deviceNo);
payload.put("details", detailPayload);
return payload;
}
```
- [ ] **Step 2: Run the focused regression test to verify it passes**
Run: `mvn -pl water-modules/water-app -DskipTests=false -Dtest=AppControllerTest test`
Expected: PASS with `AppControllerTest` green
- [ ] **Step 3: Refactor only if needed to remove duplication inside schedule bind payload shaping**
```java
private List<Map<String, Object>> toScheduleDetailPayload(List<AppScheduleDetailVo> details) {
List<Map<String, Object>> detailPayload = new ArrayList<>();
for (AppScheduleDetailVo detail : details) {
List<Object> timeSlots = new ArrayList<>();
JSONArray timeArray = JSONUtil.parseArray(detail.getTimeData());
for (Object timeSlot : timeArray) {
timeSlots.add(timeSlot);
}
Map<String, Object> item = new HashMap<>();
item.put("weekday", detail.getWeekday());
item.put("timeData", timeSlots);
item.put("triggerType", detail.getTriggerType());
item.put("status", detail.getStatus());
detailPayload.add(item);
}
return detailPayload;
}
```
- [ ] **Step 4: Re-run the focused regression test after refactor**
Run: `mvn -pl water-modules/water-app -DskipTests=false -Dtest=AppControllerTest test`
Expected: PASS
- [ ] **Step 5: Commit the implementation**
```bash
git add water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java
git commit -m "feat: dispatch schedule payload when binding device"
```
### Task 3: Run module-level verification and review edge behavior
**Files:**
- Modify: none required unless verification reveals defects
**Interfaces:**
- Consumes: completed controller change and regression test
- Produces: fresh verification evidence for the implementation claim
- [ ] **Step 1: Run the focused controller regression test**
Run: `mvn -pl water-modules/water-app -DskipTests=false -Dtest=AppControllerTest test`
Expected: PASS
- [ ] **Step 2: Run a compile pass for the app module with tests skipped**
Run: `mvn -pl water-modules/water-app -DskipTests compile`
Expected: BUILD SUCCESS
- [ ] **Step 3: Inspect the diff to confirm scope stayed local**
Run: `git diff --stat`
Expected: only the app controller, app module test baseline, the new controller test, and planning/spec docs changed for this task
- [ ] **Step 4: Commit any verification-driven follow-up if needed**
```bash
git add -A
git commit -m "chore: verify schedule bind dispatch change"
```

View File

@@ -0,0 +1,73 @@
# App Version Check Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an app-facing version check endpoint so the mobile app can compare its current version with the latest enabled app version record and prompt updates when they differ.
**Architecture:** Store version records in the new `app_version` table. `AppController` validates the request, asks `IAppVersionService` for the latest enabled record by platform, and returns whether an update is available.
**Tech Stack:** Spring Boot MVC, existing `R<T>` response wrapper, MyBatis Plus `BaseMapperPlus`, JUnit 5, Mockito, AssertJ.
## Global Constraints
- Keep the endpoint under `/app/v1`.
- Use exact version equality: `currentVersion` differs from configured `latestVersion` means update is available.
- Store version data in `app_version`; do not use `sys_config` for this feature.
- Do not change unrelated schedule, MQTT, auth, or OSS upload behavior.
---
### Task 1: Version Check Contract
**Files:**
- Modify: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java`
- Create: `water-modules/water-app/src/main/java/org/dromara/app/domain/AppVersion.java`
- Create: `water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppVersionVo.java`
- Create: `water-modules/water-app/src/main/java/org/dromara/app/mapper/AppVersionMapper.java`
- Create: `water-modules/water-app/src/main/java/org/dromara/app/service/IAppVersionService.java`
- Create: `water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppVersionServiceImpl.java`
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java`
- Create: `script/sql/update/add_app_version.sql`
**Interfaces:**
- Consumes: `IAppVersionService.queryLatestByPlatform(String platform)`
- Produces: `AppController.checkVersion(String platform, String currentVersion): R<AppVersionVo>`
- Produces: `AppVersionVo` fields `platform`, `currentVersion`, `latestVersion`, `versionCode`, `updateAvailable`, `forceUpdate`, `downloadUrl`, `releaseNotes`
- [ ] **Step 1: Write failing tests**
Add `IAppVersionService` as a mocked dependency and add tests for update available, no update, unsupported platform, missing current version, and missing version record.
- [ ] **Step 2: Run tests and verify RED**
Run:
```bash
mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dtest=AppControllerTest#checkVersion*" "-Dsurefire.failIfNoSpecifiedTests=false" test
```
Expected: compilation or test failure because `IAppVersionService` and table-backed implementation do not exist yet.
- [ ] **Step 3: Implement minimal production code**
Create `AppVersion`, `AppVersionVo`, `AppVersionMapper`, `IAppVersionService`, `AppVersionServiceImpl`, inject `IAppVersionService` into `AppController`, and keep `GET /checkVersion`.
- [ ] **Step 4: Run targeted tests and verify GREEN**
Run:
```bash
mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dtest=AppControllerTest#checkVersion*" "-Dsurefire.failIfNoSpecifiedTests=false" test
```
Expected: targeted version check tests pass.
- [ ] **Step 5: Run compile/package verification**
Run:
```bash
mvn -pl water-modules/water-app -am "-DskipTests" package
```
Expected: module and dependencies compile/package successfully.

View File

@@ -0,0 +1,134 @@
# Device Status LWT MAC Resolution Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Verify and document that `/MAC/publish/status` last-will messages resolve the MAC address to `deviceNo` before updating device status.
**Architecture:** Keep `DeviceStatusHandler` focused on status parsing and delegate Topic identity resolution to the existing `DeviceIdentityResolver`. Add a focused integration-style unit test using the real resolver and a mocked mapper so the complete MAC-to-device-number path is covered without duplicating database access in the handler.
**Tech Stack:** Java 17, Spring Boot, JUnit 5, Mockito, AssertJ, MyBatis-Plus
## Global Constraints
- Only `/MAC/publish/status` may use a MAC address for this change.
- Other MQTT communication continues to use `deviceNo`.
- A missing MAC mapping must not update device status.
- Preserve all existing uncommitted workspace changes.
---
### Task 1: Cover MAC Last-Will Resolution
**Files:**
- Modify: `water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceStatusHandlerTest.java`
**Interfaces:**
- Consumes: `DeviceIdentityResolver(AppDeviceMapper)` and `DeviceStatusHandler.handle(String, String, boolean)`
- Produces: Regression coverage proving a Topic MAC is converted to `AppDevice.deviceNo`
- [ ] **Step 1: Add the mapper mock and MAC last-will test**
Add imports and a mapper mock:
```java
import org.dromara.app.domain.AppDevice;
import org.dromara.app.mapper.AppDeviceMapper;
@Mock
private AppDeviceMapper appDeviceMapper;
```
Add this test:
```java
@Test
void handleResolvesLastWillTopicMacToDeviceNo() {
String macAddress = "DC:DA:0C:FA:29:5E";
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
when(appDeviceMapper.selectById(macAddress)).thenReturn(null);
when(appDeviceMapper.selectByMac("dc:da:0c:fa:29:5e")).thenReturn(device);
DeviceIdentityResolver resolver = new DeviceIdentityResolver(appDeviceMapper);
DeviceStatusHandler handler = new DeviceStatusHandler(resolver, deviceStatusService, new ObjectMapper());
handler.handle(macAddress, "{\"status\":\"offline\"}", false);
verify(appDeviceMapper).selectByMac("dc:da:0c:fa:29:5e");
verify(deviceStatusService).markOffline("D01", "设备 MQTT 状态离线");
verify(deviceStatusService, never()).markOffline(macAddress, "设备 MQTT 状态离线");
}
```
Add the missing-device guard test:
```java
@Test
void handleDoesNotUpdateStatusWhenLastWillMacIsUnknown() {
String macAddress = "DC:DA:0C:FA:29:5E";
when(appDeviceMapper.selectById(macAddress)).thenReturn(null);
when(appDeviceMapper.selectByMac("dc:da:0c:fa:29:5e")).thenReturn(null);
DeviceIdentityResolver resolver = new DeviceIdentityResolver(appDeviceMapper);
DeviceStatusHandler handler = new DeviceStatusHandler(resolver, deviceStatusService, new ObjectMapper());
handler.handle(macAddress, "{\"status\":\"offline\"}", false);
verify(deviceStatusService, never()).markOffline(anyString(), anyString());
verify(deviceStatusService, never()).markOnline(anyString());
}
```
- [ ] **Step 2: Run the focused test**
Run:
```powershell
mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" "-Dtest=DeviceStatusHandlerTest#handleResolvesLastWillTopicMacToDeviceNo+handleDoesNotUpdateStatusWhenLastWillMacIsUnknown" "-Dsurefire.failIfNoSpecifiedTests=false" test
```
Expected: both tests PASS. The requested production path already exists in `DeviceIdentityResolver`; these characterization tests make that behavior explicit and prevent regression.
### Task 2: Clarify Device Status Topic Identity
**Files:**
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceStatusHandler.java:15-19`
**Interfaces:**
- Consumes: Existing `DeviceIdentityResolver.resolveDeviceNo(String)` behavior
- Produces: Javadoc that accurately documents device-number and MAC Topic identities
- [ ] **Step 1: Update handler Javadoc**
Replace the class description with:
```java
/**
* 设备在线/离线状态处理器,匹配 /{deviceIdentity}/publish/status。
* <p>
* deviceIdentity 支持设备编号;设备遗嘱消息允许使用 MAC 地址,处理前统一解析为设备编号。
* 设备通过 status=online 标记上线,通过 LWT status=offline 标记异常离线。
*/
```
- [ ] **Step 2: Run all handler tests**
Run:
```powershell
mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" "-Dtest=DeviceStatusHandlerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
```
Expected: all `DeviceStatusHandlerTest` tests PASS with zero failures and errors.
- [ ] **Step 3: Check the final diff**
Run:
```powershell
git diff --check -- water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceStatusHandler.java water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceStatusHandlerTest.java
```
Expected: exit code 0 and no whitespace errors.
- [ ] **Step 4: Leave implementation changes uncommitted for review**
Both implementation files already contain user changes. Do not create a commit that would mix those changes with this task.

View File

@@ -0,0 +1,59 @@
# 绑定排程时向设备下发排程信息设计
## 背景
当前 `AppController.addScheduleDevice` 只负责建立排程与设备的关联关系,不会把排程详情同步下发给设备。这样会导致设备绑定成功后仍然缺少最新排程配置。
## 目标
在 APP 端绑定排程与设备成功时,服务端同步向对应设备下发该排程详情信息,确保设备立即拿到当前排程配置。
## 设计
### 入口与改动范围
- 入口保持在 `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java``addScheduleDevice`
- 复用现有 `IDeviceCommandService.sendScheduleBindCommand(...)`
- 不修改现有 MQTT 发布器、ACK 处理器和设备主题路由
### 下发时机
对请求中的每个 `deviceNo`
1. 校验设备归属
2. 写入排程设备关联
3. 读取当前排程详情
4. 同步下发排程命令
### 命令协议
- `commandType`: `bindSchedule`
- `payload.deviceNo`: 当前设备编号
- `payload.details`: 当前排程详情列表
`payload.details` 每项沿用当前 APP 查询排程详情时对外返回的结构,包含:
- `weekday`
- `timeData`
- `triggerType`
- `status`
如果排程详情里存在 `zones` 字段且当前查询对象可直接取到,则一并下发;否则不额外改造现有排程详情出参结构。
### 失败策略
绑定接口采用同步失败策略:
- 只要某台设备的排程命令下发失败,接口直接返回失败
- 不吞掉下发异常
- 前端可以明确感知“排程绑定/同步下发”未完全成功
本次不额外引入补偿、重试编排或异步任务。
## 测试与验证
- 优先补最小范围的自动化测试;如果模块当前没有现成测试基线,则至少执行模块编译或定向测试命令做回归验证
- 手工验证重点:
- 绑定排程后关联表仍正常写入
- MQTT 下发 payload 包含 `deviceNo``details`
- 设备未登记 MAC 或命令下发异常时,接口返回失败

View File

@@ -0,0 +1,18 @@
CREATE TABLE `app_version` (
`id` bigint NOT NULL COMMENT '主键',
`platform` varchar(20) NOT NULL COMMENT '平台 android/ios',
`latest_version` varchar(50) NOT NULL COMMENT '最新版本号',
`version_code` int NOT NULL COMMENT '版本序号,用于排序取最新版本',
`force_update` char(1) NOT NULL DEFAULT '0' COMMENT '是否强制更新 0否 1是',
`download_url` varchar(500) DEFAULT NULL COMMENT '下载地址',
`release_notes` varchar(1000) DEFAULT NULL COMMENT '更新说明',
`status` char(1) NOT NULL DEFAULT '1' COMMENT '状态 0停用 1启用',
`create_dept` bigint DEFAULT NULL COMMENT '创建部门',
`create_by` bigint DEFAULT NULL COMMENT '创建者',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` bigint DEFAULT NULL COMMENT '更新者',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`),
KEY `idx_app_version_platform_status_code` (`platform`, `status`, `version_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='APP版本表';

View File

@@ -190,8 +190,9 @@ public class AuthController {
@DeleteMapping("/account") @DeleteMapping("/account")
public R<Void> cancelAccount(@Validated @RequestBody AccountCancelBody body) { public R<Void> cancelAccount(@Validated @RequestBody AccountCancelBody body) {
StpUtil.checkLogin(); StpUtil.checkLogin();
Long userId = LoginHelper.getUserId();
accountCancellationService.cancelCurrentAccount(body.getCode()); accountCancellationService.cancelCurrentAccount(body.getCode());
StpUtil.logout(); StpUtil.logout(userId);
return R.ok("注销成功"); return R.ok("注销成功");
} }

View File

@@ -110,6 +110,7 @@ public class CaptchaController {
/** /**
* 注销账号验证码 * 注销账号验证码
*/ */
@RateLimiter(key = "#{T(org.dromara.common.satoken.utils.LoginHelper).getUserId()}", time = 60, count = 1)
@GetMapping("/resource/account/cancel/code") @GetMapping("/resource/account/cancel/code")
public R<Void> accountCancelCode() { public R<Void> accountCancelCode() {
StpUtil.checkLogin(); StpUtil.checkLogin();
@@ -129,7 +130,7 @@ public class CaptchaController {
if (!mailProperties.getEnabled()) { if (!mailProperties.getEnabled()) {
return R.fail("当前系统没有开启邮箱功能!"); return R.fail("当前系统没有开启邮箱功能!");
} }
emailCodeImpl(user.getEmail(), username); SpringUtils.getAopProxy(this).emailCodeImpl(user.getEmail(), username);
return R.ok("操作成功"); return R.ok("操作成功");
} }
return R.fail("当前账号未绑定手机号或邮箱"); return R.fail("当前账号未绑定手机号或邮箱");

View File

@@ -0,0 +1,36 @@
package org.dromara.web.service;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.springframework.stereotype.Service;
/**
* 注销账号验证码服务
*/
@RequiredArgsConstructor
@Service
public class AccountCancelCodeService {
public void validate(String username, String code) {
String cacheCode = RedisUtils.getCacheObject(buildKey(username));
if (StringUtils.isBlank(cacheCode)) {
throw new CaptchaExpireException();
}
if (!StringUtils.equals(cacheCode, code)) {
throw new UserException("验证码无效");
}
}
public void delete(String username) {
RedisUtils.deleteObject(buildKey(username));
}
private String buildKey(String username) {
return GlobalConstants.CAPTCHA_CODE_KEY + username;
}
}

View File

@@ -0,0 +1,156 @@
package org.dromara.web.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.dromara.app.domain.*;
import org.dromara.app.mapper.*;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.SysSocial;
import org.dromara.system.domain.SysUserPost;
import org.dromara.system.domain.SysUserRole;
import org.dromara.system.mapper.SysSocialMapper;
import org.dromara.system.mapper.SysUserMapper;
import org.dromara.system.mapper.SysUserPostMapper;
import org.dromara.system.mapper.SysUserRoleMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Objects;
@RequiredArgsConstructor
@Service
public class AccountCancellationService {
private final SysUserMapper sysUserMapper;
private final SysUserRoleMapper sysUserRoleMapper;
private final SysUserPostMapper sysUserPostMapper;
private final SysSocialMapper sysSocialMapper;
private final AppDeviceMapper appDeviceMapper;
private final AppScheduleMapper appScheduleMapper;
private final AppScheduleDetailMapper appScheduleDetailMapper;
private final AppSchedulingDeviceMapper appSchedulingDeviceMapper;
private final AppWateringLogMapper appWateringLogMapper;
private final AccountCancelCodeService accountCancelCodeService;
private final IDeviceCommandService deviceCommandService;
@Transactional(rollbackFor = Exception.class)
public void cancelCurrentAccount(String code) {
Long userId = LoginHelper.getUserId();
if (userId == null) {
throw new ServiceException("用户未登录");
}
if (LoginHelper.isSuperAdmin(userId)) {
throw new ServiceException("超级管理员账号不允许注销");
}
String username = getCurrentUsername();
accountCancelCodeService.validate(username, code);
List<String> deviceNos = queryUserDeviceNos(userId);
List<Long> scheduleIds = queryUserScheduleIds(userId);
sendInitDeviceCommands(deviceNos);
deleteAppScheduleData(userId, scheduleIds);
deleteAppDeviceData(userId, deviceNos);
deleteSystemUserData(userId);
accountCancelCodeService.delete(username);
}
private String getCurrentUsername() {
String username = LoginHelper.getUsername();
if (StringUtils.isBlank(username)) {
throw new ServiceException("用户未登录");
}
return username;
}
private List<String> queryUserDeviceNos(Long userId) {
return appDeviceMapper.selectList(
Wrappers.<AppDevice>lambdaQuery()
.select(AppDevice::getDeviceNo)
.eq(AppDevice::getUserId, userId)
).stream()
.map(AppDevice::getDeviceNo)
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
}
private List<Long> queryUserScheduleIds(Long userId) {
return appScheduleMapper.selectList(
Wrappers.<AppSchedule>lambdaQuery()
.select(AppSchedule::getId)
.eq(AppSchedule::getUserId, userId)
).stream()
.map(AppSchedule::getId)
.filter(Objects::nonNull)
.distinct()
.toList();
}
private void sendInitDeviceCommands(List<String> deviceNos) {
for (String deviceNo : deviceNos) {
deviceCommandService.sendInitDeviceCommand(deviceNo);
}
}
private void deleteAppScheduleData(Long userId, List<Long> scheduleIds) {
if (!scheduleIds.isEmpty()) {
appSchedulingDeviceMapper.delete(
Wrappers.<AppSchedulingDevice>lambdaQuery()
.in(AppSchedulingDevice::getScheduleId, scheduleIds)
);
appScheduleDetailMapper.delete(
Wrappers.<AppScheduleDetail>lambdaQuery()
.in(AppScheduleDetail::getScheduleId, scheduleIds)
);
}
appScheduleMapper.delete(
Wrappers.<AppSchedule>lambdaQuery()
.eq(AppSchedule::getUserId, userId)
);
}
private void deleteAppDeviceData(Long userId, List<String> deviceNos) {
if (!deviceNos.isEmpty()) {
appSchedulingDeviceMapper.delete(
Wrappers.<AppSchedulingDevice>lambdaQuery()
.in(AppSchedulingDevice::getDeviceNo, deviceNos)
);
appWateringLogMapper.delete(
Wrappers.<AppWateringLog>lambdaQuery()
.in(AppWateringLog::getDeviceNo, deviceNos)
);
}
appWateringLogMapper.delete(
Wrappers.<AppWateringLog>lambdaQuery()
.eq(AppWateringLog::getUserId, userId)
);
appDeviceMapper.delete(
Wrappers.<AppDevice>lambdaQuery()
.eq(AppDevice::getUserId, userId)
);
}
private void deleteSystemUserData(Long userId) {
sysSocialMapper.delete(
Wrappers.<SysSocial>lambdaQuery()
.eq(SysSocial::getUserId, userId)
);
sysUserRoleMapper.delete(
Wrappers.<SysUserRole>lambdaQuery()
.eq(SysUserRole::getUserId, userId)
);
sysUserPostMapper.delete(
Wrappers.<SysUserPost>lambdaQuery()
.eq(SysUserPost::getUserId, userId)
);
int rows = sysUserMapper.deleteById(userId);
if (rows < 1) {
throw new ServiceException("注销账号失败");
}
}
}

View File

@@ -4,7 +4,7 @@ spring.servlet.multipart.location: /water/server/temp
--- # 监控中心配置 --- # 监控中心配置
spring.boot.admin.client: spring.boot.admin.client:
# 增加客户端开关 # 增加客户端开关
enabled: true enabled: false
url: http://localhost:9090/admin url: http://localhost:9090/admin
instance: instance:
service-host-type: IP service-host-type: IP
@@ -16,7 +16,7 @@ spring.boot.admin.client:
--- # snail-job 配置 --- # snail-job 配置
snail-job: snail-job:
enabled: true enabled: false
# 需要在 SnailJob 后台组管理创建对应名称的组,然后创建任务的时候选择对应的组,才能正确分派任务 # 需要在 SnailJob 后台组管理创建对应名称的组,然后创建任务的时候选择对应的组,才能正确分派任务
group: "water_group" group: "water_group"
# SnailJob 接入验证令牌 详见 script/sql/ry_job.sql `sj_group_config`表 # SnailJob 接入验证令牌 详见 script/sql/ry_job.sql `sj_group_config`表

View File

@@ -1,7 +1,7 @@
# 开发环境配置 # 开发环境配置
server: server:
# 服务器的HTTP端口默认为8080 # 服务器的HTTP端口默认为8080
port: 8081 port: 8082
servlet: servlet:
# 应用的访问路径 # 应用的访问路径
context-path: / context-path: /
@@ -26,9 +26,9 @@ mqtt:
broker-url: tcp://47.97.217.123 broker-url: tcp://47.97.217.123
username: admin username: admin
password: 61e6062129e9 password: 61e6062129e9
client-id: water-server-${server.port} client-id: water-server-1${server.port}
qos: 1 qos: 1
keep-alive: 60 keep-alive: 300
connection-timeout: 30 connection-timeout: 30
max-inflight: 5000 max-inflight: 5000
clean-session: false clean-session: false
@@ -55,9 +55,10 @@ mqtt:
ack-key-prefix: "mqtt:command:ack:" ack-key-prefix: "mqtt:command:ack:"
pending-set-key: "mqtt:command:pending:ids" pending-set-key: "mqtt:command:pending:ids"
device-status-cache-prefix: "mqtt:device:status:" device-status-cache-prefix: "mqtt:device:status:"
device-status-cache-ttl-seconds: 300 device-status-cache-ttl-seconds: 900
offline-check: offline-check:
enabled: true enabled: true
ttl-compat-enabled: true
interval-ms: 90000 interval-ms: 90000
topics: topics:
# 订阅/下发主题第一段为设备 MAC小写、去分隔符上行业务侧由 DeviceIdentityResolver 解析为设备编号 # 订阅/下发主题第一段为设备 MAC小写、去分隔符上行业务侧由 DeviceIdentityResolver 解析为设备编号
@@ -65,6 +66,7 @@ mqtt:
- /+/publish/finish/schedule #排程任务完成上报 - /+/publish/finish/schedule #排程任务完成上报
- /+/publish/register #设备注册 - /+/publish/register #设备注册
- /+/publish/status #设备在线/离线状态online/offline离线配合设备 LWT
- /+/publish/power #电量 - /+/publish/power #电量
- /+/publish/ack #应答 - /+/publish/ack #应答
- /+/publish/start #开始浇水上报 - /+/publish/start #开始浇水上报
@@ -236,7 +238,7 @@ api-decrypt:
springdoc: springdoc:
api-docs: api-docs:
# 是否开启接口文档 # 是否开启接口文档
enabled: true enabled: false
info: info:
# 标题 # 标题
title: '标题water-Vue-Plus多租户管理系统_接口文档' title: '标题water-Vue-Plus多租户管理系统_接口文档'
@@ -306,7 +308,7 @@ websocket:
--- # warm-flow工作流配置 --- # warm-flow工作流配置
warm-flow: warm-flow:
# 是否开启工作流默认true # 是否开启工作流默认true
enabled: true enabled: false
# 是否开启设计器ui # 是否开启设计器ui
ui: true ui: true
# 是否显示流程图顶部文字 # 是否显示流程图顶部文字

View File

@@ -0,0 +1,65 @@
package org.dromara.web.config;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.yaml.snakeyaml.Yaml;
import java.io.InputStream;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Tag("dev")
class MqttCommandAckConfigUnitTest {
@Test
void commandAckMaxRetryCountIsThree() throws Exception {
Integer maxRetryCount = null;
Yaml yaml = new Yaml();
try (InputStream inputStream = new ClassPathResource("application.yml").getInputStream()) {
for (Object document : yaml.loadAll(inputStream)) {
if (!(document instanceof Map<?, ?> root)) {
continue;
}
Object mqtt = root.get("mqtt");
if (!(mqtt instanceof Map<?, ?> mqttConfig)) {
continue;
}
Object commandAck = mqttConfig.get("command-ack");
if (!(commandAck instanceof Map<?, ?> commandAckConfig)) {
continue;
}
Object value = commandAckConfig.get("max-retry-count");
if (value instanceof Number number) {
maxRetryCount = number.intValue();
}
}
}
assertThat(maxRetryCount).isEqualTo(3);
}
@Test
void offlineCheckTtlCompatibilityIsEnabled() throws Exception {
Boolean ttlCompatEnabled = null;
Yaml yaml = new Yaml();
try (InputStream inputStream = new ClassPathResource("application.yml").getInputStream()) {
for (Object document : yaml.loadAll(inputStream)) {
if (!(document instanceof Map<?, ?> root)) {
continue;
}
Object mqtt = root.get("mqtt");
if (!(mqtt instanceof Map<?, ?> mqttConfig)) {
continue;
}
Object offlineCheck = mqttConfig.get("offline-check");
if (offlineCheck instanceof Map<?, ?> offlineCheckConfig) {
ttlCompatEnabled = (Boolean) offlineCheckConfig.get("ttl-compat-enabled");
}
}
}
assertThat(ttlCompatEnabled).isTrue();
}
}

View File

@@ -0,0 +1,55 @@
package org.dromara.web.controller;
import cn.dev33.satoken.stp.StpUtil;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.AccountCancelBody;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.web.service.AccountCancellationService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class AuthControllerUnitTest {
@Mock
private AccountCancellationService accountCancellationService;
@Test
void cancelAccount_checksLoginCancelsAccountAndLogsOut() {
AuthController controller = new AuthController(
null,
null,
null,
null,
null,
null,
null,
null,
null,
accountCancellationService
);
try (MockedStatic<StpUtil> stpUtil = mockStatic(StpUtil.class);
MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
AccountCancelBody body = new AccountCancelBody();
body.setCode("123456");
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
R<Void> result = controller.cancelAccount(body);
assertThat(result.getCode()).isEqualTo(200);
verify(accountCancellationService).cancelCurrentAccount("123456");
stpUtil.verify(StpUtil::checkLogin);
stpUtil.verify(() -> StpUtil.logout(100L));
}
}
}

View File

@@ -0,0 +1,119 @@
package org.dromara.web.controller;
import cn.dev33.satoken.stp.StpUtil;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.mail.config.properties.MailProperties;
import org.dromara.common.ratelimiter.annotation.RateLimiter;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.vo.SysUserVo;
import org.dromara.system.service.ISysUserService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class CaptchaControllerUnitTest {
@Mock
private MailProperties mailProperties;
@Mock
private ISysUserService userService;
@Test
void accountCancelCode_sendsSmsCodeWhenCurrentUserHasPhone() {
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
SysUserVo user = new SysUserVo();
user.setPhonenumber("13305376054");
user.setEmail("demo@example.com");
when(userService.selectUserById(100L)).thenReturn(user);
doReturn(R.ok("操作成功")).when(controller).sendSmsCode("13305376054", "zhangsan");
try (MockedStatic<StpUtil> stpUtil = mockStatic(StpUtil.class);
MockedStatic<SpringUtils> springUtils = mockStatic(SpringUtils.class);
MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
loginHelper.when(LoginHelper::getUsername).thenReturn("zhangsan");
springUtils.when(() -> SpringUtils.getAopProxy(controller)).thenReturn(controller);
R<Void> result = controller.accountCancelCode();
assertThat(result.getCode()).isEqualTo(200);
stpUtil.verify(StpUtil::checkLogin);
verify(controller).sendSmsCode("13305376054", "zhangsan");
verify(controller, never()).emailCodeImpl("demo@example.com");
}
}
@Test
void accountCancelCode_sendsEmailCodeWhenCurrentUserHasNoPhone() {
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
SysUserVo user = new SysUserVo();
user.setEmail("demo@example.com");
when(userService.selectUserById(100L)).thenReturn(user);
when(mailProperties.getEnabled()).thenReturn(true);
doNothing().when(controller).emailCodeImpl("demo@example.com", "zhangsan");
try (MockedStatic<StpUtil> stpUtil = mockStatic(StpUtil.class);
MockedStatic<SpringUtils> springUtils = mockStatic(SpringUtils.class);
MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
loginHelper.when(LoginHelper::getUsername).thenReturn("zhangsan");
springUtils.when(() -> SpringUtils.getAopProxy(controller)).thenReturn(controller);
R<Void> result = controller.accountCancelCode();
assertThat(result.getCode()).isEqualTo(200);
stpUtil.verify(StpUtil::checkLogin);
springUtils.verify(() -> SpringUtils.getAopProxy(controller));
verify(controller).emailCodeImpl("demo@example.com", "zhangsan");
verify(controller, never()).sendSmsCode("demo@example.com", "zhangsan");
}
}
@Test
void accountCancelCode_hasPerUserRateLimiter() throws NoSuchMethodException {
Method method = CaptchaController.class.getDeclaredMethod("accountCancelCode");
RateLimiter rateLimiter = method.getAnnotation(RateLimiter.class);
assertThat(rateLimiter).isNotNull();
assertThat(rateLimiter.time()).isEqualTo(60);
assertThat(rateLimiter.count()).isEqualTo(1);
assertThat(rateLimiter.key())
.isEqualTo("#{T(org.dromara.common.satoken.utils.LoginHelper).getUserId()}");
}
@Test
void accountCancelCode_failsWhenCurrentUserHasNoPhoneOrEmail() {
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
SysUserVo user = new SysUserVo();
when(userService.selectUserById(100L)).thenReturn(user);
try (MockedStatic<StpUtil> stpUtil = mockStatic(StpUtil.class);
MockedStatic<SpringUtils> springUtils = mockStatic(SpringUtils.class);
MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
loginHelper.when(LoginHelper::getUsername).thenReturn("zhangsan");
springUtils.when(() -> SpringUtils.getAopProxy(controller)).thenReturn(controller);
R<Void> result = controller.accountCancelCode();
assertThat(result.getCode()).isEqualTo(500);
assertThat(result.getMsg()).isEqualTo("当前账号未绑定手机号或邮箱");
stpUtil.verify(StpUtil::checkLogin);
verify(controller, never()).sendSmsCode("13305376054", "zhangsan");
verify(controller, never()).emailCodeImpl("demo@example.com", "zhangsan");
}
}
}

View File

@@ -0,0 +1,215 @@
package org.dromara.web.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.dromara.app.domain.*;
import org.dromara.app.mapper.*;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.SysSocial;
import org.dromara.system.domain.SysUserPost;
import org.dromara.system.domain.SysUserRole;
import org.dromara.system.mapper.SysSocialMapper;
import org.dromara.system.mapper.SysUserMapper;
import org.dromara.system.mapper.SysUserPostMapper;
import org.dromara.system.mapper.SysUserRoleMapper;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class AccountCancellationServiceUnitTest {
@Mock
private SysUserMapper sysUserMapper;
@Mock
private SysUserRoleMapper sysUserRoleMapper;
@Mock
private SysUserPostMapper sysUserPostMapper;
@Mock
private SysSocialMapper sysSocialMapper;
@Mock
private AppDeviceMapper appDeviceMapper;
@Mock
private AppScheduleMapper appScheduleMapper;
@Mock
private AppScheduleDetailMapper appScheduleDetailMapper;
@Mock
private AppSchedulingDeviceMapper appSchedulingDeviceMapper;
@Mock
private AppWateringLogMapper appWateringLogMapper;
@Mock
private AccountCancelCodeService accountCancelCodeService;
@Mock
private IDeviceCommandService deviceCommandService;
@BeforeAll
static void initTableInfo() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
initTableInfo(assistant, AppDevice.class);
initTableInfo(assistant, AppSchedule.class);
initTableInfo(assistant, AppScheduleDetail.class);
initTableInfo(assistant, AppSchedulingDevice.class);
initTableInfo(assistant, AppWateringLog.class);
initTableInfo(assistant, SysSocial.class);
initTableInfo(assistant, SysUserRole.class);
initTableInfo(assistant, SysUserPost.class);
}
private static void initTableInfo(MapperBuilderAssistant assistant, Class<?> entityClass) {
TableInfoHelper.remove(entityClass);
TableInfoHelper.initTableInfo(assistant, entityClass);
}
@Test
void cancelCurrentAccount_deletesCurrentUserAndRelatedData() {
AccountCancellationService service = newService();
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
AppSchedule schedule = new AppSchedule();
schedule.setId(10L);
when(appDeviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(device));
when(appScheduleMapper.selectList(any(Wrapper.class))).thenReturn(List.of(schedule));
when(sysUserMapper.deleteById(100L)).thenReturn(1);
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
loginHelper.when(LoginHelper::getUsername).thenReturn("zhangsan");
loginHelper.when(() -> LoginHelper.isSuperAdmin(100L)).thenReturn(false);
service.cancelCurrentAccount("123456");
verify(accountCancelCodeService).validate("zhangsan", "123456");
verify(deviceCommandService).sendInitDeviceCommand("D01");
verify(appSchedulingDeviceMapper, atLeastOnce()).delete(any(Wrapper.class));
verify(appScheduleDetailMapper).delete(any(Wrapper.class));
verify(appScheduleMapper).delete(any(Wrapper.class));
verify(appWateringLogMapper, atLeastOnce()).delete(any(Wrapper.class));
verify(appDeviceMapper).delete(any(Wrapper.class));
verify(sysSocialMapper).delete(any(Wrapper.class));
verify(sysUserRoleMapper).delete(any(Wrapper.class));
verify(sysUserPostMapper).delete(any(Wrapper.class));
verify(sysUserMapper).deleteById(100L);
verify(accountCancelCodeService).delete("zhangsan");
}
}
@Test
void cancelCurrentAccount_rejectsSuperAdmin() {
AccountCancellationService service = newService();
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(1L);
loginHelper.when(() -> LoginHelper.isSuperAdmin(1L)).thenReturn(true);
assertThatThrownBy(() -> service.cancelCurrentAccount("123456"))
.isInstanceOf(ServiceException.class)
.hasMessageContaining("超级管理员");
verifyNoInteractions(
sysUserMapper,
sysUserRoleMapper,
sysUserPostMapper,
sysSocialMapper,
appDeviceMapper,
appScheduleMapper,
appScheduleDetailMapper,
appSchedulingDeviceMapper,
appWateringLogMapper,
accountCancelCodeService
);
}
}
@Test
void cancelCurrentAccount_rejectsInvalidCodeWithoutDeletingData() {
AccountCancellationService service = newService();
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
loginHelper.when(LoginHelper::getUsername).thenReturn("zhangsan");
loginHelper.when(() -> LoginHelper.isSuperAdmin(100L)).thenReturn(false);
doThrow(new UserException("验证码无效"))
.when(accountCancelCodeService).validate("zhangsan", "654321");
assertThatThrownBy(() -> service.cancelCurrentAccount("654321"))
.isInstanceOf(UserException.class);
verifyNoInteractions(
sysUserMapper,
sysUserRoleMapper,
sysUserPostMapper,
sysSocialMapper,
appDeviceMapper,
appScheduleMapper,
appScheduleDetailMapper,
appSchedulingDeviceMapper,
appWateringLogMapper
);
verify(accountCancelCodeService).validate("zhangsan", "654321");
verify(accountCancelCodeService, never()).delete("zhangsan");
}
}
@Test
void cancelCurrentAccount_rejectsExpiredCodeWithoutDeletingData() {
AccountCancellationService service = newService();
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
loginHelper.when(LoginHelper::getUsername).thenReturn("zhangsan");
loginHelper.when(() -> LoginHelper.isSuperAdmin(100L)).thenReturn(false);
doThrow(new CaptchaExpireException())
.when(accountCancelCodeService).validate("zhangsan", "123456");
assertThatThrownBy(() -> service.cancelCurrentAccount("123456"))
.isInstanceOf(CaptchaExpireException.class);
verifyNoInteractions(
sysUserMapper,
sysUserRoleMapper,
sysUserPostMapper,
sysSocialMapper,
appDeviceMapper,
appScheduleMapper,
appScheduleDetailMapper,
appSchedulingDeviceMapper,
appWateringLogMapper
);
verify(accountCancelCodeService).validate("zhangsan", "123456");
verify(accountCancelCodeService, never()).delete("zhangsan");
}
}
private AccountCancellationService newService() {
return new AccountCancellationService(
sysUserMapper,
sysUserRoleMapper,
sysUserPostMapper,
sysSocialMapper,
appDeviceMapper,
appScheduleMapper,
appScheduleDetailMapper,
appSchedulingDeviceMapper,
appWateringLogMapper,
accountCancelCodeService,
deviceCommandService
);
}
}

View File

@@ -0,0 +1,24 @@
package org.dromara.common.core.domain.model;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 注销账号请求体
*/
@Data
public class AccountCancelBody implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 验证码
*/
@NotBlank(message = "{sms.code.not.blank}")
private String code;
}

View File

@@ -37,65 +37,18 @@ public class MqttClientManager implements DisposableBean {
private final MqttMessageDispatcher dispatcher; private final MqttMessageDispatcher dispatcher;
private MqttAsyncClient client; private MqttAsyncClient client;
private ThreadPoolExecutor consumerExecutor; private ThreadPoolExecutor consumerExecutor;
private BlockingQueue<InboundMessage> messageQueue; private List<BlockingQueue<InboundMessage>> messageQueues;
private volatile boolean running; private volatile boolean running;
private int consumerCount;
@Bean static int deviceIdentityHash(String topic) {
@ConditionalOnProperty(prefix = "mqtt", name = "enabled", havingValue = "true") if (topic == null) {
public MqttAsyncClient mqttConnect() throws MqttException { return 0;
if (StringUtils.isBlank(props.getBrokerUrl())) {
throw new ServiceException("启用 MQTT 时必须配置 broker-url");
} }
if (StringUtils.isBlank(props.getClientId())) { int start = topic.startsWith("/") ? 1 : 0;
throw new ServiceException("启用 MQTT 时必须配置 client-id"); int end = topic.indexOf('/', start);
} String identity = end < 0 ? topic.substring(start) : topic.substring(start, end);
messageQueue = new ArrayBlockingQueue<>(Math.max(1, props.getAsync().getQueueCapacity())); return identity.hashCode();
consumerExecutor = createConsumerExecutor();
running = true;
startConsumers();
client = new MqttAsyncClient(props.getBrokerUrl(), props.getClientId(), new MemoryPersistence());
MqttConnectOptions options = new MqttConnectOptions();
options.setUserName(props.getUsername());
if (props.getPassword() != null) {
options.setPassword(props.getPassword().toCharArray());
}
options.setKeepAliveInterval(props.getKeepAlive());
options.setConnectionTimeout(props.getConnectionTimeout());
options.setAutomaticReconnect(props.isAutomaticReconnect());
options.setCleanSession(props.isCleanSession());
options.setMaxInflight(props.getMaxInflight());
configureTls(options);
client.setCallback(new MqttCallbackExtended() {
@Override
public void connectionLost(Throwable cause) {
log.warn("[MQTT] 连接已断开:{}", cause == null ? "" : cause.getMessage());
}
@Override
public void connectComplete(boolean reconnect, String serverURI) {
if (reconnect) {
log.info("[MQTT] 已重新连接:{}", serverURI);
subscribeTopics();
}
}
@Override
public void messageArrived(String topic, MqttMessage message) {
String payload = new String(message.getPayload(), StandardCharsets.UTF_8);
enqueue(topic, payload);
}
@Override
public void deliveryComplete(IMqttDeliveryToken token) {
}
});
client.connect(options).waitForCompletion();
log.info("[MQTT] 已连接:{}", props.getBrokerUrl());
subscribeTopics();
return client;
} }
private void configureTls(MqttConnectOptions options) throws MqttException { private void configureTls(MqttConnectOptions options) throws MqttException {
@@ -171,39 +124,97 @@ public class MqttClientManager implements DisposableBean {
}; };
} }
@Bean
@ConditionalOnProperty(prefix = "mqtt", name = "enabled", havingValue = "true")
public MqttAsyncClient mqttConnect() throws MqttException {
if (StringUtils.isBlank(props.getBrokerUrl())) {
throw new ServiceException("启用 MQTT 时必须配置 broker-url");
}
if (StringUtils.isBlank(props.getClientId())) {
throw new ServiceException("启用 MQTT 时必须配置 client-id");
}
consumerCount = Math.max(1, props.getAsync().getConsumerCount());
messageQueues = createMessageQueues();
consumerExecutor = createConsumerExecutor();
running = true;
startConsumers();
client = new MqttAsyncClient(props.getBrokerUrl(), props.getClientId(), new MemoryPersistence());
MqttConnectOptions options = new MqttConnectOptions();
options.setUserName(props.getUsername());
if (props.getPassword() != null) {
options.setPassword(props.getPassword().toCharArray());
}
options.setKeepAliveInterval(props.getKeepAlive());
options.setConnectionTimeout(props.getConnectionTimeout());
options.setAutomaticReconnect(props.isAutomaticReconnect());
options.setCleanSession(props.isCleanSession());
options.setMaxInflight(props.getMaxInflight());
configureTls(options);
client.setCallback(new MqttCallbackExtended() {
@Override
public void connectionLost(Throwable cause) {
log.warn("[MQTT] 连接已断开:{}", cause == null ? "" : cause.getMessage());
}
@Override
public void connectComplete(boolean reconnect, String serverURI) {
if (reconnect) {
log.info("[MQTT] 已重新连接:{}", serverURI);
subscribeTopics();
}
}
@Override
public void messageArrived(String topic, MqttMessage message) {
String payload = new String(message.getPayload(), StandardCharsets.UTF_8);
enqueue(topic, payload, message.isRetained());
}
@Override
public void deliveryComplete(IMqttDeliveryToken token) {
}
});
client.connect(options).waitForCompletion();
log.info("[MQTT] 已连接:{}", props.getBrokerUrl());
subscribeTopics();
return client;
}
private void startConsumers() { private void startConsumers() {
int consumerCount = Math.max(1, props.getAsync().getConsumerCount());
for (int i = 0; i < consumerCount; i++) { for (int i = 0; i < consumerCount; i++) {
consumerExecutor.execute(this::consumeLoop); final int consumerIndex = i;
consumerExecutor.execute(() -> consumeLoop(consumerIndex));
} }
} }
private void enqueue(String topic, String payload) { private void enqueue(String topic, String payload, boolean retained) {
InboundMessage inboundMessage = new InboundMessage(topic, payload); InboundMessage inboundMessage = new InboundMessage(topic, payload, retained);
try { try {
if (!messageQueue.offer(inboundMessage, props.getAsync().getOfferTimeoutMs(), TimeUnit.MILLISECONDS)) { messageQueues.get(queueIndex(topic)).put(inboundMessage);
log.warn("[MQTT] 上行消息队列已满,丢弃 主题={}", topic);
}
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
log.warn("[MQTT] 上行消息入队被中断 主题={}", topic); log.warn("[MQTT] 上行消息入队被中断 主题={}", topic);
} }
} }
private void consumeLoop() { private void consumeLoop(int consumerIndex) {
MqttProperties.Async async = props.getAsync(); MqttProperties.Async async = props.getAsync();
int batchSize = Math.max(1, async.getBatchSize()); int batchSize = Math.max(1, async.getBatchSize());
long pollTimeoutMs = Math.max(1, async.getPollTimeoutMs()); long pollTimeoutMs = Math.max(1, async.getPollTimeoutMs());
List<InboundMessage> batch = new java.util.ArrayList<>(batchSize); List<InboundMessage> batch = new java.util.ArrayList<>(batchSize);
BlockingQueue<InboundMessage> queue = messageQueues.get(consumerIndex);
while (running || !messageQueue.isEmpty()) { while (running || !queue.isEmpty()) {
try { try {
InboundMessage first = messageQueue.poll(pollTimeoutMs, TimeUnit.MILLISECONDS); InboundMessage first = queue.poll(pollTimeoutMs, TimeUnit.MILLISECONDS);
if (first == null) { if (first == null) {
continue; continue;
} }
batch.add(first); batch.add(first);
messageQueue.drainTo(batch, batchSize - 1); queue.drainTo(batch, batchSize - 1);
for (InboundMessage message : batch) { for (InboundMessage message : batch) {
dispatch(message); dispatch(message);
} }
@@ -218,12 +229,26 @@ public class MqttClientManager implements DisposableBean {
private void dispatch(InboundMessage message) { private void dispatch(InboundMessage message) {
try { try {
dispatcher.dispatch(message.topic(), message.payload()); dispatcher.dispatch(message.topic(), message.payload(), message.retained());
} catch (Exception e) { } catch (Exception e) {
log.error("[MQTT] 消息分发失败 主题={}", message.topic(), e); log.error("[MQTT] 消息分发失败 主题={}", message.topic(), e);
} }
} }
private List<BlockingQueue<InboundMessage>> createMessageQueues() {
int capacity = Math.max(1, props.getAsync().getQueueCapacity());
int queueCapacity = Math.max(1, (capacity + Math.max(1, consumerCount) - 1) / Math.max(1, consumerCount));
List<BlockingQueue<InboundMessage>> queues = new java.util.ArrayList<>(consumerCount);
for (int i = 0; i < consumerCount; i++) {
queues.add(new ArrayBlockingQueue<>(queueCapacity));
}
return queues;
}
private int queueIndex(String topic) {
return Math.floorMod(deviceIdentityHash(topic), Math.max(1, consumerCount));
}
private void subscribeTopics() { private void subscribeTopics() {
try { try {
if (client == null || !client.isConnected()) { if (client == null || !client.isConnected()) {
@@ -276,6 +301,6 @@ public class MqttClientManager implements DisposableBean {
} }
} }
private record InboundMessage(String topic, String payload) { private record InboundMessage(String topic, String payload, boolean retained) {
} }
} }

View File

@@ -22,6 +22,7 @@ import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.*; import java.util.*;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
@Slf4j @Slf4j
@Service @Service
@@ -43,11 +44,10 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
pendingIds().add(command.getCommandId()); pendingIds().add(command.getCommandId());
} }
public void removePending(String commandId) { static boolean ackMatchesPendingCommand(String topicDeviceNo, DeviceCommand pendingCommand) {
if (StringUtils.isBlank(commandId)) { return StringUtils.isNotBlank(topicDeviceNo)
return; && pendingCommand != null
} && topicDeviceNo.equals(pendingCommand.getDeviceNo());
withCommandLock(commandId, () -> deletePending(commandId));
} }
static boolean resolveMissingCommandId(DeviceCommandAck ack, List<DeviceCommand> pendingCommands) { static boolean resolveMissingCommandId(DeviceCommandAck ack, List<DeviceCommand> pendingCommands) {
@@ -68,6 +68,16 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
return true; return true;
} }
public void removePending(String commandId) {
if (StringUtils.isBlank(commandId)) {
return;
}
withCommandLock(commandId, () -> {
deletePending(commandId);
return true;
});
}
@Override @Override
public void handleAck(String deviceNo, String payload) { public void handleAck(String deviceNo, String payload) {
if (StringUtils.isBlank(payload)) { if (StringUtils.isBlank(payload)) {
@@ -102,8 +112,19 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
} }
} }
boolean ackSaved = withCommandLock(ack.getCommandId(), () -> { boolean ackSaved = withCommandLock(ack.getCommandId(), () -> {
DeviceCommand pending = RedisUtils.getCacheObject(pendingKey(ack.getCommandId()));
if (pending == null) {
log.warn("[MQTT] ACK 对应命令不存在或已过期 设备编号={} 命令编号={}", deviceNo, ack.getCommandId());
return false;
}
if (!ackMatchesPendingCommand(deviceNo, pending)) {
log.warn("[MQTT] ACK 设备编号与待确认命令不匹配,已拒绝 设备编号={} 命令编号={} 命令设备={}",
deviceNo, ack.getCommandId(), pending.getDeviceNo());
return false;
}
deletePending(ack.getCommandId()); deletePending(ack.getCommandId());
RedisUtils.setCacheObject(ackKey(ack.getCommandId()), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds())); RedisUtils.setCacheObject(ackKey(ack.getCommandId()), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
return true;
}); });
if (!ackSaved) { if (!ackSaved) {
log.warn("[MQTT] ACK 处理未获得命令锁,本次跳过 设备编号={} 命令编号={}", deviceNo, ack.getCommandId()); log.warn("[MQTT] ACK 处理未获得命令锁,本次跳过 设备编号={} 命令编号={}", deviceNo, ack.getCommandId());
@@ -130,8 +151,19 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
ack.setMessage(payload); ack.setMessage(payload);
boolean ackSaved = withCommandLock(commandId, () -> { boolean ackSaved = withCommandLock(commandId, () -> {
DeviceCommand pending = RedisUtils.getCacheObject(pendingKey(commandId));
if (pending == null) {
log.warn("[MQTT] 非 JSON ACK 对应命令不存在或已过期 设备编号={} 命令编号={}", deviceNo, commandId);
return false;
}
if (!ackMatchesPendingCommand(deviceNo, pending)) {
log.warn("[MQTT] 非 JSON ACK 设备编号与待确认命令不匹配,已拒绝 设备编号={} 命令编号={} 命令设备={}",
deviceNo, commandId, pending.getDeviceNo());
return false;
}
deletePending(commandId); deletePending(commandId);
RedisUtils.setCacheObject(ackKey(commandId), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds())); RedisUtils.setCacheObject(ackKey(commandId), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
return true;
}); });
if (!ackSaved) { if (!ackSaved) {
log.warn("[MQTT] 非 JSON ACK 处理未获得命令锁,本次跳过 设备编号={} 命令编号={}", deviceNo, commandId); log.warn("[MQTT] 非 JSON ACK 处理未获得命令锁,本次跳过 设备编号={} 命令编号={}", deviceNo, commandId);
@@ -160,7 +192,10 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
} }
private void retryCommandIfLocked(String commandId, long now) { private void retryCommandIfLocked(String commandId, long now) {
withCommandLock(commandId, () -> retryCommand(commandId, now)); withCommandLock(commandId, () -> {
retryCommand(commandId, now);
return true;
});
} }
private void retryCommand(String commandId, long now) { private void retryCommand(String commandId, long now) {
@@ -178,24 +213,24 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
return; return;
} }
if (!isDeviceOnline(command.getDeviceNo())) { if (!isDeviceOnline(command.getDeviceNo())) {
command.setRetryCount(command.getRetryCount() + 1);
if (command.getRetryCount() >= mqttProperties.getCommandAck().getMaxRetryCount()) {
deletePending(commandId);
log.warn("[MQTT] 设备离线,命令达到最大等待次数,已删除待确认命令 设备编号={} 命令编号={}", command.getDeviceNo(), commandId);
return;
}
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs()); command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
savePending(command); savePending(command);
log.debug("[MQTT] 设备离线,命令延后重试 设备编号={} 命令编号={} 等待次数={}", command.getDeviceNo(), commandId, command.getRetryCount()); log.debug("[MQTT] 设备离线,命令延后重试 设备编号={} 命令编号={}", command.getDeviceNo(), commandId);
return; return;
} }
command.setRetryCount(command.getRetryCount() + 1); command.setRetryCount(command.getRetryCount() + 1);
command.setLastSentAt(now); command.setLastSentAt(now);
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs()); command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
mqttClientManager.publish(command.getTopic(), JsonUtils.toJsonString(command.getPayload())); try {
savePending(command); mqttClientManager.publish(command.getTopic(), JsonUtils.toJsonString(command.getPayload()));
log.info("[MQTT] 命令已重新下发 设备编号={} 命令编号={} 重试次数={}", command.getDeviceNo(), commandId, command.getRetryCount()); savePending(command);
log.info("[MQTT] 命令已重新下发 设备编号={} 命令编号={} 重试次数={}", command.getDeviceNo(), commandId, command.getRetryCount());
} catch (RuntimeException e) {
savePending(command);
log.warn("[MQTT] 命令重新下发失败,已延后重试 设备编号={} 命令编号={} 重试次数={}",
command.getDeviceNo(), commandId, command.getRetryCount(), e);
}
} }
private boolean isDeviceOnline(String deviceNo) { private boolean isDeviceOnline(String deviceNo) {
@@ -249,7 +284,7 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
pendingIds().remove(commandId); pendingIds().remove(commandId);
} }
private boolean withCommandLock(String commandId, Runnable action) { private boolean withCommandLock(String commandId, BooleanSupplier action) {
RLock lock = RedisUtils.getClient().getLock(mqttProperties.getCommandAck().getRetryLockKeyPrefix() + commandId); RLock lock = RedisUtils.getClient().getLock(mqttProperties.getCommandAck().getRetryLockKeyPrefix() + commandId);
boolean locked = false; boolean locked = false;
try { try {
@@ -257,8 +292,7 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
if (!locked) { if (!locked) {
return false; return false;
} }
action.run(); return action.getAsBoolean();
return true;
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
log.warn("[MQTT] 命令锁等待被中断 命令编号={}", commandId); log.warn("[MQTT] 命令锁等待被中断 命令编号={}", commandId);

View File

@@ -18,7 +18,7 @@ public class MqttProperties {
private String password; private String password;
private String clientId; private String clientId;
private int qos = 1; private int qos = 1;
private int keepAlive = 60; private int keepAlive = 300;
private int connectionTimeout = 30; private int connectionTimeout = 30;
private int maxInflight = 1000; private int maxInflight = 1000;
private boolean cleanSession = false; private boolean cleanSession = false;
@@ -65,7 +65,7 @@ public class MqttProperties {
private String retryLockKeyPrefix = "lock:mqtt:command:retry:"; private String retryLockKeyPrefix = "lock:mqtt:command:retry:";
private long retryLockTtlMs = 30000; private long retryLockTtlMs = 30000;
private String deviceStatusCachePrefix = "mqtt:device:status:"; private String deviceStatusCachePrefix = "mqtt:device:status:";
private long deviceStatusCacheTtlSeconds = 300; private long deviceStatusCacheTtlSeconds = 900;
} }
} }

View File

@@ -0,0 +1,26 @@
package org.dromara.mqtt;
import org.dromara.mqtt.config.properties.MqttProperties;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.util.ReflectionTestUtils.invokeMethod;
@Tag("dev")
class DeviceMqttCommandPublisherTest {
@Test
void buildCommandTopicDefaultsToDeviceNo() {
MqttProperties properties = new MqttProperties();
DeviceMqttCommandPublisher publisher = new DeviceMqttCommandPublisher(
null,
null,
properties
);
String topic = invokeMethod(publisher, "buildCommandTopic", "D01");
assertThat(topic).isEqualTo("/d01/subscriber/cmd");
}
}

View File

@@ -0,0 +1,122 @@
package org.dromara.mqtt;
import cn.hutool.extra.spring.SpringUtil;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.domain.mqtt.DeviceCommandAck;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.mqtt.config.properties.MqttProperties;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@Tag("dev")
class MqttCommandAckServiceTest {
private static GenericApplicationContext applicationContext;
@BeforeAll
static void initializeRedisUtils() {
if (TableInfoHelper.getTableInfo(AppDevice.class) == null) {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), ""), AppDevice.class);
}
applicationContext = new GenericApplicationContext();
applicationContext.registerBean(RedissonClient.class, () -> mock(RedissonClient.class));
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
}
@AfterAll
static void closeApplicationContext() {
applicationContext.close();
}
@Test
void resolveMissingCommandId_usesSinglePendingCommandForDevice() {
DeviceCommandAck ack = new DeviceCommandAck();
ack.setCommandId("");
DeviceCommand pending = new DeviceCommand();
pending.setCommandId("cmd-1");
pending.setDeviceNo("D01");
boolean resolved = MqttCommandAckService.resolveMissingCommandId(ack, List.of(pending));
assertThat(resolved).isTrue();
assertThat(ack.getCommandId()).isEqualTo("cmd-1");
assertThat(ack.getStatus()).isEqualTo("1");
}
@Test
void resolveMissingCommandId_refusesAmbiguousPendingCommands() {
DeviceCommandAck ack = new DeviceCommandAck();
ack.setCommandId("");
DeviceCommand first = new DeviceCommand();
first.setCommandId("cmd-1");
DeviceCommand second = new DeviceCommand();
second.setCommandId("cmd-2");
boolean resolved = MqttCommandAckService.resolveMissingCommandId(ack, List.of(first, second));
assertThat(resolved).isFalse();
assertThat(ack.getCommandId()).isEmpty();
}
@Test
void ackMatchesPendingCommandRequiresSameDevice() {
DeviceCommand pending = new DeviceCommand();
pending.setDeviceNo("D01");
assertThat(MqttCommandAckService.ackMatchesPendingCommand("D01", pending)).isTrue();
assertThat(MqttCommandAckService.ackMatchesPendingCommand("D02", pending)).isFalse();
assertThat(MqttCommandAckService.ackMatchesPendingCommand("D01", null)).isFalse();
}
@Test
void refreshDeviceOnlineRenewsStatusCacheTtl() throws Exception {
MqttProperties properties = new MqttProperties();
properties.getCommandAck().setDeviceStatusCacheTtlSeconds(900);
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
RedissonClient redissonClient = mock(RedissonClient.class);
RLock lock = mock(RLock.class);
when(redissonClient.getLock("lock:mqtt:device:status:D01")).thenReturn(lock);
when(lock.tryLock(0, 10, TimeUnit.SECONDS)).thenReturn(true);
when(lock.isHeldByCurrentThread()).thenReturn(true);
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
ReflectionTestUtils.invokeMethod(service, "refreshDeviceOnline", "D01");
redis.verify(() -> RedisUtils.setCacheObject(
eq("mqtt:device:status:D01"),
any(Map.class),
eq(Duration.ofSeconds(900))
));
verify(appDeviceMapper).update(eq(null), any());
verify(lock).unlock();
}
}
}

View File

@@ -0,0 +1,106 @@
package org.dromara.app.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import org.dromara.app.domain.bo.AppVersionBo;
import org.dromara.app.domain.vo.AppVersionVo;
import org.dromara.app.service.IAppVersionService;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.validate.AddGroup;
import org.dromara.common.core.validate.EditGroup;
import org.dromara.common.excel.utils.ExcelUtil;
import org.dromara.common.idempotent.annotation.RepeatSubmit;
import org.dromara.common.log.annotation.Log;
import org.dromara.common.log.enums.BusinessType;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.web.core.BaseController;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* APP版本
*
* @author Lion Li
* @date 2026-07-01
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/app/version")
public class AppVersionController extends BaseController {
private final IAppVersionService appVersionService;
/**
* 查询APP版本列表
*/
@SaCheckPermission("app:version:list")
@GetMapping("/list")
public TableDataInfo<AppVersionVo> list(AppVersionBo bo, PageQuery pageQuery) {
return appVersionService.queryPageList(bo, pageQuery);
}
/**
* 导出APP版本列表
*/
@SaCheckPermission("app:version:export")
@Log(title = "APP版本", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(AppVersionBo bo, HttpServletResponse response) {
List<AppVersionVo> list = appVersionService.queryList(bo);
ExcelUtil.exportExcel(list, "APP版本", AppVersionVo.class, response);
}
/**
* 获取APP版本详细信息
*
* @param id 主键
*/
@SaCheckPermission("app:version:query")
@GetMapping("/{id}")
public R<AppVersionVo> getInfo(@NotNull(message = "主键不能为空")
@PathVariable Long id) {
return R.ok(appVersionService.queryById(id));
}
/**
* 新增APP版本
*/
@SaCheckPermission("app:version:add")
@Log(title = "APP版本", businessType = BusinessType.INSERT)
@RepeatSubmit()
@PostMapping()
public R<Void> add(@Validated(AddGroup.class) @RequestBody AppVersionBo bo) {
return toAjax(appVersionService.insertByBo(bo));
}
/**
* 修改APP版本
*/
@SaCheckPermission("app:version:edit")
@Log(title = "APP版本", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PutMapping()
public R<Void> edit(@Validated(EditGroup.class) @RequestBody AppVersionBo bo) {
return toAjax(appVersionService.updateByBo(bo));
}
/**
* 删除APP版本
*
* @param ids 主键串
*/
@SaCheckPermission("app:version:remove")
@Log(title = "APP版本", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public R<Void> remove(@NotEmpty(message = "主键不能为空")
@PathVariable Long[] ids) {
return toAjax(appVersionService.deleteWithValidByIds(List.of(ids), true));
}
}

View File

@@ -0,0 +1,59 @@
package org.dromara.app.domain;
import com.baomidou.mybatisplus.annotation.TableId;
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版本对象 app_version
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("app_version")
public class AppVersion extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
@TableId(value = "id")
private Long id;
/**
* 平台 android/ios
*/
private String platform;
/**
* 最新版本号
*/
private String latestVersion;
/**
* 版本序号
*/
private Integer versionCode;
/**
* 是否强制更新 0否 1是
*/
private String forceUpdate;
/**
* 下载地址
*/
private String downloadUrl;
/**
* 更新说明
*/
private String releaseNotes;
/**
* 状态 0停用 1启用
*/
private String status;
}

View File

@@ -0,0 +1,76 @@
package org.dromara.app.domain.bo;
import io.github.linpeilie.annotations.AutoMapper;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.dromara.app.domain.AppVersion;
import org.dromara.common.core.validate.AddGroup;
import org.dromara.common.core.validate.EditGroup;
import org.dromara.common.mybatis.core.domain.BaseEntity;
/**
* APP版本业务对象 app_version
*
* @author Lion Li
* @date 2026-07-01
*/
@Data
@EqualsAndHashCode(callSuper = true)
@AutoMapper(target = AppVersion.class, reverseConvertGenerate = false)
public class AppVersionBo extends BaseEntity {
/**
* 主键
*/
@NotNull(message = "主键不能为空", groups = { EditGroup.class })
private Long id;
/**
* 平台 android/ios
*/
@NotBlank(message = "平台 android/ios不能为空", groups = { AddGroup.class, EditGroup.class })
private String platform;
/**
* 最新版本号
*/
@NotBlank(message = "最新版本号不能为空", groups = { AddGroup.class, EditGroup.class })
private String latestVersion;
/**
* 版本序号,用于排序取最新版本
*/
@NotNull(message = "版本序号,用于排序取最新版本不能为空", groups = { AddGroup.class, EditGroup.class })
private Long versionCode;
/**
* 是否强制更新 0否 1是
*/
@NotBlank(message = "是否强制更新 0否 1是不能为空", groups = { AddGroup.class, EditGroup.class })
private String forceUpdate;
/**
* 下载地址
*/
private String downloadUrl;
/**
* 更新说明
*/
private String releaseNotes;
/**
* 状态 0停用 1启用
*/
@NotBlank(message = "状态 0停用 1启用不能为空", groups = { AddGroup.class, EditGroup.class })
private String status;
/**
* 备注
*/
private String remark;
}

View File

@@ -0,0 +1,53 @@
package org.dromara.app.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
@Data
public class AppVersionCheckVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 平台类型android 或 ios
*/
private String platform;
/**
* APP当前版本号由客户端上传
*/
private String currentVersion;
/**
* 后台配置的最新版本号
*/
private String latestVersion;
/**
* 最新版本序号,用于版本排序
*/
private Integer versionCode;
/**
* 是否存在可更新版本
*/
private Boolean updateAvailable;
/**
* 是否强制更新
*/
private Boolean forceUpdate;
/**
* APP安装包下载地址
*/
private String downloadUrl;
/**
* 更新说明
*/
private String releaseNotes;
}

View File

@@ -0,0 +1,49 @@
package org.dromara.app.domain.vo;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.dromara.app.domain.AppVersion;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
@Data
@ExcelIgnoreUnannotated
@AutoMapper(target = AppVersion.class)
public class AppVersionVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long id;
@ExcelProperty(value = "平台 android/ios")
private String platform;
@ExcelProperty(value = "最新版本号")
private String latestVersion;
@ExcelProperty(value = "版本序号,用于排序取最新版本")
private Integer versionCode;
@ExcelProperty(value = "是否强制更新 0否 1是")
private String forceUpdate;
@ExcelProperty(value = "下载地址")
private String downloadUrl;
@ExcelProperty(value = "更新说明")
private String releaseNotes;
@ExcelProperty(value = "状态 0停用 1启用")
private String status;
@ExcelProperty(value = "备注")
private String remark;
@ExcelProperty(value = "创建时间")
private Date createTime;
}

View File

@@ -1,25 +1,15 @@
package org.dromara.app.handler; package org.dromara.app.handler;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.mqtt.MqttTopicHandler; import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceService; import org.dromara.app.service.IAppDeviceService;
import org.dromara.common.json.utils.JsonUtils; import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.Date; import java.util.Date;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
@@ -32,14 +22,6 @@ public class DeviceDataHandler implements MqttTopicHandler {
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/power$"); private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/power$");
private final IAppDeviceService appDeviceService; private final IAppDeviceService appDeviceService;
private final AppDeviceMapper appDeviceMapper;
private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:";
@Value("${mqtt.command-ack.device-status-cache-prefix}")
private String deviceStatusCachePrefix;
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds}")
private int deviceStatusCacheTtlSeconds;
private final DeviceIdentityResolver deviceIdentityResolver; private final DeviceIdentityResolver deviceIdentityResolver;
@Override @Override
@@ -60,7 +42,6 @@ public class DeviceDataHandler implements MqttTopicHandler {
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class); Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
if (dto == null || dto.get("powerLevel") == null) { if (dto == null || dto.get("powerLevel") == null) {
log.warn("[MQTT] 设备电量上报缺少电量字段 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload); log.warn("[MQTT] 设备电量上报缺少电量字段 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
refreshDeviceOnline(deviceNo);
return; return;
} }
// 更新设备电量 + 同步在线状态到数据库 // 更新设备电量 + 同步在线状态到数据库
@@ -68,50 +49,11 @@ public class DeviceDataHandler implements MqttTopicHandler {
appDeviceBo.setDeviceNo(deviceNo); appDeviceBo.setDeviceNo(deviceNo);
appDeviceBo.setPowerLevel(dto.get("powerLevel").toString()); appDeviceBo.setPowerLevel(dto.get("powerLevel").toString());
appDeviceBo.setPowerLevelUpdatatime(new Date()); appDeviceBo.setPowerLevelUpdatatime(new Date());
appDeviceBo.setStatus("1"); // 收到数据 = 在线
appDeviceService.updateByBo(appDeviceBo); appDeviceService.updateByBo(appDeviceBo);
// 同步刷新 Redis 在线缓存(定时任务离线检测依赖此 Key
refreshDeviceOnline(deviceNo);
log.info("[MQTT] 设备电量更新 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload); log.info("[MQTT] 设备电量更新 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
} catch (Exception e) { } catch (Exception e) {
log.error("[MQTT] 设备电量更新失败 时间={} 设备标识={} 设备编号={} 消息体={}", log.error("[MQTT] 设备电量更新失败 时间={} 设备标识={} 设备编号={} 消息体={}",
HandlerLogTime.now(), deviceIdentity, deviceNo, payload, e); HandlerLogTime.now(), deviceIdentity, deviceNo, payload, e);
} }
} }
/**
* 刷新设备在线状态到 Redis与 MqttCommandAckService 逻辑一致)
* 写入幂等,未拿到锁则跳过由后续上报兜底
*/
private void refreshDeviceOnline(String deviceNo) {
RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo);
boolean locked = false;
try {
locked = lock.tryLock(0, 10, TimeUnit.SECONDS);
if (!locked) {
return;
}
Map<String, Object> statusCache = new HashMap<>();
statusCache.put("deviceNo", deviceNo);
statusCache.put("status", "1");
statusCache.put("lastReportTime", Instant.now().toString());
RedisUtils.setCacheObject(
deviceStatusCachePrefix + deviceNo,
statusCache,
Duration.ofSeconds(deviceStatusCacheTtlSeconds)
);
appDeviceMapper.update(null,
new LambdaUpdateWrapper<AppDevice>()
.set(AppDevice::getStatus, "1")
.eq(AppDevice::getDeviceNo, deviceNo)
);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("[MQTT] 刷新设备在线状态被中断 设备编号={}", deviceNo);
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
} }

View File

@@ -1,6 +1,5 @@
package org.dromara.app.handler; package org.dromara.app.handler;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppDevice; import org.dromara.app.domain.AppDevice;
@@ -11,19 +10,12 @@ import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceService; import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IDeviceCommandPublisher; import org.dromara.app.service.IDeviceCommandPublisher;
import org.dromara.common.json.utils.JsonUtils; import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
import org.redisson.api.RLock;
import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
@@ -39,14 +31,7 @@ DeviceRegisterHandler implements MqttTopicHandler {
private final IAppDeviceService appDeviceService; private final IAppDeviceService appDeviceService;
private final AppDeviceMapper appDeviceMapper; private final AppDeviceMapper appDeviceMapper;
private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:";
private final ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider; private final ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider;
@Value("${mqtt.command-ack.device-status-cache-prefix}")
private String deviceStatusCachePrefix;
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds}")
private int deviceStatusCacheTtlSeconds;
private final DeviceIdentityResolver deviceIdentityResolver; private final DeviceIdentityResolver deviceIdentityResolver;
@Override @Override
@@ -59,7 +44,7 @@ DeviceRegisterHandler implements MqttTopicHandler {
try { try {
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class); Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
AppDevice exists = deviceIdentityResolver.resolve(deviceIdentity); AppDevice exists = deviceIdentityResolver.resolve(deviceIdentity);
String normalizedDeviceMac = resolveDeviceMac(deviceIdentity, dto); String normalizedDeviceMac = resolveDeviceMac(deviceIdentity, dto, exists);
if (exists == null && normalizedDeviceMac != null) { if (exists == null && normalizedDeviceMac != null) {
exists = appDeviceMapper.selectByMac(normalizedDeviceMac); exists = appDeviceMapper.selectByMac(normalizedDeviceMac);
} }
@@ -75,7 +60,6 @@ DeviceRegisterHandler implements MqttTopicHandler {
} }
AppDeviceBo device = buildRegisterDevice(deviceNo, normalizedDeviceMac, dto); AppDeviceBo device = buildRegisterDevice(deviceNo, normalizedDeviceMac, dto);
appDeviceService.registerByMqtt(device); appDeviceService.registerByMqtt(device);
refreshDeviceOnline(deviceNo);
sendDeviceNoToDevice(deviceNo, normalizedDeviceMac); sendDeviceNoToDevice(deviceNo, normalizedDeviceMac);
log.info("[MQTT] 设备注册 时间={} MAC={} 设备编号={} 消息体={}", log.info("[MQTT] 设备注册 时间={} MAC={} 设备编号={} 消息体={}",
HandlerLogTime.now(), normalizedDeviceMac, deviceNo, payload); HandlerLogTime.now(), normalizedDeviceMac, deviceNo, payload);
@@ -88,11 +72,9 @@ DeviceRegisterHandler implements MqttTopicHandler {
AppDeviceBo device = new AppDeviceBo(); AppDeviceBo device = new AppDeviceBo();
device.setDeviceNo(deviceNo); device.setDeviceNo(deviceNo);
device.setMacAddress(deviceMac); device.setMacAddress(deviceMac);
device.setStatus("1"); // 注册即在线
if (dto == null) { if (dto == null) {
return device; return device;
} }
device.setDeviceName(valueAsString(dto.get("deviceName")));
device.setDeviceInitName(valueAsString(dto.get("deviceName"))); device.setDeviceInitName(valueAsString(dto.get("deviceName")));
device.setPowerLevel(valueAsString(dto.get("powerLevel"))); device.setPowerLevel(valueAsString(dto.get("powerLevel")));
device.setDeviceEm(valueAsString(dto.get("deviceEm"))); device.setDeviceEm(valueAsString(dto.get("deviceEm")));
@@ -101,9 +83,15 @@ DeviceRegisterHandler implements MqttTopicHandler {
return device; return device;
} }
private String resolveDeviceMac(String topicDeviceMac, Map<String, Object> dto) { private String resolveDeviceMac(String topicDeviceMac, Map<String, Object> dto, AppDevice exists) {
String payloadMac = dto == null ? null : firstNotBlank(dto.get("deviceMac"), dto.get("macAddress")); String payloadMac = dto == null ? null : firstNotBlank(dto.get("deviceMac"), dto.get("macAddress"));
return normalizeMacAddress(firstNotBlank(payloadMac, topicDeviceMac)); if (payloadMac != null && !payloadMac.isBlank()) {
return normalizeMacAddress(payloadMac);
}
if (exists != null && exists.getMacAddress() != null && !exists.getMacAddress().isBlank()) {
return normalizeMacAddress(exists.getMacAddress());
}
return normalizeMacAddress(topicDeviceMac);
} }
private String normalizeMacAddress(String macAddress) { private String normalizeMacAddress(String macAddress) {
@@ -157,39 +145,4 @@ DeviceRegisterHandler implements MqttTopicHandler {
private String valueAsString(Object value) { private String valueAsString(Object value) {
return value == null ? null : String.valueOf(value); return value == null ? null : String.valueOf(value);
} }
/**
* 刷新设备在线状态到 Redis状态写入幂等未拿到锁则跳过由后续上报兜底
*/
private void refreshDeviceOnline(String deviceNo) {
RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo);
boolean locked = false;
try {
locked = lock.tryLock(0, 10, TimeUnit.SECONDS);
if (!locked) {
return;
}
Map<String, Object> statusCache = new HashMap<>();
statusCache.put("deviceNo", deviceNo);
statusCache.put("status", "1");
statusCache.put("lastReportTime", Instant.now().toString());
RedisUtils.setCacheObject(
deviceStatusCachePrefix + deviceNo,
statusCache,
Duration.ofSeconds(deviceStatusCacheTtlSeconds)
);
appDeviceMapper.update(null,
new LambdaUpdateWrapper<AppDevice>()
.set(AppDevice::getStatus, "1")
.eq(AppDevice::getDeviceNo, deviceNo)
);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("[MQTT] 刷新设备在线状态被中断 设备编号={}", deviceNo);
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
} }

View File

@@ -0,0 +1,90 @@
package org.dromara.app.handler;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.common.core.utils.StringUtils;
import org.springframework.stereotype.Component;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 设备在线/离线状态处理器,匹配 /{deviceIdentity}/publish/status。
* <p>
* deviceIdentity 支持设备编号;设备遗嘱消息允许使用 MAC 地址,处理前统一解析为设备编号。
* 设备通过 status=online 标记上线,通过 LWT status=offline 标记异常离线。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DeviceStatusHandler implements MqttTopicHandler {
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/status$");
private static final String OFFLINE_REASON = "设备 MQTT 状态离线";
private final DeviceIdentityResolver deviceIdentityResolver;
private final MqttDeviceStatusService deviceStatusService;
private final ObjectMapper objectMapper;
@Override
public Pattern topicPattern() {
return PATTERN;
}
@Override
public void handle(String deviceIdentity, String payload) {
handle(deviceIdentity, payload, false);
}
@Override
public void handle(String deviceIdentity, String payload, boolean retained) {
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
if (deviceNo == null) {
log.warn("[MQTT] 设备状态上报未找到设备 时间={} 设备标识={} 消息体={}",
HandlerLogTime.now(), deviceIdentity, payload);
return;
}
String status = parseStatus(payload);
if ("online".equals(status) || "1".equals(status)) {
deviceStatusService.markOnline(deviceNo);
log.info("[MQTT] 设备状态在线 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
return;
}
if ("offline".equals(status) || "0".equals(status)) {
if (retained) {
log.warn("[MQTT] 忽略 retained 离线状态 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
return;
}
deviceStatusService.markOffline(deviceNo, OFFLINE_REASON);
log.info("[MQTT] 设备状态离线 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
return;
}
log.warn("[MQTT] 设备状态上报状态未知 时间={} 设备编号={} 消息体={}",
HandlerLogTime.now(), deviceNo, payload);
}
private String parseStatus(String payload) {
if (StringUtils.isBlank(payload)) {
return null;
}
String trimmed = payload.trim();
if (!trimmed.startsWith("{")) {
return trimmed.toLowerCase(Locale.ROOT);
}
try {
Map<String, Object> body = objectMapper.readValue(trimmed, new TypeReference<Map<String, Object>>() {
});
Object status = body.get("status");
return status == null ? null : String.valueOf(status).trim().toLowerCase(Locale.ROOT);
} catch (Exception e) {
log.warn("[MQTT] 设备状态上报 JSON 格式错误 时间={} 消息体={}", HandlerLogTime.now(), payload, e);
return null;
}
}
}

View File

@@ -0,0 +1,118 @@
package org.dromara.app.handler;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class MqttDeviceStatusService {
private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:";
private final AppDeviceMapper appDeviceMapper;
private final IAppWateringLogService wateringLogService;
@Value("${mqtt.command-ack.device-status-cache-prefix}")
private String deviceStatusCachePrefix;
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:900}")
private long deviceStatusCacheTtlSeconds;
public void markOnline(String deviceNo) {
if (StringUtils.isBlank(deviceNo)) {
return;
}
withDeviceStatusLock(deviceNo, () -> {
Map<String, Object> statusCache = new HashMap<>();
statusCache.put("deviceNo", deviceNo);
statusCache.put("status", "1");
statusCache.put("lastReportTime", Instant.now().toString());
RedisUtils.setCacheObject(
deviceStatusCachePrefix + deviceNo,
statusCache,
Duration.ofSeconds(deviceStatusCacheTtlSeconds)
);
appDeviceMapper.update(null,
new LambdaUpdateWrapper<AppDevice>()
.set(AppDevice::getStatus, "1")
.eq(AppDevice::getDeviceNo, deviceNo)
);
return true;
});
}
public void markOffline(String deviceNo, String reason) {
if (StringUtils.isBlank(deviceNo)) {
return;
}
markOfflineWithLock(deviceNo, reason, false);
}
public boolean markOfflineIfCacheMissing(String deviceNo, String reason) {
if (StringUtils.isBlank(deviceNo)) {
return false;
}
return markOfflineWithLock(deviceNo, reason, true);
}
private boolean markOfflineWithLock(String deviceNo, String reason, boolean skipWhenCacheExists) {
return withDeviceStatusLock(deviceNo, () -> {
if (skipWhenCacheExists && RedisUtils.hasKey(deviceStatusCachePrefix + deviceNo)) {
return false;
}
RedisUtils.deleteObject(deviceStatusCachePrefix + deviceNo);
int updated = appDeviceMapper.update(null,
new LambdaUpdateWrapper<AppDevice>()
.set(AppDevice::getStatus, "0")
.set(AppDevice::getWorkStatus, "0")
.ne(AppDevice::getStatus, "0")
.eq(AppDevice::getDeviceNo, deviceNo)
);
if (updated > 0) {
int count = wateringLogService.finishRunningLogsByDevice(deviceNo, new Date(), reason);
if (count > 0) {
log.info("[MQTT] 已结束设备进行中的浇水记录 设备编号={} 记录数={} 原因={}", deviceNo, count, reason);
}
}
return updated > 0;
});
}
private boolean withDeviceStatusLock(String deviceNo, java.util.function.BooleanSupplier action) {
RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo);
boolean locked = false;
try {
locked = lock.tryLock(0, 10, TimeUnit.SECONDS);
if (!locked) {
log.debug("[MQTT] 获取设备状态锁失败,本次跳过 设备编号={}", deviceNo);
return false;
}
return action.getAsBoolean();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("[MQTT] 获取设备状态锁被中断 设备编号={}", deviceNo);
return false;
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}

View File

@@ -0,0 +1,11 @@
package org.dromara.app.mapper;
import org.dromara.app.domain.AppVersion;
import org.dromara.app.domain.vo.AppVersionVo;
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
/**
* APP版本Mapper接口
*/
public interface AppVersionMapper extends BaseMapperPlus<AppVersion, AppVersionVo> {
}

View File

@@ -33,13 +33,17 @@ public class MqttMessageDispatcher {
* @param payload 消息体 * @param payload 消息体
*/ */
public void dispatch(String topic, String payload) { public void dispatch(String topic, String payload) {
dispatch(topic, payload, false);
}
public void dispatch(String topic, String payload, boolean retained) {
log.debug("[MQTT] 收到消息 主题={} 消息体={}", topic, payload); log.debug("[MQTT] 收到消息 主题={} 消息体={}", topic, payload);
for (MqttTopicHandler handler : handlers) { for (MqttTopicHandler handler : handlers) {
Matcher matcher = handler.topicPattern().matcher(topic); Matcher matcher = handler.topicPattern().matcher(topic);
if (matcher.matches()) { if (matcher.matches()) {
String deviceIdentity = matcher.group(1); String deviceIdentity = matcher.group(1);
handler.handle(deviceIdentity, payload); handler.handle(deviceIdentity, payload, retained);
return; return;
} }
} }

View File

@@ -25,4 +25,11 @@ public interface MqttTopicHandler {
* @param payload 消息体JSON * @param payload 消息体JSON
*/ */
void handle(String deviceIdentity, String payload); void handle(String deviceIdentity, String payload);
/**
* 处理匹配到的消息,并携带 MQTT 消息属性。
*/
default void handle(String deviceIdentity, String payload, boolean retained) {
handle(deviceIdentity, payload);
}
} }

View File

@@ -0,0 +1,76 @@
package org.dromara.app.service;
import org.dromara.app.domain.bo.AppVersionBo;
import org.dromara.app.domain.vo.AppVersionVo;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import java.util.Collection;
import java.util.List;
/**
* APP版本Service接口
*
* @author Lion Li
* @date 2026-07-01
*/
public interface IAppVersionService {
/**
* 查询APP版本
*
* @param id 主键
* @return APP版本
*/
AppVersionVo queryById(Long id);
/**
* 分页查询APP版本列表
*
* @param bo 查询条件
* @param pageQuery 分页参数
* @return APP版本分页列表
*/
TableDataInfo<AppVersionVo> queryPageList(AppVersionBo bo, PageQuery pageQuery);
/**
* 查询符合条件的APP版本列表
*
* @param bo 查询条件
* @return APP版本列表
*/
List<AppVersionVo> queryList(AppVersionBo bo);
/**
* 新增APP版本
*
* @param bo APP版本
* @return 是否新增成功
*/
Boolean insertByBo(AppVersionBo bo);
/**
* 修改APP版本
*
* @param bo APP版本
* @return 是否修改成功
*/
Boolean updateByBo(AppVersionBo bo);
/**
* 校验并批量删除APP版本信息
*
* @param ids 待删除的主键集合
* @param isValid 是否进行有效性校验
* @return 是否删除成功
*/
Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
/**
* 查询指定平台启用中的最新版本
*
* @param platform 平台 android/ios
* @return 最新版本配置,不存在返回 null
*/
AppVersionVo queryLatestByPlatform(String platform);
}

View File

@@ -23,6 +23,7 @@ import org.dromara.app.mapper.AppWateringLogMapper;
import org.dromara.app.service.IAppDeviceService; import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppWateringLogService; import org.dromara.app.service.IAppWateringLogService;
import org.dromara.app.service.IDeviceCommandService; import org.dromara.app.service.IDeviceCommandService;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.MapstructUtils; import org.dromara.common.core.utils.MapstructUtils;
import org.dromara.common.core.utils.StringUtils; import org.dromara.common.core.utils.StringUtils;
@@ -147,8 +148,10 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
device.setPowerLevelUpdatatime(new Date()); device.setPowerLevelUpdatatime(new Date());
// applyRegisterBindToken(device, bo, exists); // applyRegisterBindToken(device, bo, exists);
if (StringUtils.isBlank(device.getStatus())) { if (StringUtils.isBlank(device.getStatus())) {
device.setStatus("1"); // 注册默认在线 device.setStatus(exists == null || StringUtils.isBlank(exists.getStatus()) ? "0" : exists.getStatus());
device.setWorkStatus("2"); }
if (StringUtils.isBlank(device.getWorkStatus())) {
device.setWorkStatus(exists == null || StringUtils.isBlank(exists.getWorkStatus()) ? "2" : exists.getWorkStatus());
} }
if (exists == null) { if (exists == null) {
@@ -282,6 +285,7 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("bindDeviceStatus", 200); params.put("bindDeviceStatus", 200);
params.put("bindDeviceStatusName", ""); params.put("bindDeviceStatusName", "");
if (bo == null) { if (bo == null) {
params.put("bindDeviceStatus", 300); params.put("bindDeviceStatus", 300);
params.put("bindDeviceStatusName", "设备信息不能为空"); params.put("bindDeviceStatusName", "设备信息不能为空");
@@ -296,12 +300,18 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
params.put("bindDevice", null); params.put("bindDevice", null);
Long userId = LoginHelper.getUserId(); Long userId = LoginHelper.getUserId();
AppDevice exists = findDeviceForBindStatus(bo); AppDevice exists = findDeviceForBindStatus(bo);
if (exists == null) { if (exists == null) {
params.put("bindDeviceStatus", 302); params.put("bindDeviceStatus", 302);
params.put("bindDeviceStatusName", "设备未入库注册,请先进行入库"); params.put("bindDeviceStatusName", "设备未入库注册,请先进行入库");
return params; return params;
} }
params.put("bindDevice", exists); params.put("bindDevice", exists);
if (exists.getUserId() == null && exists.getWorkStatus().equals("2") && exists.getStatus().equals("0") ) {
params.put("bindDeviceStatus", 305);
params.put("bindDeviceStatusName", "设备未配网,请先去配置网络");
return params;
}
if (exists.getUserId() != null && !exists.getUserId().equals(userId)) { if (exists.getUserId() != null && !exists.getUserId().equals(userId)) {
params.put("bindDeviceStatus", 303); params.put("bindDeviceStatus", 303);
params.put("bindDeviceStatusName", "设备已被其他用户绑定"); params.put("bindDeviceStatusName", "设备已被其他用户绑定");
@@ -312,11 +322,7 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
params.put("bindDeviceStatusName", "设备未绑定,请先绑定用户"); params.put("bindDeviceStatusName", "设备未绑定,请先绑定用户");
return params; return params;
} }
if (exists.getUserId() == null && exists.getWorkStatus().equals("2") && exists.getStatus().equals("0") ) {
params.put("bindDeviceStatus", 305);
params.put("bindDeviceStatusName", "设备未配网,请先去配置网络");
return params;
}
return params; return params;
} }
@@ -421,7 +427,7 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
throw new ServiceException("设备编号不能为空"); throw new ServiceException("设备编号不能为空");
} }
boolean platformAdmin = LoginHelper.isSuperAdmin() || LoginHelper.isTenantAdmin(); boolean platformAdmin = isPlatformAdmin();
Long userId = platformAdmin ? null : LoginHelper.getUserId(); Long userId = platformAdmin ? null : LoginHelper.getUserId();
if (!platformAdmin) { if (!platformAdmin) {
if (userId == null) { if (userId == null) {
@@ -452,6 +458,17 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
return flag; return flag;
} }
private boolean isPlatformAdmin() {
if (LoginHelper.isSuperAdmin() || LoginHelper.isTenantAdmin()) {
return true;
}
try {
return UserType.SYS_USER == LoginHelper.getUserType();
} catch (RuntimeException ignored) {
return false;
}
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Boolean unbindDevices(Collection<String> deviceNos, Long userId) { public Boolean unbindDevices(Collection<String> deviceNos, Long userId) {

View File

@@ -0,0 +1,158 @@
package org.dromara.app.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.AppVersion;
import org.dromara.app.domain.bo.AppVersionBo;
import org.dromara.app.domain.vo.AppVersionVo;
import org.dromara.app.mapper.AppVersionMapper;
import org.dromara.app.service.IAppVersionService;
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.stereotype.Service;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* APP版本Service业务层处理
*
* @author Lion Li
* @date 2026-07-01
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class AppVersionServiceImpl implements IAppVersionService {
private static final String STATUS_ENABLED = "1";
private final AppVersionMapper baseMapper;
/**
* 查询APP版本
*
* @param id 主键
* @return APP版本
*/
@Override
public AppVersionVo queryById(Long id){
return baseMapper.selectVoById(id);
}
/**
* 分页查询APP版本列表
*
* @param bo 查询条件
* @param pageQuery 分页参数
* @return APP版本分页列表
*/
@Override
public TableDataInfo<AppVersionVo> queryPageList(AppVersionBo bo, PageQuery pageQuery) {
LambdaQueryWrapper<AppVersion> lqw = buildQueryWrapper(bo);
Page<AppVersionVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
return TableDataInfo.build(result);
}
/**
* 查询符合条件的APP版本列表
*
* @param bo 查询条件
* @return APP版本列表
*/
@Override
public List<AppVersionVo> queryList(AppVersionBo bo) {
LambdaQueryWrapper<AppVersion> lqw = buildQueryWrapper(bo);
return baseMapper.selectVoList(lqw);
}
private LambdaQueryWrapper<AppVersion> buildQueryWrapper(AppVersionBo bo) {
Map<String, Object> params = bo.getParams();
LambdaQueryWrapper<AppVersion> lqw = Wrappers.lambdaQuery();
lqw.orderByAsc(AppVersion::getId);
lqw.eq(StringUtils.isNotBlank(bo.getPlatform()), AppVersion::getPlatform, bo.getPlatform());
lqw.eq(StringUtils.isNotBlank(bo.getLatestVersion()), AppVersion::getLatestVersion, bo.getLatestVersion());
lqw.eq(bo.getVersionCode() != null, AppVersion::getVersionCode, bo.getVersionCode());
lqw.eq(StringUtils.isNotBlank(bo.getForceUpdate()), AppVersion::getForceUpdate, bo.getForceUpdate());
lqw.eq(StringUtils.isNotBlank(bo.getDownloadUrl()), AppVersion::getDownloadUrl, bo.getDownloadUrl());
lqw.eq(StringUtils.isNotBlank(bo.getReleaseNotes()), AppVersion::getReleaseNotes, bo.getReleaseNotes());
lqw.eq(StringUtils.isNotBlank(bo.getStatus()), AppVersion::getStatus, bo.getStatus());
return lqw;
}
/**
* 新增APP版本
*
* @param bo APP版本
* @return 是否新增成功
*/
@Override
public Boolean insertByBo(AppVersionBo bo) {
AppVersion add = MapstructUtils.convert(bo, AppVersion.class);
validEntityBeforeSave(add);
boolean flag = baseMapper.insert(add) > 0;
if (flag) {
bo.setId(add.getId());
}
return flag;
}
/**
* 修改APP版本
*
* @param bo APP版本
* @return 是否修改成功
*/
@Override
public Boolean updateByBo(AppVersionBo bo) {
AppVersion update = MapstructUtils.convert(bo, AppVersion.class);
validEntityBeforeSave(update);
return baseMapper.updateById(update) > 0;
}
/**
* 保存前的数据校验
*/
private void validEntityBeforeSave(AppVersion entity){
//TODO 做一些数据校验,如唯一约束
}
/**
* 校验并批量删除APP版本信息
*
* @param ids 待删除的主键集合
* @param isValid 是否进行有效性校验
* @return 是否删除成功
*/
@Override
public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
if(isValid){
//TODO 做一些业务上的校验,判断是否需要校验
}
return baseMapper.deleteByIds(ids) > 0;
}
@Override
public AppVersionVo queryLatestByPlatform(String platform) {
if (StringUtils.isBlank(platform)) {
return null;
}
AppVersion version = baseMapper.selectOne(
Wrappers.<AppVersion>lambdaQuery()
.eq(AppVersion::getPlatform, platform)
.eq(AppVersion::getStatus, STATUS_ENABLED)
.orderByDesc(AppVersion::getVersionCode)
.orderByDesc(AppVersion::getId)
.last("limit 1")
);
if (version == null) {
return null;
}
return MapstructUtils.convert(version, AppVersionVo.class);
}
}

View File

@@ -1,29 +1,25 @@
package org.dromara.app.task; package org.dromara.app.task;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppDevice; import org.dromara.app.domain.AppDevice;
import org.dromara.app.handler.MqttDeviceStatusService;
import org.dromara.app.mapper.AppDeviceMapper; import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.common.redis.utils.RedisUtils; import org.dromara.common.redis.utils.RedisUtils;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/** /**
* 设备离线检测定时任务 * 设备离线检测定时任务
* <p> * <p>
* 逻辑:Redis 中 mqtt:device:status:{deviceNo} Key 的 TTL 由 device-status-cache-ttl-seconds 控制 * 逻辑:默认通过设备 LWT 离线消息更新状态
* 设备断电/断网后不再上报数据TTL 到期 Key 自动消失 * 兼容旧设备时,可打开 ttl-compat-enabled继续使用 Redis 在线 Key 过期兜底离线
* 本任务定期扫描数据库中非离线状态的设备,如果对应 Redis Key 已不存在, * 本任务定期扫描数据库中非离线状态的设备,如果对应 Redis Key 已不存在,
* 则将数据库 status 更新为 0离线 * 则将数据库 status 更新为 0离线
* </p> * </p>
@@ -36,22 +32,26 @@ import java.util.stream.Collectors;
public class DeviceOfflineCheckTask { public class DeviceOfflineCheckTask {
private final AppDeviceMapper appDeviceMapper; private final AppDeviceMapper appDeviceMapper;
private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:"; private final MqttDeviceStatusService deviceStatusService;
@Value("${mqtt.command-ack.device-status-cache-prefix}") @Value("${mqtt.command-ack.device-status-cache-prefix}")
private String deviceStatusCachePrefix; private String deviceStatusCachePrefix;
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:900}")
private long deviceStatusCacheTtlSeconds;
@Value("${mqtt.offline-check.enabled:true}") @Value("${mqtt.offline-check.enabled:true}")
private boolean enabled; private boolean enabled;
private final IAppWateringLogService wateringLogService; @Value("${mqtt.offline-check.ttl-compat-enabled:false}")
private boolean ttlCompatEnabled;
/** /**
* 每 90 秒检查一次Redis TTL 默认 300s90s 间隔确保 1.5 个周期内同步) * 每 90 秒检查一次。默认不开启 TTL 兼容离线检测,避免低功耗长连接设备被误判离线。
* 可通过 mqtt.offline-check.interval-ms 覆盖(单位 ms * 可通过 mqtt.offline-check.interval-ms 覆盖(单位 ms
*/ */
@Scheduled(fixedDelayString = "${mqtt.offline-check.interval-ms:90000}") @Scheduled(fixedDelayString = "${mqtt.offline-check.interval-ms:90000}")
public void checkOfflineDevices() { public void checkOfflineDevices() {
if (!enabled) { if (!enabled || !ttlCompatEnabled) {
return; return;
} }
@@ -66,11 +66,28 @@ public class DeviceOfflineCheckTask {
return; return;
} }
// 过滤出 Redis Key 已过期(不存在)的设备 List<String> offlineDeviceNos = new ArrayList<>();
List<String> offlineDeviceNos = onlineDevices.stream() int migratedPermanentKeys = 0;
.map(AppDevice::getDeviceNo) for (AppDevice device : onlineDevices) {
.filter(deviceNo -> !RedisUtils.hasKey(deviceStatusCachePrefix + deviceNo)) String deviceNo = device.getDeviceNo();
.collect(Collectors.toList()); String statusKey = deviceStatusCachePrefix + deviceNo;
if (!RedisUtils.hasKey(statusKey)) {
offlineDeviceNos.add(deviceNo);
continue;
}
long ttl = RedisUtils.getTimeToLive(statusKey);
if (ttl == -1L) {
RedisUtils.expire(statusKey, Duration.ofSeconds(deviceStatusCacheTtlSeconds));
migratedPermanentKeys++;
} else if (ttl == -2L) {
offlineDeviceNos.add(deviceNo);
}
}
if (migratedPermanentKeys > 0) {
log.info("[设备离线] 已为旧版永久在线缓存补充 TTL设备数{}", migratedPermanentKeys);
}
if (offlineDeviceNos.isEmpty()) { if (offlineDeviceNos.isEmpty()) {
log.debug("[设备离线] 本次检测未发现离线设备,扫描设备数:{}", onlineDevices.size()); log.debug("[设备离线] 本次检测未发现离线设备,扫描设备数:{}", onlineDevices.size());
@@ -79,7 +96,7 @@ public class DeviceOfflineCheckTask {
List<String> updatedDeviceNos = new ArrayList<>(); List<String> updatedDeviceNos = new ArrayList<>();
for (String deviceNo : offlineDeviceNos) { for (String deviceNo : offlineDeviceNos) {
if (markOfflineIfStillExpired(deviceNo)) { if (deviceStatusService.markOfflineIfCacheMissing(deviceNo, "设备掉线")) {
updatedDeviceNos.add(deviceNo); updatedDeviceNos.add(deviceNo);
} }
} }
@@ -89,43 +106,4 @@ public class DeviceOfflineCheckTask {
updatedDeviceNos.size(), updatedDeviceNos); updatedDeviceNos.size(), updatedDeviceNos);
} }
} }
private boolean markOfflineIfStillExpired(String deviceNo) {
RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo);
boolean locked = false;
try {
locked = lock.tryLock(100, 10000, TimeUnit.MILLISECONDS);
if (!locked) {
log.debug("[设备离线] 获取设备状态锁失败,本轮跳过 设备编号={}", deviceNo);
return false;
}
// 获取锁后再次确认 Redis 在线 Key 仍不存在,避免设备刚注册上线时被旧扫描结果覆盖为离线
if (RedisUtils.hasKey(deviceStatusCachePrefix + deviceNo)) {
return false;
}
return appDeviceMapper.update(null,
new LambdaUpdateWrapper<AppDevice>()
.set(AppDevice::getStatus, "0")
.set(AppDevice::getWorkStatus, "0")
.ne(AppDevice::getStatus, "0")
.eq(AppDevice::getDeviceNo, deviceNo)
) > 0 && finishOfflineWateringLogs(deviceNo);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("[设备离线] 获取设备状态锁被中断 设备编号={}", deviceNo);
return false;
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private boolean finishOfflineWateringLogs(String deviceNo) {
int count = wateringLogService.finishRunningLogsByDevice(deviceNo, new Date(), "设备掉线");
if (count > 0) {
log.info("[设备离线] 已结束设备进行中的浇水记录 设备编号={} 记录数={}", deviceNo, count);
}
return true;
}
} }

View File

@@ -0,0 +1,434 @@
package org.dromara.app.controller;
import cn.hutool.extra.spring.SpringUtil;
import cn.hutool.json.JSONObject;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.bo.AppScheduleBo;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.*;
import org.dromara.app.service.*;
import org.dromara.common.core.domain.R;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.vo.SysOssUploadVo;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
import org.dromara.system.service.ISysUserService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.mock.web.MockMultipartFile;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
public class AppControllerTest {
@Mock private IAppDeviceService appDeviceService;
@Mock private IAppScheduleService appScheduleService;
@Mock private IAppScheduleDetailService appScheduleDetailService;
@Mock private org.dromara.app.service.impl.AppScheduleServiceImpl appScheduleServiceimpl;
@Mock private IAppSchedulingDeviceService appSchedulingDeviceService;
@Mock private ISysUserService userService;
@Mock private IAppWateringLogService appWateringLogService;
@Mock private IDeviceCommandService deviceCommandService;
@Mock private ISysOssService ossService;
@Mock private IAppVersionService appVersionService;
@Test
public void checkVersion_returnsUpdateAvailableWhenConfiguredVersionDiffers() {
AppController controller = newController();
AppVersionVo latestVersion = new AppVersionVo();
latestVersion.setPlatform("android");
latestVersion.setLatestVersion("1.0.1");
latestVersion.setVersionCode(2);
latestVersion.setForceUpdate("1");
latestVersion.setDownloadUrl("https://example.com/app.apk");
latestVersion.setReleaseNotes("修复已知问题");
when(appVersionService.queryLatestByPlatform("android")).thenReturn(latestVersion);
R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("android", "1.0.0"));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
assertThat(result.getData().getPlatform()).isEqualTo("android");
assertThat(result.getData().getCurrentVersion()).isEqualTo("1.0.0");
assertThat(result.getData().getLatestVersion()).isEqualTo("1.0.1");
assertThat(result.getData().getUpdateAvailable()).isTrue();
assertThat(result.getData().getForceUpdate()).isTrue();
assertThat(result.getData().getDownloadUrl()).isEqualTo("https://example.com/app.apk");
assertThat(result.getData().getReleaseNotes()).isEqualTo("修复已知问题");
}
@Test
public void checkVersion_returnsNoUpdateWhenConfiguredVersionMatches() {
AppController controller = newController();
AppVersionVo latestVersion = new AppVersionVo();
latestVersion.setPlatform("ios");
latestVersion.setLatestVersion("2.0.0");
latestVersion.setVersionCode(3);
latestVersion.setForceUpdate("0");
latestVersion.setDownloadUrl("https://example.com/app");
latestVersion.setReleaseNotes("最新版本");
when(appVersionService.queryLatestByPlatform("ios")).thenReturn(latestVersion);
R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("ios", "2.0.0"));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
assertThat(result.getData().getPlatform()).isEqualTo("ios");
assertThat(result.getData().getCurrentVersion()).isEqualTo("2.0.0");
assertThat(result.getData().getLatestVersion()).isEqualTo("2.0.0");
assertThat(result.getData().getUpdateAvailable()).isFalse();
assertThat(result.getData().getForceUpdate()).isFalse();
}
@Test
public void checkVersion_rejectsMissingCurrentVersion() {
AppController controller = newController();
R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("android", " "));
assertThat(result.getCode()).isEqualTo(500);
assertThat(result.getMsg()).isEqualTo("当前版本不能为空");
verify(appVersionService, never()).queryLatestByPlatform(any());
}
@Test
public void checkVersion_rejectsUnsupportedPlatform() {
AppController controller = newController();
R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("harmony", "1.0.0"));
assertThat(result.getCode()).isEqualTo(500);
assertThat(result.getMsg()).isEqualTo("平台类型仅支持 android 或 ios");
verify(appVersionService, never()).queryLatestByPlatform(any());
}
@Test
public void checkVersion_rejectsMissingVersionConfig() {
AppController controller = newController();
when(appVersionService.queryLatestByPlatform("android")).thenReturn(null);
R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("android", "1.0.0"));
assertThat(result.getCode()).isEqualTo(500);
assertThat(result.getMsg()).isEqualTo("版本配置不存在");
}
@Test
public void uploadImage_uploadsToOssAndReturnsBackendVisibleInfo() {
AppController controller = newController();
MockMultipartFile file = new MockMultipartFile(
"file",
"plant.png",
"image/png",
new byte[]{1, 2, 3}
);
SysOssVo oss = new SysOssVo();
oss.setOssId(123L);
oss.setOriginalName("plant.png");
oss.setUrl("https://bucket.oss-cn-hangzhou.aliyuncs.com/plant.png");
when(ossService.upload(file)).thenReturn(oss);
R<SysOssUploadVo> result = callWithLogin(1L, () -> controller.uploadImage(file));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
assertThat(result.getData().getOssId()).isEqualTo("123");
assertThat(result.getData().getFileName()).isEqualTo("plant.png");
assertThat(result.getData().getUrl()).isEqualTo("https://bucket.oss-cn-hangzhou.aliyuncs.com/plant.png");
verify(ossService).upload(file);
}
@Test
public void uploadImage_rejectsNonImageFile() {
AppController controller = newController();
MockMultipartFile file = new MockMultipartFile(
"file",
"readme.txt",
"text/plain",
new byte[]{1, 2, 3}
);
R<SysOssUploadVo> result = callWithLogin(1L, () -> controller.uploadImage(file));
assertThat(result.getCode()).isEqualTo(500);
assertThat(result.getMsg()).isEqualTo("只能上传图片文件");
verify(ossService, never()).upload(any(org.springframework.web.multipart.MultipartFile.class));
}
@Test
public void switchDevice_usesCurrentTimeWithSecondsAsStartTime() throws Exception {
AppController controller = newController();
when(appDeviceService.switchDevice(eq("D01"), eq("1"), any(), eq(10))).thenReturn(true);
waitUntilCurrentSecondIsSafelyNonZero();
R<Void> result = callWithLogin(99L,
() -> controller.switchDevice("{\"deviceNo\":\"D01\",\"workStatus\":\"1\",\"durationMin\":\"10\"}"));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
ArgumentCaptor<String> startTimeCaptor = ArgumentCaptor.forClass(String.class);
verify(appDeviceService).switchDevice(eq("D01"), eq("1"), startTimeCaptor.capture(), eq(10));
assertThat(startTimeCaptor.getValue()).matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}");
assertThat(startTimeCaptor.getValue()).doesNotEndWith(":00");
}
@Test
public void addScheduleDevice_dispatchesSchedulePayloadAfterBinding() {
AppController controller = newController();
Long userId = 99L;
AppScheduleVo schedule = new AppScheduleVo();
schedule.setId(10L);
schedule.setUserId(userId);
schedule.setName("Morning");
schedule.setStatus("1");
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(userId);
AppScheduleDetailVo detail = new AppScheduleDetailVo();
detail.setId(101L);
detail.setWeekday(1);
detail.setTimeData("[{\"startTime\":\"08:00\",\"durationMin\":15}]");
detail.setTriggerType("0");
detail.setStatus("1");
when(appScheduleService.queryById(10L)).thenReturn(schedule);
when(appDeviceService.queryById("D01")).thenReturn(device);
lenient().when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of(detail));
when(appSchedulingDeviceService.insertByBo(any(AppSchedulingDeviceBo.class))).thenReturn(true);
R<Void> result = callWithLogin(userId,
() -> controller.addScheduleDevice("{\"scheduleId\":10,\"deviceNos\":[\"D01\"]}"));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), payloadCaptor.capture());
Map<String, Object> payload = payloadCaptor.getValue();
assertThat(payload.get("deviceNo")).isEqualTo("D01");
assertThat(payload).containsKey("details").doesNotContainKeys("cmd", "schedule");
List<Map<String, Object>> details = (List<Map<String, Object>>) payload.get("details");
assertThat(details).hasSize(1);
Map<String, Object> detailPayload = details.get(0);
assertThat(detailPayload)
.containsEntry("weekday", 1)
.containsEntry("triggerType", "0")
.containsEntry("status", "1")
.doesNotContainKey("id")
.doesNotContainKey("zones");
List<Object> timeData = (List<Object>) detailPayload.get("timeData");
assertThat(timeData).hasSize(1);
JSONObject timeSlot = (JSONObject) timeData.get(0);
assertThat(timeSlot.getStr("startTime")).isEqualTo("08:00");
assertThat(timeSlot.getInt("durationMin")).isEqualTo(15);
}
@Test
public void addScheduleDevice_doesNotDispatchWhenBindingAlreadyExists() {
AppController controller = newController();
Long userId = 99L;
AppScheduleVo schedule = ownedSchedule(10L, userId);
AppDeviceVo device = ownedDevice("D01", userId);
AppSchedulingDeviceVo existingBinding = new AppSchedulingDeviceVo();
when(appScheduleService.queryById(10L)).thenReturn(schedule);
when(appDeviceService.queryById("D01")).thenReturn(device);
when(appSchedulingDeviceService.queryByScheduleIdAndDeviceNo(10L, "D01")).thenReturn(existingBinding);
R<Void> result = callWithLogin(userId,
() -> controller.addScheduleDevice("{\"scheduleId\":10,\"deviceNos\":[\"D01\"]}"));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
verify(appSchedulingDeviceService, never()).insertByBo(any(AppSchedulingDeviceBo.class));
verify(deviceCommandService, never()).sendScheduleBindCommand(eq("D01"), any());
}
@Test
public void removeSchedule_dispatchesUnbindOnlyAfterDeleteSucceeds() {
AppController controller = newController();
Long userId = 99L;
AppScheduleVo schedule = ownedSchedule(10L, userId);
AppSchedulingDeviceVo binding = new AppSchedulingDeviceVo();
binding.setDeviceNo("D01");
when(appScheduleService.queryById(10L)).thenReturn(schedule);
when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))).thenReturn(List.of(binding));
when(appScheduleService.deleteWithValidByIds(List.of(10L), true)).thenReturn(true);
R<Void> result = callWithLogin(userId, () -> controller.removeSchedule(new Long[]{10L}));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
var ordered = inOrder(appScheduleService, deviceCommandService);
ordered.verify(appScheduleService).deleteWithValidByIds(List.of(10L), true);
ordered.verify(deviceCommandService).sendScheduleUnbindCommand("D01", 10L);
}
@Test
public void removeSchedule_doesNotDispatchUnbindWhenDeleteFails() {
AppController controller = newController();
Long userId = 99L;
AppScheduleVo schedule = ownedSchedule(10L, userId);
AppSchedulingDeviceVo binding = new AppSchedulingDeviceVo();
binding.setDeviceNo("D01");
when(appScheduleService.queryById(10L)).thenReturn(schedule);
when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))).thenReturn(List.of(binding));
when(appScheduleService.deleteWithValidByIds(List.of(10L), true)).thenReturn(false);
R<Void> result = callWithLogin(userId, () -> controller.removeSchedule(new Long[]{10L}));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(500);
verify(deviceCommandService, never()).sendScheduleUnbindCommand("D01", 10L);
}
@Test
public void editScheduleStatus_dispatchesCanceledCommandWhenScheduleClosed() {
AppController controller = newController();
Long userId = 99L;
AppScheduleVo schedule = ownedSchedule(10L, userId);
AppSchedulingDeviceVo binding = new AppSchedulingDeviceVo();
binding.setDeviceNo("D01");
AppScheduleBo bo = new AppScheduleBo();
bo.setId(10L);
bo.setStatus("0");
when(appScheduleService.queryById(10L)).thenReturn(schedule);
when(appScheduleService.updateStatusByBo(bo)).thenReturn(true);
when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))).thenReturn(List.of(binding));
R<Void> result = callWithLogin(userId, () -> controller.editScheduleStatus(bo));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
verify(deviceCommandService).sendScheduleCanceledCommand("D01", 10L);
verify(deviceCommandService, never()).sendScheduleBindCommand(eq("D01"), any());
}
@Test
public void editScheduleStatus_dispatchesSchedulePayloadWhenScheduleOpened() {
AppController controller = newController();
Long userId = 99L;
AppScheduleVo schedule = ownedSchedule(10L, userId);
AppSchedulingDeviceVo binding = new AppSchedulingDeviceVo();
binding.setDeviceNo("D01");
AppScheduleDetailVo detail = new AppScheduleDetailVo();
detail.setWeekday(1);
detail.setTimeData("[{\"startTime\":\"08:00\",\"durationMin\":15}]");
detail.setTriggerType("0");
detail.setStatus("1");
AppScheduleBo bo = new AppScheduleBo();
bo.setId(10L);
bo.setStatus("1");
when(appScheduleService.queryById(10L)).thenReturn(schedule);
when(appScheduleService.updateStatusByBo(bo)).thenReturn(true);
when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))).thenReturn(List.of(binding));
when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of(detail));
R<Void> result = callWithLogin(userId, () -> controller.editScheduleStatus(bo));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), any());
verify(deviceCommandService, never()).sendScheduleCanceledCommand("D01", 10L);
}
@Test
public void latestWaterLog_returnsLatestRunningLogById() throws Exception {
AppController controller = newController();
Long userId = 99L;
AppDeviceVo device = ownedDevice("D01", userId);
AppWateringLogVo laterStartTime = new AppWateringLogVo();
laterStartTime.setId(1L);
laterStartTime.setDeviceNo("D01");
laterStartTime.setStatus("1");
laterStartTime.setStartTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2026-07-06 16:39:00"));
laterStartTime.setEndTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2026-07-06 16:49:00"));
AppWateringLogVo latest = new AppWateringLogVo();
latest.setId(2L);
latest.setDeviceNo("D01");
latest.setStatus("1");
latest.setStartTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2026-07-06 08:30:00"));
latest.setEndTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2026-07-06 08:40:00"));
when(appDeviceService.queryById("D01")).thenReturn(device);
when(appWateringLogService.queryList(any(AppWateringLogBo.class))).thenReturn(List.of(laterStartTime, latest));
R<Map<String, Object>> result = callWithLogin(userId, () -> controller.latestWaterLog("D01"));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
assertThat(result.getData())
.containsEntry("startTime", "2026-07-06 08:30:00")
.containsEntry("endTime", "2026-07-06 08:40:00");
ArgumentCaptor<AppWateringLogBo> captor = ArgumentCaptor.forClass(AppWateringLogBo.class);
verify(appWateringLogService).queryList(captor.capture());
assertThat(captor.getValue().getUserId()).isEqualTo(userId);
assertThat(captor.getValue().getDeviceNo()).isEqualTo("D01");
assertThat(captor.getValue().getStatus()).isEqualTo("1");
}
private AppController newController() {
return new AppController(
appDeviceService,
appScheduleService,
appScheduleDetailService,
appScheduleServiceimpl,
appSchedulingDeviceService,
userService,
appWateringLogService,
deviceCommandService,
ossService,
appVersionService
);
}
private <T> R<T> callWithLogin(Long userId, Supplier<R<T>> action) {
try (GenericApplicationContext applicationContext = new GenericApplicationContext();
MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
loginHelper.when(LoginHelper::getUserId).thenReturn(userId);
return action.get();
}
}
private AppScheduleVo ownedSchedule(Long scheduleId, Long userId) {
AppScheduleVo schedule = new AppScheduleVo();
schedule.setId(scheduleId);
schedule.setUserId(userId);
return schedule;
}
private AppDeviceVo ownedDevice(String deviceNo, Long userId) {
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo(deviceNo);
device.setUserId(userId);
return device;
}
private void waitUntilCurrentSecondIsSafelyNonZero() throws InterruptedException {
while (true) {
int second = Calendar.getInstance().get(Calendar.SECOND);
if (second > 1 && second < 59) {
return;
}
Thread.sleep(50);
}
}
}

View File

@@ -0,0 +1,79 @@
package org.dromara.app.handler;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IDeviceCommandPublisher;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.ObjectProvider;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class DeviceRegisterHandlerTest {
@Mock private IAppDeviceService appDeviceService;
@Mock private AppDeviceMapper appDeviceMapper;
@Mock private ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider;
@Mock private IDeviceCommandPublisher commandPublisher;
@Mock private DeviceIdentityResolver deviceIdentityResolver;
@Test
void firstRegistrationReplyStillUsesMacTopic() throws Exception {
DeviceRegisterHandler handler = new DeviceRegisterHandler(
appDeviceService,
appDeviceMapper,
commandPublisherProvider,
deviceIdentityResolver
);
when(commandPublisherProvider.getIfAvailable()).thenReturn(commandPublisher);
when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1");
Method method = DeviceRegisterHandler.class.getDeclaredMethod(
"sendDeviceNoToDevice", String.class, String.class
);
method.setAccessible(true);
method.invoke(handler, "D01", "AA:BB:CC");
ArgumentCaptor<DeviceCommand> captor = ArgumentCaptor.forClass(DeviceCommand.class);
verify(commandPublisher).send(captor.capture());
assertThat(captor.getValue().getTopic()).isEqualTo("/aa:bb:cc/subscriber/cmd");
assertThat(captor.getValue().getCommandType()).isEqualTo("registerDeviceNo");
assertThat(captor.getValue().getDeviceNo()).isEqualTo("D01");
}
@Test
void handleKeepsExistingMacWhenTopicUsesDeviceNoAndPayloadOmitsMac() {
DeviceRegisterHandler handler = new DeviceRegisterHandler(
appDeviceService,
appDeviceMapper,
commandPublisherProvider,
deviceIdentityResolver
);
AppDevice existing = new AppDevice();
existing.setDeviceNo("2075423638947475457");
existing.setMacAddress("aa:bb:cc");
when(deviceIdentityResolver.resolve("2075423638947475457")).thenReturn(existing);
when(commandPublisherProvider.getIfAvailable()).thenReturn(null);
handler.handle("2075423638947475457", "{}");
org.mockito.ArgumentCaptor<AppDeviceBo> captor = org.mockito.ArgumentCaptor.forClass(AppDeviceBo.class);
verify(appDeviceService).registerByMqtt(captor.capture());
assertThat(captor.getValue().getDeviceNo()).isEqualTo("2075423638947475457");
assertThat(captor.getValue().getMacAddress()).isEqualTo("aa:bb:cc");
}
}

View File

@@ -0,0 +1,104 @@
package org.dromara.app.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.mapper.AppDeviceMapper;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class DeviceStatusHandlerTest {
@Mock
private DeviceIdentityResolver deviceIdentityResolver;
@Mock
private MqttDeviceStatusService deviceStatusService;
@Mock
private AppDeviceMapper appDeviceMapper;
@Test
void topicPatternMatchesDeviceStatusTopic() {
DeviceStatusHandler handler = newHandler();
assertThat(handler.topicPattern().matcher("/D01/publish/status").matches()).isTrue();
assertThat(handler.topicPattern().matcher("/D01/publish/power").matches()).isFalse();
}
@Test
void handleMarksDeviceOnlineFromPlainPayload() {
DeviceStatusHandler handler = newHandler();
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
handler.handle("D01", "online");
verify(deviceStatusService).markOnline("D01");
verify(deviceStatusService, never()).markOffline(anyString(), anyString());
}
@Test
void handleMarksDeviceOfflineFromJsonPayload() {
DeviceStatusHandler handler = newHandler();
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
handler.handle("D01", "{\"status\":\"offline\"}");
verify(deviceStatusService).markOffline("D01", "设备 MQTT 状态离线");
verify(deviceStatusService, never()).markOnline(anyString());
}
@Test
void handleResolvesLastWillTopicMacToDeviceNo() {
String macAddress = "DC:DA:0C:FA:29:5E";
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
when(appDeviceMapper.selectById(macAddress)).thenReturn(null);
when(appDeviceMapper.selectByMac("dc:da:0c:fa:29:5e")).thenReturn(device);
DeviceIdentityResolver resolver = new DeviceIdentityResolver(appDeviceMapper);
DeviceStatusHandler handler = new DeviceStatusHandler(resolver, deviceStatusService, new ObjectMapper());
handler.handle(macAddress, "{\"status\":\"offline\"}", false);
verify(appDeviceMapper).selectByMac("dc:da:0c:fa:29:5e");
verify(deviceStatusService).markOffline("D01", "设备 MQTT 状态离线");
verify(deviceStatusService, never()).markOffline(macAddress, "设备 MQTT 状态离线");
}
@Test
void handleDoesNotUpdateStatusWhenLastWillMacIsUnknown() {
String macAddress = "DC:DA:0C:FA:29:5E";
when(appDeviceMapper.selectById(macAddress)).thenReturn(null);
when(appDeviceMapper.selectByMac("dc:da:0c:fa:29:5e")).thenReturn(null);
DeviceIdentityResolver resolver = new DeviceIdentityResolver(appDeviceMapper);
DeviceStatusHandler handler = new DeviceStatusHandler(resolver, deviceStatusService, new ObjectMapper());
handler.handle(macAddress, "{\"status\":\"offline\"}", false);
verify(deviceStatusService, never()).markOffline(anyString(), anyString());
verify(deviceStatusService, never()).markOnline(anyString());
}
@Test
void handleIgnoresUnknownStatusPayload() {
DeviceStatusHandler handler = newHandler();
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
handler.handle("D01", "{\"status\":\"sleeping\"}");
verify(deviceStatusService, never()).markOnline(anyString());
verify(deviceStatusService, never()).markOffline(anyString(), anyString());
}
private DeviceStatusHandler newHandler() {
return new DeviceStatusHandler(deviceIdentityResolver, deviceStatusService, new ObjectMapper());
}
}

View File

@@ -0,0 +1,73 @@
package org.dromara.app.handler;
import cn.hutool.extra.spring.SpringUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class KeyFinishHandlerTest {
@Mock private IAppWateringLogService appWateringLogService;
@Mock private IAppDeviceService appDeviceService;
@Mock private AppSchedulingDeviceMapper schedulingDeviceMapper;
@Mock private DeviceIdentityResolver deviceIdentityResolver;
@Test
void handle_setsDeviceWorkStatusIdleWhenKeyWateringFinished() {
KeyFinishHandler handler = new KeyFinishHandler(
appWateringLogService,
appDeviceService,
schedulingDeviceMapper,
deviceIdentityResolver
);
AppDeviceVo device = new AppDeviceVo();
device.setUserId(99L);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"durationMin\":10}");
return null;
});
ArgumentCaptor<AppWateringLogBo> logCaptor = ArgumentCaptor.forClass(AppWateringLogBo.class);
verify(appWateringLogService).confirmScheduleLog(logCaptor.capture());
assertThat(logCaptor.getValue().getStatus()).isEqualTo("0");
assertThat(logCaptor.getValue().getTriggerType()).isEqualTo("1");
ArgumentCaptor<AppDeviceBo> deviceCaptor = ArgumentCaptor.forClass(AppDeviceBo.class);
verify(appDeviceService).updateByBo(deviceCaptor.capture());
AppDeviceBo updateBo = deviceCaptor.getValue();
assertThat(updateBo.getDeviceNo()).isEqualTo("D01");
assertThat(updateBo.getWorkStatus()).isEqualTo("0");
}
private <T> T withJsonContext(Supplier<T> action) {
try (GenericApplicationContext applicationContext = new GenericApplicationContext()) {
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
return action.get();
}
}
}

View File

@@ -0,0 +1,134 @@
package org.dromara.app.handler;
import cn.hutool.extra.spring.SpringUtil;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.common.redis.utils.RedisUtils;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class MqttDeviceStatusServiceTest {
private static GenericApplicationContext applicationContext;
@Mock
private AppDeviceMapper appDeviceMapper;
@Mock
private IAppWateringLogService wateringLogService;
@Mock
private RedissonClient redissonClient;
@Mock
private RLock lock;
@BeforeAll
static void initializeRedisUtils() {
if (TableInfoHelper.getTableInfo(AppDevice.class) == null) {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), ""), AppDevice.class);
}
applicationContext = new GenericApplicationContext();
applicationContext.registerBean(RedissonClient.class, () -> org.mockito.Mockito.mock(RedissonClient.class));
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
}
@AfterAll
static void closeApplicationContext() {
applicationContext.close();
}
@Test
void markOnlineCachesOnlineStatusWithTtlAndUpdatesDatabase() throws Exception {
MqttDeviceStatusService service = newService();
when(redissonClient.getLock("lock:mqtt:device:status:D01")).thenReturn(lock);
when(lock.tryLock(0, 10, TimeUnit.SECONDS)).thenReturn(true);
when(lock.isHeldByCurrentThread()).thenReturn(true);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
service.markOnline("D01");
redis.verify(() -> RedisUtils.setCacheObject(
eq("mqtt:device:status:D01"),
any(Map.class),
eq(Duration.ofSeconds(900))
));
verify(appDeviceMapper).update(eq(null), any(LambdaUpdateWrapper.class));
verify(lock).unlock();
}
}
@Test
void markOfflineDeletesOnlineCacheUpdatesDatabaseAndFinishesRunningLogs() throws Exception {
MqttDeviceStatusService service = newService();
when(redissonClient.getLock("lock:mqtt:device:status:D01")).thenReturn(lock);
when(lock.tryLock(0, 10, TimeUnit.SECONDS)).thenReturn(true);
when(lock.isHeldByCurrentThread()).thenReturn(true);
when(appDeviceMapper.update(eq(null), any(LambdaUpdateWrapper.class))).thenReturn(1);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
service.markOffline("D01", "设备 MQTT 状态离线");
redis.verify(() -> RedisUtils.deleteObject("mqtt:device:status:D01"));
verify(appDeviceMapper).update(eq(null), any(LambdaUpdateWrapper.class));
verify(wateringLogService).finishRunningLogsByDevice(eq("D01"), any(Date.class), eq("设备 MQTT 状态离线"));
verify(lock).unlock();
}
}
@Test
void markOfflineIfCacheMissingSkipsWhenDeviceCameBackOnline() throws Exception {
MqttDeviceStatusService service = newService();
when(redissonClient.getLock("lock:mqtt:device:status:D01")).thenReturn(lock);
when(lock.tryLock(0, 10, TimeUnit.SECONDS)).thenReturn(true);
when(lock.isHeldByCurrentThread()).thenReturn(true);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
redis.when(() -> RedisUtils.hasKey("mqtt:device:status:D01")).thenReturn(true);
service.markOfflineIfCacheMissing("D01", "设备掉线");
redis.verify(RedisUtils::getClient);
redis.verify(() -> RedisUtils.hasKey("mqtt:device:status:D01"));
redis.verify(() -> RedisUtils.deleteObject("mqtt:device:status:D01"), never());
verify(appDeviceMapper, never()).update(eq(null), any(LambdaUpdateWrapper.class));
verify(wateringLogService, never()).finishRunningLogsByDevice(eq("D01"), any(Date.class), eq("设备掉线"));
verify(lock).unlock();
}
}
private MqttDeviceStatusService newService() {
MqttDeviceStatusService service = new MqttDeviceStatusService(appDeviceMapper, wateringLogService);
ReflectionTestUtils.setField(service, "deviceStatusCachePrefix", "mqtt:device:status:");
ReflectionTestUtils.setField(service, "deviceStatusCacheTtlSeconds", 900L);
return service;
}
}

View File

@@ -0,0 +1,115 @@
package org.dromara.app.handler;
import cn.hutool.extra.spring.SpringUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import java.util.Calendar;
import java.util.List;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class ScheduleFinishHandlerTest {
@Mock private IAppWateringLogService appWateringLogService;
@Mock private IAppDeviceService appDeviceService;
@Mock private AppSchedulingDeviceMapper schedulingDeviceMapper;
@Mock private DeviceIdentityResolver deviceIdentityResolver;
@Test
void handle_setsDeviceWorkStatusIdleWhenScheduleWateringFinished() {
ScheduleFinishHandler handler = new ScheduleFinishHandler(
appWateringLogService,
appDeviceService,
schedulingDeviceMapper,
deviceIdentityResolver
);
AppDeviceVo device = new AppDeviceVo();
device.setUserId(99L);
AppSchedulingDevice binding = new AppSchedulingDevice();
binding.setDeviceNo("D01");
binding.setScheduleId(10L);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
when(schedulingDeviceMapper.selectList(any())).thenReturn(List.of(binding));
withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"durationMin\":10}");
return null;
});
ArgumentCaptor<AppWateringLogBo> logCaptor = ArgumentCaptor.forClass(AppWateringLogBo.class);
verify(appWateringLogService).confirmScheduleLog(logCaptor.capture());
assertThat(logCaptor.getValue().getStatus()).isEqualTo("0");
ArgumentCaptor<AppDeviceBo> deviceCaptor = ArgumentCaptor.forClass(AppDeviceBo.class);
verify(appDeviceService).updateByBo(deviceCaptor.capture());
AppDeviceBo updateBo = deviceCaptor.getValue();
assertThat(updateBo.getDeviceNo()).isEqualTo("D01");
assertThat(updateBo.getWorkStatus()).isEqualTo("0");
}
@Test
void handle_preservesSecondsWhenScheduleFinishReportsTimeOnly() {
ScheduleFinishHandler handler = new ScheduleFinishHandler(
appWateringLogService,
appDeviceService,
schedulingDeviceMapper,
deviceIdentityResolver
);
AppDeviceVo device = new AppDeviceVo();
device.setUserId(99L);
AppSchedulingDevice binding = new AppSchedulingDevice();
binding.setDeviceNo("D01");
binding.setScheduleId(10L);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
when(schedulingDeviceMapper.selectList(any())).thenReturn(List.of(binding));
withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"08:30:27\",\"durationMin\":10}");
return null;
});
ArgumentCaptor<AppWateringLogBo> logCaptor = ArgumentCaptor.forClass(AppWateringLogBo.class);
verify(appWateringLogService).confirmScheduleLog(logCaptor.capture());
AppWateringLogBo logBo = logCaptor.getValue();
assertThat(secondOf(logBo.getStartTime())).isEqualTo(27);
}
private <T> T withJsonContext(Supplier<T> action) {
try (GenericApplicationContext applicationContext = new GenericApplicationContext()) {
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
return action.get();
}
}
private int secondOf(java.util.Date date) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
return calendar.get(Calendar.SECOND);
}
}

View File

@@ -0,0 +1,193 @@
package org.dromara.app.handler;
import cn.hutool.extra.spring.SpringUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.domain.vo.AppWateringLogVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import java.text.SimpleDateFormat;
import java.util.List;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class StartWaterHandlerTest {
@Mock private IAppWateringLogService appWateringLogService;
@Mock private IAppDeviceService appDeviceService;
@Mock private AppSchedulingDeviceMapper schedulingDeviceMapper;
@Mock private DeviceIdentityResolver deviceIdentityResolver;
@Test
void handle_insertsRunningScheduleWateringLog() throws Exception {
StartWaterHandler handler = new StartWaterHandler(
appWateringLogService,
appDeviceService,
schedulingDeviceMapper,
deviceIdentityResolver
);
AppDeviceVo device = new AppDeviceVo();
device.setUserId(99L);
AppSchedulingDevice binding = new AppSchedulingDevice();
binding.setDeviceNo("D01");
binding.setScheduleId(10L);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
when(schedulingDeviceMapper.selectList(any())).thenReturn(List.of(binding));
withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"triggerON\":\"schedule\"}");
return null;
});
ArgumentCaptor<AppWateringLogBo> captor = ArgumentCaptor.forClass(AppWateringLogBo.class);
verify(appWateringLogService).insertByBo(captor.capture());
AppWateringLogBo logBo = captor.getValue();
assertThat(logBo.getDeviceNo()).isEqualTo("D01");
assertThat(logBo.getUserId()).isEqualTo(99L);
assertThat(logBo.getScheduleId()).isEqualTo(10L);
assertThat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(logBo.getStartTime()))
.isEqualTo("2026-07-06 08:30:00");
assertThat(logBo.getTriggerType()).isEqualTo("0");
assertThat(logBo.getStatus()).isEqualTo("1");
assertThat(logBo.getEndTime()).isNull();
assertThat(logBo.getRemark()).isEqualTo("设备上报排程开始浇水");
ArgumentCaptor<AppDeviceBo> deviceCaptor = ArgumentCaptor.forClass(AppDeviceBo.class);
verify(appDeviceService).updateByBo(deviceCaptor.capture());
AppDeviceBo deviceBo = deviceCaptor.getValue();
assertThat(deviceBo.getDeviceNo()).isEqualTo("D01");
assertThat(deviceBo.getWorkStatus()).isEqualTo("1");
}
@Test
void handle_skipsDuplicateRunningWateringLogWithSameStartTime() throws Exception {
StartWaterHandler handler = new StartWaterHandler(
appWateringLogService,
appDeviceService,
schedulingDeviceMapper,
deviceIdentityResolver
);
AppDeviceVo device = new AppDeviceVo();
device.setUserId(99L);
AppWateringLogVo activeLog = new AppWateringLogVo();
activeLog.setDeviceNo("D01");
activeLog.setUserId(99L);
activeLog.setTriggerType("1");
activeLog.setStartTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2026-07-06 08:30:00"));
activeLog.setEndTime(null);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
lenient().when(appWateringLogService.queryList(any(AppWateringLogBo.class))).thenReturn(List.of(activeLog));
withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"triggerON\":\"manual\"}");
return null;
});
verify(appWateringLogService, never()).insertByBo(any(AppWateringLogBo.class));
ArgumentCaptor<AppDeviceBo> deviceCaptor = ArgumentCaptor.forClass(AppDeviceBo.class);
verify(appDeviceService).updateByBo(deviceCaptor.capture());
assertThat(deviceCaptor.getValue().getDeviceNo()).isEqualTo("D01");
assertThat(deviceCaptor.getValue().getWorkStatus()).isEqualTo("1");
}
@Test
void handle_skipsDuplicateRunningWateringLogWithinSameMinute() throws Exception {
StartWaterHandler handler = new StartWaterHandler(
appWateringLogService,
appDeviceService,
schedulingDeviceMapper,
deviceIdentityResolver
);
AppDeviceVo device = new AppDeviceVo();
device.setUserId(99L);
AppWateringLogVo activeLog = new AppWateringLogVo();
activeLog.setDeviceNo("D01");
activeLog.setUserId(99L);
activeLog.setScheduleId(0L);
activeLog.setTriggerType("1");
activeLog.setStatus("1");
activeLog.setStartTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2026-07-06 08:30:00"));
activeLog.setEndTime(null);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
lenient().when(appWateringLogService.queryList(any(AppWateringLogBo.class))).thenReturn(List.of(activeLog));
withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:27\",\"triggerON\":\"manual\"}");
return null;
});
verify(appWateringLogService, never()).insertByBo(any(AppWateringLogBo.class));
ArgumentCaptor<AppDeviceBo> deviceCaptor = ArgumentCaptor.forClass(AppDeviceBo.class);
verify(appDeviceService).updateByBo(deviceCaptor.capture());
assertThat(deviceCaptor.getValue().getDeviceNo()).isEqualTo("D01");
assertThat(deviceCaptor.getValue().getWorkStatus()).isEqualTo("1");
}
@Test
void handle_setsTodayEndTimeFromHourMinuteStartTimeAndDurationMin() {
StartWaterHandler handler = new StartWaterHandler(
appWateringLogService,
appDeviceService,
schedulingDeviceMapper,
deviceIdentityResolver
);
AppDeviceVo device = new AppDeviceVo();
device.setUserId(99L);
String today = new SimpleDateFormat("yyyy-MM-dd").format(new java.util.Date());
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
withJsonContext(() -> {
handler.handle("D01", "{\"deviceNo\":\"D01\",\"startTime\":\"16:39\",\"durationMin\":10,\"triggerON\":\"manual\"}");
return null;
});
ArgumentCaptor<AppWateringLogBo> captor = ArgumentCaptor.forClass(AppWateringLogBo.class);
verify(appWateringLogService).insertByBo(captor.capture());
AppWateringLogBo logBo = captor.getValue();
assertThat(logBo.getDurationMin()).isEqualTo("10");
assertThat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(logBo.getStartTime()))
.isEqualTo(today + " 16:39:00");
assertThat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(logBo.getEndTime()))
.isEqualTo(today + " 16:49:00");
assertThat(logBo.getStatus()).isEqualTo("1");
ArgumentCaptor<AppDeviceBo> deviceCaptor = ArgumentCaptor.forClass(AppDeviceBo.class);
verify(appDeviceService).updateByBo(deviceCaptor.capture());
assertThat(deviceCaptor.getValue().getDeviceNo()).isEqualTo("D01");
assertThat(deviceCaptor.getValue().getWorkStatus()).isEqualTo("1");
}
private <T> T withJsonContext(Supplier<T> action) {
try (GenericApplicationContext applicationContext = new GenericApplicationContext()) {
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
return action.get();
}
}
}

View File

@@ -0,0 +1,188 @@
package org.dromara.app.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mapper.AppWateringLogMapper;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.satoken.utils.LoginHelper;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class AppDeviceServiceImplTest {
@Mock
private AppDeviceMapper appDeviceMapper;
@Mock
private AppSchedulingDeviceMapper schedulingDeviceMapper;
@Mock
private AppWateringLogMapper wateringLogMapper;
@Mock
private IAppWateringLogService wateringLogService;
@Test
void renderQrCodeImage_addsDeviceNoBelowQrCode() throws Exception {
byte[] imageBytes = AppDeviceServiceImpl.renderQrCodeImage("{\"deviceNo\":\"D20260702001\"}", "D20260702001");
BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageBytes));
assertThat(image.getWidth()).isEqualTo(300);
assertThat(image.getHeight()).isEqualTo(320);
assertThat(countNonWhitePixels(image, 300, 320)).isGreaterThan(0);
}
@Test
void deleteWithValidByIds_allowsSuperAdminToDeleteDeviceOwnedByAnotherUser() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl(
appDeviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
wateringLogService
);
when(appDeviceMapper.delete(any(Wrapper.class))).thenReturn(1);
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::isSuperAdmin).thenReturn(true);
Boolean result = service.deleteWithValidByIds(List.of("D01"), true);
assertThat(result).isTrue();
verify(appDeviceMapper, never()).selectCount(any(Wrapper.class));
verify(appDeviceMapper).delete(any(Wrapper.class));
}
}
@Test
void deleteWithValidByIds_allowsAuthorizedSystemUserToDeleteDeviceOwnedByAnotherUser() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl(
appDeviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
wateringLogService
);
when(appDeviceMapper.delete(any(Wrapper.class))).thenReturn(1);
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::isSuperAdmin).thenReturn(false);
loginHelper.when(LoginHelper::isTenantAdmin).thenReturn(false);
loginHelper.when(LoginHelper::getUserType).thenReturn(UserType.SYS_USER);
Boolean result = service.deleteWithValidByIds(List.of("D01"), true);
assertThat(result).isTrue();
verify(appDeviceMapper, never()).selectCount(any(Wrapper.class));
verify(appDeviceMapper).delete(any(Wrapper.class));
}
}
@Test
void bindDeviceStatus_findsDeviceByDeviceNo() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl(
appDeviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
wateringLogService
);
AppDeviceBo bo = new AppDeviceBo();
bo.setDeviceNo("D01");
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
device.setUserId(100L);
when(appDeviceMapper.selectById("D01")).thenReturn(device);
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
Map<String, Object> result = service.bindDeviceStatus(bo);
assertThat(result.get("bindDeviceStatus")).isEqualTo(200);
assertThat(result.get("bindDevice")).isSameAs(device);
verify(appDeviceMapper).selectById("D01");
verify(appDeviceMapper, never()).selectByMac(any());
}
}
@Test
void bindDeviceStatus_findsDeviceByDeviceInitName() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl(
appDeviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
wateringLogService
);
AppDeviceBo bo = new AppDeviceBo();
bo.setDeviceInitName("INIT-01");
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
device.setDeviceInitName("INIT-01");
device.setUserId(100L);
when(appDeviceMapper.selectByDeviceInitName("INIT-01")).thenReturn(device);
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
Map<String, Object> result = service.bindDeviceStatus(bo);
assertThat(result.get("bindDeviceStatus")).isEqualTo(200);
assertThat(result.get("bindDevice")).isSameAs(device);
verify(appDeviceMapper).selectByDeviceInitName("INIT-01");
verify(appDeviceMapper, never()).selectByMac(any());
}
}
@Test
void bindDeviceStatus_returnsUnboundWhenNetworkStatusFieldsAreNull() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl(
appDeviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
wateringLogService
);
AppDeviceBo bo = new AppDeviceBo();
bo.setDeviceNo("D01");
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
when(appDeviceMapper.selectById("D01")).thenReturn(device);
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
Map<String, Object> result = service.bindDeviceStatus(bo);
assertThat(result.get("bindDeviceStatus")).isEqualTo(304);
assertThat(result.get("bindDeviceStatusName")).isEqualTo("设备未绑定,请先绑定用户");
}
}
private int countNonWhitePixels(BufferedImage image, int startY, int endY) {
int count = 0;
for (int y = startY; y < endY; y++) {
for (int x = 0; x < image.getWidth(); x++) {
if ((image.getRGB(x, y) & 0x00FFFFFF) != 0x00FFFFFF) {
count++;
}
}
}
return count;
}
}

View File

@@ -0,0 +1,413 @@
package org.dromara.app.service.impl;
import cn.hutool.extra.spring.SpringUtil;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import io.github.linpeilie.Converter;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.dromara.app.domain.AppWateringLog;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.mapper.AppWateringLogMapper;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import java.text.SimpleDateFormat;
import java.util.List;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class AppWateringLogServiceImplTest {
@Mock
private AppWateringLogMapper baseMapper;
@BeforeAll
static void initMybatisPlusTableInfo() {
if (TableInfoHelper.getTableInfo(AppWateringLog.class) == null) {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), ""), AppWateringLog.class);
}
}
@Test
void confirmScheduleLog_updatesMatchingRunningScheduleLogBeforeInsert() throws Exception {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
AppWateringLogBo bo = new AppWateringLogBo();
bo.setDeviceNo("D01");
bo.setScheduleId(10L);
bo.setUserId(99L);
bo.setStartTime(format.parse("2026-07-07 08:30:00"));
bo.setDurationMin("10");
bo.setEndTime(format.parse("2026-07-07 08:40:00"));
bo.setTriggerType("0");
bo.setStatus("0");
bo.setRemark("排程浇水完成");
AppWateringLog runningLog = new AppWateringLog();
runningLog.setId(100L);
runningLog.setDeviceNo("D01");
runningLog.setScheduleId(10L);
runningLog.setStartTime(bo.getStartTime());
runningLog.setDurationMin("10");
runningLog.setEndTime(bo.getEndTime());
runningLog.setTriggerType("0");
runningLog.setStatus("1");
when(baseMapper.selectOne(any(Wrapper.class))).thenReturn(null, runningLog);
when(baseMapper.updateById(any(AppWateringLog.class))).thenReturn(1);
Boolean result = withConverterContext(() -> {
AppWateringLogServiceImpl service = new AppWateringLogServiceImpl(baseMapper);
return service.confirmScheduleLog(bo);
});
assertThat(result).isTrue();
ArgumentCaptor<AppWateringLog> updateCaptor = ArgumentCaptor.forClass(AppWateringLog.class);
verify(baseMapper).updateById(updateCaptor.capture());
AppWateringLog update = updateCaptor.getValue();
assertThat(update.getId()).isEqualTo(100L);
assertThat(update.getStatus()).isEqualTo("0");
assertThat(update.getRemark()).isEqualTo("排程浇水完成");
verify(baseMapper, never()).insert(any(AppWateringLog.class));
}
@Test
void confirmScheduleLog_updatesMatchingRunningManualLogBeforeInsert() throws Exception {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
AppWateringLogBo bo = new AppWateringLogBo();
bo.setDeviceNo("D01");
bo.setScheduleId(0L);
bo.setUserId(99L);
bo.setStartTime(format.parse("2026-07-07 08:30:00"));
bo.setDurationMin("10");
bo.setEndTime(format.parse("2026-07-07 08:40:00"));
bo.setTriggerType("1");
bo.setStatus("0");
bo.setRemark("设备按键完成浇水");
AppWateringLog runningLog = new AppWateringLog();
runningLog.setId(101L);
runningLog.setDeviceNo("D01");
runningLog.setScheduleId(0L);
runningLog.setStartTime(bo.getStartTime());
runningLog.setDurationMin("10");
runningLog.setEndTime(bo.getEndTime());
runningLog.setTriggerType("1");
runningLog.setStatus("1");
when(baseMapper.selectOne(any(Wrapper.class))).thenReturn(null, runningLog);
when(baseMapper.updateById(any(AppWateringLog.class))).thenReturn(1);
Boolean result = withConverterContext(() -> {
AppWateringLogServiceImpl service = new AppWateringLogServiceImpl(baseMapper);
return service.confirmScheduleLog(bo);
});
assertThat(result).isTrue();
ArgumentCaptor<AppWateringLog> updateCaptor = ArgumentCaptor.forClass(AppWateringLog.class);
verify(baseMapper).updateById(updateCaptor.capture());
AppWateringLog update = updateCaptor.getValue();
assertThat(update.getId()).isEqualTo(101L);
assertThat(update.getTriggerType()).isEqualTo("1");
assertThat(update.getStatus()).isEqualTo("0");
assertThat(update.getRemark()).isEqualTo("设备按键完成浇水");
verify(baseMapper, never()).insert(any(AppWateringLog.class));
}
@Test
void confirmScheduleLog_matchesRunningLogByMinuteRange() throws Exception {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
AppWateringLogBo bo = new AppWateringLogBo();
bo.setDeviceNo("D01");
bo.setScheduleId(10L);
bo.setUserId(99L);
bo.setStartTime(format.parse("2026-07-07 09:20:27"));
bo.setDurationMin("10");
bo.setEndTime(format.parse("2026-07-07 09:30:27"));
bo.setTriggerType("0");
bo.setStatus("0");
bo.setRemark("排程浇水完成");
AppWateringLog runningLog = new AppWateringLog();
runningLog.setId(102L);
runningLog.setDeviceNo("D01");
runningLog.setStartTime(format.parse("2026-07-07 09:20:00"));
runningLog.setDurationMin("10");
runningLog.setEndTime(format.parse("2026-07-07 09:30:00"));
runningLog.setStatus("1");
when(baseMapper.selectOne(any(Wrapper.class))).thenReturn(null, runningLog);
when(baseMapper.updateById(any(AppWateringLog.class))).thenReturn(1);
Boolean result = withConverterContext(() -> {
AppWateringLogServiceImpl service = new AppWateringLogServiceImpl(baseMapper);
return service.confirmScheduleLog(bo);
});
assertThat(result).isTrue();
ArgumentCaptor<Wrapper> wrapperCaptor = ArgumentCaptor.forClass(Wrapper.class);
verify(baseMapper, times(2)).selectOne(wrapperCaptor.capture());
List<Wrapper> wrappers = wrapperCaptor.getAllValues();
String runningQuery = wrappers.get(1).getCustomSqlSegment().toLowerCase();
assertThat(runningQuery).contains("starttime between");
assertThat(runningQuery).doesNotContain("starttime =");
}
@Test
void confirmScheduleLog_matchesRunningManualLogWithoutDurationEquality() throws Exception {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
AppWateringLogBo bo = new AppWateringLogBo();
bo.setDeviceNo("D01");
bo.setScheduleId(0L);
bo.setUserId(99L);
bo.setStartTime(format.parse("2026-07-07 09:20:15"));
bo.setDurationMin("3");
bo.setEndTime(format.parse("2026-07-07 09:23:45"));
bo.setTriggerType("1");
bo.setStatus("0");
bo.setRemark("设备按键完成浇水");
AppWateringLog runningLog = new AppWateringLog();
runningLog.setId(103L);
runningLog.setDeviceNo("D01");
runningLog.setScheduleId(0L);
runningLog.setStartTime(format.parse("2026-07-07 09:20:00"));
runningLog.setDurationMin("10");
runningLog.setEndTime(format.parse("2026-07-07 09:30:00"));
runningLog.setTriggerType("1");
runningLog.setStatus("1");
when(baseMapper.selectOne(any(Wrapper.class))).thenReturn(null, runningLog);
when(baseMapper.updateById(any(AppWateringLog.class))).thenReturn(1);
Boolean result = withConverterContext(() -> {
AppWateringLogServiceImpl service = new AppWateringLogServiceImpl(baseMapper);
return service.confirmScheduleLog(bo);
});
assertThat(result).isTrue();
ArgumentCaptor<Wrapper> wrapperCaptor = ArgumentCaptor.forClass(Wrapper.class);
verify(baseMapper, times(2)).selectOne(wrapperCaptor.capture());
String runningQuery = wrapperCaptor.getAllValues().get(1).getCustomSqlSegment().toLowerCase();
assertThat(runningQuery).contains("starttime between");
assertThat(runningQuery).contains("triggertype =");
assertThat(runningQuery).doesNotContain("durationmin =");
ArgumentCaptor<AppWateringLog> updateCaptor = ArgumentCaptor.forClass(AppWateringLog.class);
verify(baseMapper).updateById(updateCaptor.capture());
assertThat(updateCaptor.getValue().getId()).isEqualTo(103L);
verify(baseMapper, never()).insert(any(AppWateringLog.class));
}
@Test
void confirmScheduleLog_matchesRunningScheduleLogWithoutDurationEqualityWhenFinishedEarly() throws Exception {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
AppWateringLogBo bo = new AppWateringLogBo();
bo.setDeviceNo("D01");
bo.setScheduleId(10L);
bo.setUserId(99L);
bo.setStartTime(format.parse("2026-07-07 09:20:15"));
bo.setDurationMin("3");
bo.setEndTime(format.parse("2026-07-07 09:23:45"));
bo.setTriggerType("0");
bo.setStatus("0");
bo.setRemark("设备上报排程浇水完成");
AppWateringLog runningLog = new AppWateringLog();
runningLog.setId(104L);
runningLog.setDeviceNo("D01");
runningLog.setScheduleId(10L);
runningLog.setStartTime(format.parse("2026-07-07 09:20:00"));
runningLog.setDurationMin("10");
runningLog.setEndTime(format.parse("2026-07-07 09:30:00"));
runningLog.setTriggerType("0");
runningLog.setStatus("1");
when(baseMapper.selectOne(any(Wrapper.class))).thenReturn(null, runningLog);
when(baseMapper.updateById(any(AppWateringLog.class))).thenReturn(1);
Boolean result = withConverterContext(() -> {
AppWateringLogServiceImpl service = new AppWateringLogServiceImpl(baseMapper);
return service.confirmScheduleLog(bo);
});
assertThat(result).isTrue();
ArgumentCaptor<Wrapper> wrapperCaptor = ArgumentCaptor.forClass(Wrapper.class);
verify(baseMapper, times(2)).selectOne(wrapperCaptor.capture());
String runningQuery = wrapperCaptor.getAllValues().get(1).getCustomSqlSegment().toLowerCase();
assertThat(runningQuery).contains("starttime between");
assertThat(runningQuery).contains("triggertype =");
assertThat(runningQuery).doesNotContain("durationmin =");
ArgumentCaptor<AppWateringLog> updateCaptor = ArgumentCaptor.forClass(AppWateringLog.class);
verify(baseMapper).updateById(updateCaptor.capture());
assertThat(updateCaptor.getValue().getId()).isEqualTo(104L);
verify(baseMapper, never()).insert(any(AppWateringLog.class));
}
@Test
void confirmScheduleLog_updatesExistingFinishedManualLogByMinuteBeforeInsert() throws Exception {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
AppWateringLogBo bo = new AppWateringLogBo();
bo.setDeviceNo("D01");
bo.setScheduleId(0L);
bo.setUserId(99L);
bo.setStartTime(format.parse("2026-07-07 10:31:27"));
bo.setDurationMin("3");
bo.setEndTime(format.parse("2026-07-07 10:34:27"));
bo.setTriggerType("1");
bo.setStatus("0");
bo.setRemark("设备按键完成浇水");
AppWateringLog finishedLog = new AppWateringLog();
finishedLog.setId(106L);
finishedLog.setDeviceNo("D01");
finishedLog.setScheduleId(0L);
finishedLog.setStartTime(format.parse("2026-07-07 10:31:00"));
finishedLog.setDurationMin("2");
finishedLog.setEndTime(format.parse("2026-07-07 10:33:30"));
finishedLog.setTriggerType("1");
finishedLog.setStatus("0");
when(baseMapper.selectOne(any(Wrapper.class))).thenReturn(null, null, finishedLog);
when(baseMapper.updateById(any(AppWateringLog.class))).thenReturn(1);
Boolean result = withConverterContext(() -> {
AppWateringLogServiceImpl service = new AppWateringLogServiceImpl(baseMapper);
return service.confirmScheduleLog(bo);
});
assertThat(result).isTrue();
ArgumentCaptor<Wrapper> wrapperCaptor = ArgumentCaptor.forClass(Wrapper.class);
verify(baseMapper, times(3)).selectOne(wrapperCaptor.capture());
String existingQuery = wrapperCaptor.getAllValues().get(2).getCustomSqlSegment().toLowerCase();
assertThat(existingQuery).contains("starttime between");
assertThat(existingQuery).contains("triggertype =");
assertThat(existingQuery).contains("status =");
ArgumentCaptor<AppWateringLog> updateCaptor = ArgumentCaptor.forClass(AppWateringLog.class);
verify(baseMapper).updateById(updateCaptor.capture());
assertThat(updateCaptor.getValue().getId()).isEqualTo(106L);
assertThat(updateCaptor.getValue().getStatus()).isEqualTo("0");
verify(baseMapper, never()).insert(any(AppWateringLog.class));
}
@Test
void finishRunningLogsByDevice_finishesLatestStatusOneLogWithPlannedEndTime() throws Exception {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
AppWateringLog runningLog = new AppWateringLog();
runningLog.setId(105L);
runningLog.setDeviceNo("D01");
runningLog.setStartTime(format.parse("2026-07-07 09:20:00"));
runningLog.setEndTime(format.parse("2026-07-07 09:30:00"));
runningLog.setDurationMin("10");
runningLog.setTriggerType("0");
runningLog.setStatus("1");
when(baseMapper.selectOne(any(Wrapper.class))).thenReturn(runningLog);
when(baseMapper.update(eq(null), any(Wrapper.class))).thenReturn(1);
AppWateringLogServiceImpl service = new AppWateringLogServiceImpl(baseMapper);
int result = service.finishRunningLogsByDevice(
"D01",
format.parse("2026-07-07 09:23:30"),
"手动停止浇水"
);
assertThat(result).isEqualTo(1);
ArgumentCaptor<Wrapper> queryCaptor = ArgumentCaptor.forClass(Wrapper.class);
verify(baseMapper).selectOne(queryCaptor.capture());
String query = queryCaptor.getValue().getCustomSqlSegment().toLowerCase();
assertThat(query).contains("status =");
assertThat(query).contains("order by");
assertThat(query).contains("limit 1");
assertThat(query).doesNotContain("endtime is null");
ArgumentCaptor<Wrapper> updateCaptor = ArgumentCaptor.forClass(Wrapper.class);
verify(baseMapper).update(eq(null), updateCaptor.capture());
LambdaUpdateWrapper<AppWateringLog> updateWrapper = (LambdaUpdateWrapper<AppWateringLog>) updateCaptor.getValue();
String sqlSet = updateWrapper.getSqlSet().toLowerCase();
assertThat(sqlSet).contains("status");
assertThat(sqlSet).contains("endtime");
assertThat(updateWrapper.getParamNameValuePairs().values()).contains("0");
}
@Test
void saveEstimatedScheduleLog_defaultsStatusZeroBeforeInsert() throws Exception {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
AppWateringLogBo bo = new AppWateringLogBo();
bo.setDeviceNo("D01");
bo.setScheduleId(10L);
bo.setUserId(99L);
bo.setStartTime(format.parse("2026-07-07 09:20:00"));
bo.setEndTime(format.parse("2026-07-07 09:30:00"));
bo.setDurationMin("10");
when(baseMapper.exists(any(Wrapper.class))).thenReturn(false);
when(baseMapper.insert(any(AppWateringLog.class))).thenReturn(1);
Boolean result = withConverterContext(() -> {
AppWateringLogServiceImpl service = new AppWateringLogServiceImpl(baseMapper);
return service.saveEstimatedScheduleLog(bo);
});
assertThat(result).isTrue();
ArgumentCaptor<AppWateringLog> insertCaptor = ArgumentCaptor.forClass(AppWateringLog.class);
verify(baseMapper).insert(insertCaptor.capture());
AppWateringLog insert = insertCaptor.getValue();
assertThat(insert.getTriggerType()).isEqualTo("0");
assertThat(insert.getStatus()).isEqualTo("0");
assertThat(insert.getRemark()).isEqualTo("设备离线,按排程自动补记");
}
private <T> T withConverterContext(Supplier<T> action) {
try (GenericApplicationContext applicationContext = new GenericApplicationContext()) {
Converter converter = mock(Converter.class);
lenient().when(converter.convert(any(AppWateringLogBo.class), eq(AppWateringLog.class)))
.thenAnswer(invocation -> toEntity(invocation.getArgument(0)));
applicationContext.registerBean(Converter.class, () -> converter);
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
return action.get();
}
}
private AppWateringLog toEntity(AppWateringLogBo bo) {
AppWateringLog entity = new AppWateringLog();
entity.setId(bo.getId());
entity.setUserId(bo.getUserId());
entity.setDeviceNo(bo.getDeviceNo());
entity.setCommandId(bo.getCommandId());
entity.setScheduleId(bo.getScheduleId());
entity.setStartTime(bo.getStartTime());
entity.setEndTime(bo.getEndTime());
entity.setDurationMin(bo.getDurationMin());
entity.setZones(bo.getZones());
entity.setTriggerType(bo.getTriggerType());
entity.setRemark(bo.getRemark());
entity.setStatus(bo.getStatus());
entity.setCreateTime(bo.getCreateTime());
return entity;
}
}

View File

@@ -0,0 +1,192 @@
package org.dromara.app.service.impl;
import org.dromara.app.domain.bo.AppWateringLogBo;
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.common.satoken.utils.LoginHelper;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.text.SimpleDateFormat;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class DeviceCommandServiceImplTest {
@Mock private IAppDeviceService deviceService;
@Mock private IAppWateringLogService wateringLogService;
@Mock private IDeviceCommandPublisher commandPublisher;
@Test
void sendScheduleBindCommand_buildsScheduleTopicAndPayload() {
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(99L);
device.setMacAddress("AABBCC");
Map<String, Object> payload = new HashMap<>();
payload.put("cmd", -1);
payload.put("deviceNo", "D01");
when(deviceService.queryById("D01")).thenReturn(device);
when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1");
DeviceCommandServiceImpl service = new DeviceCommandServiceImpl(deviceService, wateringLogService);
ReflectionTestUtils.setField(service, "commandPublisher", commandPublisher);
String commandId = service.sendScheduleBindCommand("D01", payload);
assertThat(commandId).isEqualTo("cmd-1");
ArgumentCaptor<DeviceCommand> commandCaptor = ArgumentCaptor.forClass(DeviceCommand.class);
verify(commandPublisher).send(commandCaptor.capture());
DeviceCommand command = commandCaptor.getValue();
assertThat(command.getDeviceNo()).isEqualTo("D01");
assertThat(command.getDeviceMac()).isEqualTo("AABBCC");
assertThat(command.getCommandType()).isEqualTo("bindSchedule");
assertThat(command.getTopic()).isEqualTo("/d01/subscriber/schedule");
assertThat(command.getPayload()).containsEntry("cmd", -1).containsEntry("deviceNo", "D01");
}
@Test
void sendScheduleCanceledCommand_buildsScheduleCanceledTopicAndPayload() {
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(99L);
device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device);
when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1");
DeviceCommandServiceImpl service = new DeviceCommandServiceImpl(deviceService, wateringLogService);
ReflectionTestUtils.setField(service, "commandPublisher", commandPublisher);
String commandId = service.sendScheduleCanceledCommand("D01", 10L);
assertThat(commandId).isEqualTo("cmd-1");
ArgumentCaptor<DeviceCommand> commandCaptor = ArgumentCaptor.forClass(DeviceCommand.class);
verify(commandPublisher).send(commandCaptor.capture());
DeviceCommand command = commandCaptor.getValue();
assertThat(command.getDeviceNo()).isEqualTo("D01");
assertThat(command.getDeviceMac()).isEqualTo("AABBCC");
assertThat(command.getCommandType()).isEqualTo("cancelSchedule");
assertThat(command.getTopic()).isEqualTo("/d01/schedule/canceled");
assertThat(command.getPayload())
.containsEntry("deviceNo", "D01")
.containsEntry("scheduleId", 10L)
.containsEntry("canceled", true);
}
@Test
void sendBindDeviceCommand_defaultsTopicToDeviceNo() {
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(99L);
device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device);
when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1");
DeviceCommandServiceImpl service = new DeviceCommandServiceImpl(deviceService, wateringLogService);
ReflectionTestUtils.setField(service, "commandPublisher", commandPublisher);
service.sendBindDeviceCommand("D01");
ArgumentCaptor<DeviceCommand> commandCaptor = ArgumentCaptor.forClass(DeviceCommand.class);
verify(commandPublisher).send(commandCaptor.capture());
assertThat(commandCaptor.getValue().getDeviceNo()).isEqualTo("D01");
assertThat(commandCaptor.getValue().getCommandType()).isEqualTo("bindDevice");
assertThat(commandCaptor.getValue().getPayload()).containsEntry("deviceNo", "D01");
}
@Test
void sendBindDeviceCommand_keepsMacAsPayloadOnlyForFirstRegistrationFlow() {
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(99L);
device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device);
when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1");
DeviceCommandServiceImpl service = new DeviceCommandServiceImpl(deviceService, wateringLogService);
ReflectionTestUtils.setField(service, "commandPublisher", commandPublisher);
service.sendBindDeviceCommand("D01");
ArgumentCaptor<DeviceCommand> commandCaptor = ArgumentCaptor.forClass(DeviceCommand.class);
verify(commandPublisher).send(commandCaptor.capture());
DeviceCommand command = commandCaptor.getValue();
assertThat(command.getDeviceNo()).isEqualTo("D01");
assertThat(command.getDeviceMac()).isEqualTo("AABBCC");
assertThat(command.getCommandType()).isEqualTo("bindDevice");
assertThat(command.getPayload()).containsEntry("deviceNo", "D01");
}
@Test
void sendSwitchCommand_addsSecondsWhenStartTimeOnlyHasHourAndMinute() {
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(99L);
device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device);
when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1");
DeviceCommandServiceImpl service = new DeviceCommandServiceImpl(deviceService, wateringLogService);
ReflectionTestUtils.setField(service, "commandPublisher", commandPublisher);
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(99L);
service.sendSwitchCommand("D01", "1", "08:05", 10);
}
ArgumentCaptor<DeviceCommand> commandCaptor = ArgumentCaptor.forClass(DeviceCommand.class);
verify(commandPublisher).send(commandCaptor.capture());
assertThat(commandCaptor.getValue().getPayload()).containsEntry("startTime", "08:05:00");
ArgumentCaptor<AppWateringLogBo> logCaptor = ArgumentCaptor.forClass(AppWateringLogBo.class);
verify(wateringLogService).insertByBo(logCaptor.capture());
AppWateringLogBo logBo = logCaptor.getValue();
assertThat(new SimpleDateFormat("HH:mm:ss").format(logBo.getStartTime())).isEqualTo("08:05:00");
assertThat(new SimpleDateFormat("HH:mm:ss").format(logBo.getEndTime())).isEqualTo("08:15:00");
}
@Test
void sendSwitchCommand_stopFinishesCurrentRunningLogForDevice() {
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(99L);
device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device);
when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1");
when(wateringLogService.finishRunningLogsByDevice(any(), any(), any())).thenReturn(1);
DeviceCommandServiceImpl service = new DeviceCommandServiceImpl(deviceService, wateringLogService);
ReflectionTestUtils.setField(service, "commandPublisher", commandPublisher);
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(99L);
service.sendSwitchCommand("D01", "0", null, null);
}
verify(wateringLogService).finishRunningLogsByDevice(eq("D01"), any(), eq("手动停止浇水"));
verify(wateringLogService, never()).finishManualLog(any(), any(), any(), any());
}
}

View File

@@ -0,0 +1,83 @@
package org.dromara.app.task;
import cn.hutool.extra.spring.SpringUtil;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.handler.MqttDeviceStatusService;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.common.redis.utils.RedisUtils;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.redisson.api.RedissonClient;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class DeviceOfflineCheckTaskTest {
private static GenericApplicationContext applicationContext;
@Mock
private AppDeviceMapper appDeviceMapper;
@Mock
private MqttDeviceStatusService deviceStatusService;
@BeforeAll
static void initializeInfrastructure() {
if (TableInfoHelper.getTableInfo(AppDevice.class) == null) {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), ""), AppDevice.class);
}
applicationContext = new GenericApplicationContext();
applicationContext.registerBean(RedissonClient.class, () -> mock(RedissonClient.class));
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
}
@AfterAll
static void closeApplicationContext() {
applicationContext.close();
}
@Test
void checkOfflineDevicesAddsTtlToLegacyPermanentOnlineKey() {
DeviceOfflineCheckTask task = newTask();
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
when(appDeviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(device));
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(() -> RedisUtils.hasKey("mqtt:device:status:D01")).thenReturn(true);
redis.when(() -> RedisUtils.getTimeToLive("mqtt:device:status:D01")).thenReturn(-1L);
task.checkOfflineDevices();
redis.verify(() -> RedisUtils.expire("mqtt:device:status:D01", Duration.ofSeconds(900)));
verify(deviceStatusService, never()).markOfflineIfCacheMissing(any(), any());
}
}
private DeviceOfflineCheckTask newTask() {
DeviceOfflineCheckTask task = new DeviceOfflineCheckTask(appDeviceMapper, deviceStatusService);
ReflectionTestUtils.setField(task, "deviceStatusCachePrefix", "mqtt:device:status:");
ReflectionTestUtils.setField(task, "deviceStatusCacheTtlSeconds", 900L);
ReflectionTestUtils.setField(task, "enabled", true);
ReflectionTestUtils.setField(task, "ttlCompatEnabled", true);
return task;
}
}