2 Commits

Author SHA1 Message Date
yuhaiming
509e760f3d 设备bug修改 新增设备解绑功能 2026-08-25 16:18:34 +08:00
yuhaiming
b789c1c07e fix: default register client id 2026-08-14 10:35:42 +08:00
94 changed files with 4596 additions and 403 deletions

4
.gitignore vendored
View File

@@ -64,4 +64,6 @@
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
/docs/ /docs/*
!/docs/adr/
!/docs/adr/*.md

65
CONTEXT.md Normal file
View File

@@ -0,0 +1,65 @@
# Watex Device Management
This context defines how Watex manages device identity, ownership, and user-generated device data throughout a device's lifecycle.
## Language
**Active Device Binding**:
The exclusive relationship that grants one user access to a device's live state, details, data, and controls and permits binding-scoped MQTT reports to create or update data. A device has at most one active binding at a time.
_Avoid_: Device ownership, shared binding
**User Device Relationship**:
The single lifecycle relationship between one user and one device that drives the user's list state. It is reactivated rather than duplicated when the same user binds the device again, while the device may have relationships with different users over time.
_Avoid_: Binding history, device ownership
**Binding in Progress**:
The exclusive reservation created while a binding identifier is being delivered to the device. It appears as "binding" in the user's list and grants no device access until the device acknowledges that identifier.
_Avoid_: Active binding, bound device
**Binding Failed**:
A non-active list state reached when binding delivery exhausts its retries without device acknowledgment. It grants no device access, does not reserve the device, and allows the user to retry or remove the list entry.
_Avoid_: Active binding, binding in progress
**Device Unbinding**:
The source-independent workflow that terminates a device's active binding, revokes the former user's device access, and clears user-specific or runtime data while retaining the device's inventory identity and an unbound device entry for that user.
_Avoid_: Device deletion, device removal
**Unbinding in Progress**:
A binding state entered as soon as a valid unbinding command is accepted. It appears as a non-interactive "unbinding" item in the former user's device list, grants no device access, prevents any new binding while cleanup is incomplete, and never returns to active after cleanup failure.
_Avoid_: Active binding, completed unbinding
**Unbinding Recovery**:
The durable continuation of cleanup for a binding in progress. It survives server restarts, retries without depending on another device message, and keeps the device unavailable until cleanup succeeds.
_Avoid_: Binding rollback, device-triggered retry
**Device-Initiated Unbinding**:
Device unbinding initiated when the server receives an MQTT unbinding message from the device. It follows the same state and cleanup rules as user-initiated and administrator-initiated unbinding; offline button behavior and messages not received by the server are outside this workflow.
_Avoid_: Offline unbinding, local reset
**Unbound Device Entry**:
A display-only item retained in a former user's device list with the device name and number captured at unbinding, the unbound status, and the unbinding time. If the same user binds the device again, this item becomes the active list item instead of creating a binding-history entry; it grants no access while unbound.
_Avoid_: Binding history, archived device, inactive binding
**List Entry Removal**:
The physical removal of the user's `UNBOUND` or `BIND_FAILED` relationship from their own device list. It does not remove command or audit records, delete the device's inventory identity, or affect another user's active binding; a later binding creates a new relationship.
_Avoid_: Device deletion, device unbinding
**Binding-Scoped Device Data**:
User configuration and runtime data belonging to an active binding, including the user's device name, Wi-Fi credentials, watering records, telemetry state, pending commands, and runtime caches. It is permanently cleared when that binding is terminated and is never part of an unbound device entry.
_Avoid_: Device history, archived device data
**Device Inventory Identity**:
The stable device information retained across bindings: device number, MAC address, factory name, model, serial number, firmware version, QR code, image, and expiration time. It allows an unbound device to remain managed and bindable without retaining a former user's data.
_Avoid_: User device data, device binding
**User Schedule**:
A user-owned watering plan whose timing and configuration exist independently of any one device. Unbinding a device removes that device's schedule associations but retains the schedule, its details, and associations with other devices.
_Avoid_: Device schedule, device-owned schedule
**Device Deletion**:
The permanent removal of an unbound device's inventory identity together with its related data. A bound device must be unbound before it can be deleted.
_Avoid_: Device unbinding
**Device Initialization**:
The physical reset that removes user configuration from a device after unbinding. Device-initiated unbinding completes it after an `applied` result, while user-initiated or administrator-initiated unbinding requests it asynchronously from the server.
_Avoid_: Device unbinding

View File

@@ -89,17 +89,26 @@ long nextRetryAt; // 下次重试时间戳
} }
``` ```
与用户绑定关系有关的命令(`switchDevice`、排程命令、`queryPower``otaUpgrade` 和自定义命令)还会携带当前关系的 `bindingId`。设备应在对应上报或 ACK 中原样返回 `bindingId`;服务端只接受与当前 ACTIVE 绑定一致的报文,旧绑定报文会被丢弃。`bindDevice` 使用命令自身携带的目标 `bindingId``initDevice` 不携带绑定标识。
### 3.2 上行 ACK 结构DeviceCommandAck ### 3.2 上行 ACK 结构DeviceCommandAck
```json ```json
{ {
"deviceNo": "设备编号",
"commandId": "对应下发命令的commandId", "commandId": "对应下发命令的commandId",
"status": "1", "bindingId": "对应下发命令的绑定标识(有则原样返回)",
"message": "可选的文本消息" "ack": "receive",
"status": "兼容旧协议的确认标志",
"message": "可选的文本消息",
"powerLevel": 4,
"charging": 0
} }
``` ```
> 也支持非 JSON 格式的纯文本 ACK当设备只有一条待确认命令时可自动匹配 > - 设备确认标志优先使用 `ack` 字段(以 `"receive"` 开头即视为接受/成功,如 `"receive"`、`"receive deviceNo"``status` 为兼容旧协议的字段
> - `powerLevel` / `charging` 仅在 `queryPower` 命令的 ACK 中返回,为**数字类型**,服务端会自动转为字符串后分别更新到设备表的 `power_level` 和 `power_status` 字段。
> - 也支持非 JSON 格式的纯文本 ACK当设备只有一条待确认命令时可自动匹配
--- ---
@@ -116,6 +125,7 @@ long nextRetryAt; // 下次重试时间戳
"commandId": "xxx", "commandId": "xxx",
"commandType": "switchDevice", "commandType": "switchDevice",
"deviceNo": "01", "deviceNo": "01",
"bindingId": "当前绑定标识",
"cmd": "1", "cmd": "1",
"startTime": "2026-06-25 14:35:00", "startTime": "2026-06-25 14:35:00",
"durationMin": 20 "durationMin": 20
@@ -214,6 +224,7 @@ long nextRetryAt; // 下次重试时间戳
"commandType": "bindSchedule", "commandType": "bindSchedule",
"cmd": -1, "cmd": -1,
"deviceNo": "01", "deviceNo": "01",
"bindingId": "当前绑定标识",
"schedule": { "schedule": {
"id": 1, "id": 1,
"name": "每日浇水", "name": "每日浇水",
@@ -258,6 +269,7 @@ long nextRetryAt; // 下次重试时间戳
"commandType": "unbindSchedule", "commandType": "unbindSchedule",
"cmd": -1, "cmd": -1,
"deviceNo": "01", "deviceNo": "01",
"bindingId": "当前绑定标识",
"scheduleId": 1, "scheduleId": 1,
"unbind": true "unbind": true
} }
@@ -285,6 +297,83 @@ long nextRetryAt; // 下次重试时间戳
--- ---
### 4.8 电量查询命令 — `queryPower`
**触发**: 用户在 APP 主动查询设备电量
**接口**: `POST /app/v1/queryPower/{deviceNo}`
**Topic**: `/{deviceNo}/subscriber/cmd`
```json
{
"commandId": "xxx",
"commandType": "queryPower",
"deviceNo": "01",
"bindingId": "当前绑定标识"
}
```
**设备 ACK 回复**`/{deviceNo}/publish/ack`
```json
{
"deviceNo": "2074069050702561282",
"commandId": "xxx",
"bindingId": "当前绑定标识",
"powerLevel": 4,
"charging": 0,
"ack": "receive"
}
```
**附加行为**: 服务端收到 ACK 后,将 `powerLevel` 更新到设备表的 `power_level` 字段、`charging` 更新到 `power_status` 字段,并刷新 `power_level_updatatime``powerLevel`/`charging` 为数字类型(服务端自动转字符串入库),电量查询 ACK 不依赖 `status` 字段。
---
### 4.9 设备 OTA 升级命令 — `otaUpgrade`
**触发**: 管理员在设备列表选择设备(或全部在线设备)下发固件升级
**接口**: `POST /app/firmware/upgrade` · `POST /app/firmware/upgradeAll`
**Topic**: `/{deviceNo}/subscriber/cmd`
```json
{
"commandId": "xxx",
"commandType": "otaUpgrade",
"deviceNo": "01",
"firmwareUrl": "https://api.example.com/app/firmware/download/0123456789abcdef0123456789abcdef.bin",
"firmwareVersion": "1.0.0",
"md5": "可选,固件 MD5 校验值"
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `firmwareUrl` | String | 服务器固件下载地址 |
| `firmwareVersion` | String | 目标固件版本号 |
| `md5` | String | 固件 MD5 校验值(可选) |
**升级结果回报**: 设备收到命令后回 ACK 确认,下载固件并升级,重启后重新走 `/publish/register` 上报新 `version`,服务端据此更新设备表的 `fw_ver` 字段。
---
### 4.10 设备恢复出厂命令 — `factoryReset`
**触发**: 管理员在设备列表控制设备恢复出厂
**接口**: `POST /app/device/factory-reset/{deviceNo}`
**Topic**: `/{deviceNo}/subscriber/cmd`
```json
{
"commandId": "xxx",
"commandType": "factoryReset",
"deviceNo": "01"
}
```
服务端只下发 MQTT 命令并等待设备 ACK不清理服务端的用户绑定、排程或浇水日志。
---
## 5. 上行消息类型详解 ## 5. 上行消息类型详解
### 5.1 设备注册 — `/{identity}/publish/register` ### 5.1 设备注册 — `/{identity}/publish/register`
@@ -315,7 +404,8 @@ long nextRetryAt; // 下次重试时间戳
```json ```json
{ {
"powerLevel": "78" "powerLevel": "78",
"bindingId": "当前绑定标识"
} }
``` ```
@@ -354,6 +444,7 @@ long nextRetryAt; // 下次重试时间戳
{ {
"deviceNo": "01", "deviceNo": "01",
"commandId": "对应的命令ID", "commandId": "对应的命令ID",
"bindingId": "当前绑定标识",
"startTime": "2026-06-25 08:00:00", "startTime": "2026-06-25 08:00:00",
"endTime": "2026-06-25 08:15:00", "endTime": "2026-06-25 08:15:00",
"durationMin": 15, "durationMin": 15,
@@ -373,6 +464,7 @@ long nextRetryAt; // 下次重试时间戳
{ {
"deviceNo": "01", "deviceNo": "01",
"commandId": "对应的命令ID", "commandId": "对应的命令ID",
"bindingId": "当前绑定标识",
"startTime": "2026-06-25 08:00:00", "startTime": "2026-06-25 08:00:00",
"endTime": "2026-06-25 08:15:00", "endTime": "2026-06-25 08:15:00",
"durationMin": 15, "durationMin": 15,
@@ -390,7 +482,8 @@ long nextRetryAt; // 下次重试时间戳
```json ```json
{ {
"errorCode": "E001", "errorCode": "E001",
"message": "水泵故障" "message": "水泵故障",
"bindingId": "当前绑定标识"
} }
``` ```
@@ -468,6 +561,9 @@ long nextRetryAt; // 下次重试时间戳
| 绑定设备 | `POST /addDevice` | `bindDevice` | Service 层下发 | | 绑定设备 | `POST /addDevice` | `bindDevice` | Service 层下发 |
| 解绑设备 | `DELETE /deleteDevice/{deviceNos}` | `initDevice` | Service 层下发(每台设备) | | 解绑设备 | `DELETE /deleteDevice/{deviceNos}` | `initDevice` | Service 层下发(每台设备) |
| 开关设备 | `PUT /switchDevice` | `switchDevice` | Service 层下发 + 浇水日志 | | 开关设备 | `PUT /switchDevice` | `switchDevice` | Service 层下发 + 浇水日志 |
| 查询电量 | `POST /queryPower/{deviceNo}` | `queryPower` | Service 层下发ACK 回复电量 |
| OTA 升级 | `POST /firmware/upgrade` · `POST /firmware/upgradeAll` | `otaUpgrade` | Service 层下发,重启注册后更新 fw_ver |
| 恢复出厂 | `POST /device/factory-reset/{deviceNo}` | `factoryReset` | 仅下发 MQTT 命令,不修改服务端关系数据 |
| 绑定排程设备 | `POST /addScheduleDevice` | `bindSchedule` | Controller 层下发 | | 绑定排程设备 | `POST /addScheduleDevice` | `bindSchedule` | Controller 层下发 |
| 解绑排程设备 | `DELETE /deleteScheduleDevice` | `unbindSchedule` | Controller 层下发 | | 解绑排程设备 | `DELETE /deleteScheduleDevice` | `unbindSchedule` | Controller 层下发 |
| 修改排程状态 | `PUT /editScheduleStatus` | `bindSchedule` | 向所有绑定设备重新下发 | | 修改排程状态 | `PUT /editScheduleStatus` | `bindSchedule` | 向所有绑定设备重新下发 |

View File

@@ -0,0 +1,19 @@
workflow: hotfix
phase: verify
context_compression: off
build_mode: direct
build_pause: null
subagent_dispatch: null
tdd_mode: direct
isolation: branch
verify_mode: full
auto_transition: true
base_ref: 2f7597ed53736b965191d4e48ee2c73d09ff71c0
design_doc: null
plan: null
verify_result: pending
verification_report: docs/superpowers/reports/2026-08-14-fix-register-body-validation-verify.md
branch_status: pending
created_at: 2026-08-14
verified_at: null
archived: false

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-14

View File

@@ -0,0 +1,27 @@
## Context
`AuthController.register` 当前依赖 Spring MVC 的 `@Validated` 自动校验。该校验发生在控制器方法调用之前,所以方法体无法为缺失的 `clientId` 补默认值。
## Goals / Non-Goals
**Goals:**
- 空白 `clientId` 使用固定注册客户端 ID `32324a04c0175c8d61ac8a282553aea1`
- 默认值补充后仍执行 `RegisterBody` 的全部 Bean Validation 约束。
- 保持注册开关检查和注册服务调用顺序不变。
**Non-Goals:**
- 不调整 `RegisterBody` 的继承结构或其他登录字段。
- 不改变登录接口和认证客户端配置。
## Decisions
- 移除注册方法参数上的 `@Validated`,在方法体开始处补默认 `clientId` 后调用现有 `ValidatorUtils.validate(user)`。该工具已被认证控制器和各登录策略使用,异常处理行为与现有认证流程一致。
- 使用 `StringUtils.isBlank`,同时覆盖 `null`、空字符串和纯空白字符串。
- 通过 `AuthController` 单元测试观察注册服务收到的请求对象,验证公共接口行为。
## Risks / Trade-offs
- 固定客户端 ID 变更时需要同步修改代码和测试;本次按明确业务值实现。
- 手动校验依赖控制器方法内调用顺序;测试覆盖默认值补充发生在校验和业务调用之前。

View File

@@ -0,0 +1,25 @@
## Why
`/auth/register` 使用 `@Validated` 在进入控制器方法前校验 `RegisterBody`,缺少 `clientId` 的请求会直接返回“认证客户端id不能为空”因此控制器无法为注册请求补充系统默认客户端 ID。注册入口需要先补默认值再执行原有参数校验。
## What Changes
- 注册接口收到空白 `clientId` 时,将其设置为 `32324a04c0175c8d61ac8a282553aea1`
- 默认值补充完成后,通过现有 `ValidatorUtils` 执行 `RegisterBody` 的完整 Bean Validation。
- 增加控制器回归测试,覆盖默认客户端 ID 和注册服务调用。
## Capabilities
### New Capabilities
- `auth-registration`: 为已有注册入口补充默认认证客户端 ID 的行为规范。
### Modified Capabilities
无。仓库当前没有注册流程的既有 OpenSpec。
## Impact
- 影响 `water-admin` 中的 `/auth/register` 控制器及其单元测试。
- `/auth/register` 允许调用方省略或传入空白 `clientId`,其他注册参数约束保持不变。
- 不新增依赖,不修改数据库结构,也不新增公共端点。

View File

@@ -0,0 +1,11 @@
## ADDED Requirements
### Requirement: 注册请求使用默认认证客户端
系统 SHALL 在校验注册请求前,为缺失或空白的 `clientId` 设置默认值 `32324a04c0175c8d61ac8a282553aea1`,并在补值后执行 `RegisterBody` 的全部参数约束。
#### Scenario: 注册请求未提供客户端 ID
- **WHEN** 调用方提交的注册请求中 `clientId` 缺失或为空白
- **THEN** 系统将 `clientId` 设置为 `32324a04c0175c8d61ac8a282553aea1`
- **AND** 系统在调用注册服务前校验补值后的注册请求

View File

@@ -0,0 +1,4 @@
## 1. 注册默认客户端 ID
- [x] 1.1 在控制器单元测试中覆盖空白 `clientId` 自动补默认值的注册场景,并确认修改前失败。
- [x] 1.2 调整 `/auth/register` 的校验顺序,补默认 `clientId` 后执行完整参数校验,并运行相关测试。

View File

@@ -0,0 +1,19 @@
-- 设备固件版本表OTA 升级MySQL 8+。
CREATE TABLE app_firmware (
id bigint NOT NULL COMMENT '主键',
firmware_version varchar(50) NOT NULL COMMENT '固件版本号',
file_name varchar(255) NOT NULL COMMENT '原始文件名',
file_url varchar(500) NOT NULL COMMENT '服务器固件下载地址',
file_size bigint NULL COMMENT '文件大小(字节)',
md5 varchar(64) NULL COMMENT 'MD5校验值',
release_notes varchar(1000) NULL COMMENT '更新说明',
status char(1) NOT NULL DEFAULT '1' COMMENT '状态 0停用 1启用',
create_dept bigint NULL COMMENT '创建部门',
create_by bigint NULL COMMENT '创建者',
create_time datetime NULL COMMENT '创建时间',
update_by bigint NULL COMMENT '更新者',
update_time datetime NULL COMMENT '更新时间',
remark varchar(500) NULL COMMENT '备注',
PRIMARY KEY (id),
KEY idx_app_firmware_version_status (firmware_version, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='设备固件版本表';

View File

@@ -0,0 +1,63 @@
-- User-device lifecycle and device-initiated unbinding support (MySQL 8+).
CREATE TABLE app_device_binding (
id bigint NOT NULL,
device_no varchar(64) NOT NULL COMMENT '设备编号',
user_id bigint NOT NULL COMMENT '用户ID',
binding_id varchar(64) NOT NULL COMMENT '本次绑定标识',
binding_status varchar(16) NOT NULL COMMENT 'BINDING/ACTIVE/BIND_FAILED/UNBINDING/UNBOUND',
binding_started_time datetime NULL COMMENT '本次绑定开始时间',
last_bind_command_time datetime NULL COMMENT '最近一次绑定命令下发时间',
device_name_snapshot varchar(255) NULL COMMENT '列表显示名称快照',
unbound_time datetime NULL COMMENT '解绑完成时间',
unbind_source varchar(16) NULL COMMENT 'DEVICE/APP/ADMIN',
unbind_command_id varchar(64) NULL COMMENT '触发解绑的命令编号',
cleanup_attempts int NOT NULL DEFAULT 0 COMMENT '清理尝试次数',
cleanup_error varchar(500) NULL COMMENT '最近清理错误',
create_dept bigint NULL,
create_by bigint NULL,
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint NULL,
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
exclusive_device_no varchar(64) GENERATED ALWAYS AS (
CASE WHEN binding_status IN ('BINDING', 'ACTIVE', 'UNBINDING') THEN device_no ELSE NULL END
) STORED,
PRIMARY KEY (id),
UNIQUE KEY uk_app_device_binding_user_device (user_id, device_no),
UNIQUE KEY uk_app_device_binding_binding_id (binding_id),
UNIQUE KEY uk_app_device_binding_exclusive (exclusive_device_no),
KEY idx_app_device_binding_device_status (device_no, binding_status),
KEY idx_app_device_binding_recovery (binding_status, binding_started_time, last_bind_command_time)
) COMMENT='用户设备绑定生命周期';
CREATE TABLE app_device_unbind_command (
id bigint NOT NULL,
device_no varchar(64) NOT NULL COMMENT '设备编号',
command_id varchar(64) NOT NULL COMMENT '设备解绑命令编号',
binding_id varchar(64) NOT NULL COMMENT '命令目标绑定标识',
command_status varchar(16) NOT NULL COMMENT 'PROCESSING/APPLIED/STALE/FAILED',
message varchar(500) NULL COMMENT '处理结果',
attempt_count int NOT NULL DEFAULT 0 COMMENT '处理次数',
completed_time datetime NULL COMMENT '完成时间',
create_dept bigint NULL,
create_by bigint NULL,
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint NULL,
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_app_device_unbind_command (device_no, command_id),
KEY idx_app_device_unbind_binding (device_no, binding_id)
) COMMENT='设备解绑命令处理记录';
-- Existing bindings must re-establish the device-side bindingId before becoming ACTIVE.
INSERT INTO app_device_binding (
id, device_no, user_id, binding_id, binding_status, device_name_snapshot,
binding_started_time, cleanup_attempts, create_time, update_time
)
SELECT
UUID_SHORT(), device_no, user_id, REPLACE(UUID(), '-', ''), 'BINDING', device_name,
NOW(), 0, NOW(), NOW()
FROM app_device
WHERE user_id IS NOT NULL;
-- Keep the legacy column during the rollout, but remove it as a relationship source.
UPDATE app_device SET user_id = NULL WHERE user_id IS NOT NULL;

View File

@@ -0,0 +1,66 @@
2026-08-18 17:55:39 [background-preinit] INFO o.h.validator.internal.util.Version - HV000001: Hibernate Validator 8.0.3.Final
2026-08-18 17:55:39 [main] INFO org.dromara.test.TagUnitTest - Starting TagUnitTest using Java 17.0.17 with PID 29612 (started by admin in D:\code\watex\water\water-admin)
2026-08-18 17:55:39 [main] INFO org.dromara.test.TagUnitTest - The following 1 profile is active: "dev"
2026-08-18 17:55:44 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource detect P6SPY plugin and enabled it
2026-08-18 17:55:44 [main] INFO com.zaxxer.hikari.HikariDataSource - master - Starting...
2026-08-18 17:55:45 [main] INFO com.zaxxer.hikari.pool.HikariPool - master - Added connection com.mysql.cj.jdbc.ConnectionImpl@1a18e68a
2026-08-18 17:55:45 [main] INFO com.zaxxer.hikari.HikariDataSource - master - Start completed.
2026-08-18 17:55:45 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource - add a datasource named [master] success
2026-08-18 17:55:45 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource initial loaded [1] datasource,primary datasource named [master]
2026-08-18 17:55:47 [main] WARN c.b.m.c.injector.DefaultSqlInjector - class org.dromara.app.domain.AppSchedulingDevice ,Not found @TableId annotation, Cannot use Mybatis-Plus 'xxById' Method.
2026-08-18 17:55:48 [main] INFO o.d.common.json.config.JacksonConfig - 初始化JSON序列化配置
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 消息分发器初始化完成,处理器数量=9
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceCommandAckHandler -> ^/([^/]+)/publish/ack$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceDataHandler -> ^/([^/]+)/publish/power$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceRegisterHandler -> ^/([^/]+)/publish/register$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceStatusHandler -> ^/([^/]+)/publish/status$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceUnbindHandler -> ^/([^/]+)/publish/unbind$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器ErromesHandler -> ^/([^/]+)/publish/error$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器KeyFinishHandler -> ^/([^/]+)/publish/finish/key$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器ScheduleFinishHandler -> ^/([^/]+)/publish/finish/schedule$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器StartWaterHandler -> ^/([^/]+)/publish/start$
2026-08-18 17:55:49 [main] INFO o.d.common.redis.config.RedisConfig - 初始化 Redis 配置
2026-08-18 17:55:49 [main] INFO org.redisson.Version - Redisson 3.52.0
2026-08-18 17:55:49 [redisson-netty-1-1] INFO o.r.connection.ConnectionsHolder - 1 connections initialized for 47.97.217.123/47.97.217.123:6379
2026-08-18 17:55:50 [redisson-netty-1-3] INFO o.r.connection.ConnectionsHolder - 8 connections initialized for 47.97.217.123/47.97.217.123:6379
2026-08-18 17:55:51 [main] INFO c.b.m.e.s.MybatisPlusApplicationContextAware - Register ApplicationContext instances org.springframework.web.context.support.GenericWebApplicationContext@212dfd39
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已连接ssl://www.mmipco.cn:8883
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/schedule 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/register 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/status 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/power 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/ack 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/start 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/key 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/error 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/unbind 服务质量等级=1
2026-08-18 17:55:55 [mqtt-consumer-6] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:55 设备编号=2087348193597157377 消息体={"deviceMac":"dc:da:0c:fa:27:7a","offline":"true","reason":"poweroff"}
2026-08-18 17:55:55 [mqtt-consumer-8] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:55 设备编号=2087366163811627009 消息体={"deviceMac":"dc:da:0c:fa:14:7a","offline":"true","reason":"poweroff"}
2026-08-18 17:55:55 [mqtt-consumer-8] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:55 设备编号=2074069050702561282 消息体={"deviceMac":"dc:da:0c:fa:27:5a","offline":"true","reason":"poweroff"}
2026-08-18 17:55:55 [main] INFO org.dromara.test.TagUnitTest - Started TagUnitTest in 17.013 seconds (process running for 18.345)
2026-08-18 17:55:55 [main] INFO o.d.c.sse.listener.SseTopicListener - 初始化SSE主题订阅监听器成功
2026-08-18 17:55:56 [MQTT Rec: water-server-28082] WARN org.dromara.mqtt.MqttClientManager - [MQTT] 连接已断开:已断开连接
2026-08-18 17:55:56 [main] INFO o.d.s.runner.SystemApplicationRunner - 初始化OSS配置成功
2026-08-18 17:55:56 [main] INFO o.d.m.MqttPendingCommandCleanupListener - [MQTT] 应用启动清理待确认命令完成,清理数量: 1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已重新连接ssl://www.mmipco.cn:8883
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/schedule 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/register 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/status 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/power 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/ack 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/start 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/key 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/error 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/unbind 服务质量等级=1
2026-08-18 17:55:57 [mqtt-consumer-6] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:57 设备编号=2087348193597157377 消息体={"deviceMac":"dc:da:0c:fa:27:7a","offline":"true","reason":"poweroff"}
2026-08-18 17:55:57 [mqtt-consumer-8] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:57 设备编号=2087366163811627009 消息体={"deviceMac":"dc:da:0c:fa:14:7a","offline":"true","reason":"poweroff"}
2026-08-18 17:55:58 [mqtt-consumer-8] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:58 设备编号=2074069050702561282 消息体={"deviceMac":"dc:da:0c:fa:27:5a","offline":"true","reason":"poweroff"}
2026-08-18 17:55:59 [MQTT Rec: water-server-28082] WARN org.dromara.mqtt.MqttClientManager - [MQTT] 连接已断开:已断开连接
2026-08-18 17:56:00 [SpringApplicationShutdownHook] WARN o.s.b.f.s.DisposableBeanAdapter - Invocation of close method failed on bean with name 'mqttConnect': 已在进行连接 (32110)
2026-08-18 17:56:00 [SpringApplicationShutdownHook] WARN o.s.b.f.s.DisposableBeanAdapter - Invocation of destroy method failed on bean with name 'org.dromara.mqtt.MqttClientManager': 已在进行连接 (32110)
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO o.d.c.core.config.ThreadPoolConfig - ====关闭后台任务任务线程池====
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource start closing ....
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - master - Shutdown initiated...
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - master - Shutdown completed.
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.d.DefaultDataSourceDestroyer - dynamic-datasource close the datasource named [master] success,
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource all closed success,bye

View File

View File

@@ -0,0 +1,55 @@
2026-08-18 17:55:39 [background-preinit] INFO o.h.validator.internal.util.Version - HV000001: Hibernate Validator 8.0.3.Final
2026-08-18 17:55:39 [main] INFO org.dromara.test.TagUnitTest - Starting TagUnitTest using Java 17.0.17 with PID 29612 (started by admin in D:\code\watex\water\water-admin)
2026-08-18 17:55:39 [main] INFO org.dromara.test.TagUnitTest - The following 1 profile is active: "dev"
2026-08-18 17:55:44 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource detect P6SPY plugin and enabled it
2026-08-18 17:55:44 [main] INFO com.zaxxer.hikari.HikariDataSource - master - Starting...
2026-08-18 17:55:45 [main] INFO com.zaxxer.hikari.pool.HikariPool - master - Added connection com.mysql.cj.jdbc.ConnectionImpl@1a18e68a
2026-08-18 17:55:45 [main] INFO com.zaxxer.hikari.HikariDataSource - master - Start completed.
2026-08-18 17:55:45 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource - add a datasource named [master] success
2026-08-18 17:55:45 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource initial loaded [1] datasource,primary datasource named [master]
2026-08-18 17:55:48 [main] INFO o.d.common.json.config.JacksonConfig - 初始化JSON序列化配置
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 消息分发器初始化完成,处理器数量=9
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceCommandAckHandler -> ^/([^/]+)/publish/ack$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceDataHandler -> ^/([^/]+)/publish/power$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceRegisterHandler -> ^/([^/]+)/publish/register$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceStatusHandler -> ^/([^/]+)/publish/status$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceUnbindHandler -> ^/([^/]+)/publish/unbind$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器ErromesHandler -> ^/([^/]+)/publish/error$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器KeyFinishHandler -> ^/([^/]+)/publish/finish/key$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器ScheduleFinishHandler -> ^/([^/]+)/publish/finish/schedule$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器StartWaterHandler -> ^/([^/]+)/publish/start$
2026-08-18 17:55:49 [main] INFO o.d.common.redis.config.RedisConfig - 初始化 Redis 配置
2026-08-18 17:55:49 [main] INFO org.redisson.Version - Redisson 3.52.0
2026-08-18 17:55:49 [redisson-netty-1-1] INFO o.r.connection.ConnectionsHolder - 1 connections initialized for 47.97.217.123/47.97.217.123:6379
2026-08-18 17:55:50 [redisson-netty-1-3] INFO o.r.connection.ConnectionsHolder - 8 connections initialized for 47.97.217.123/47.97.217.123:6379
2026-08-18 17:55:51 [main] INFO c.b.m.e.s.MybatisPlusApplicationContextAware - Register ApplicationContext instances org.springframework.web.context.support.GenericWebApplicationContext@212dfd39
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已连接ssl://www.mmipco.cn:8883
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/schedule 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/register 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/status 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/power 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/ack 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/start 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/key 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/error 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/unbind 服务质量等级=1
2026-08-18 17:55:55 [main] INFO org.dromara.test.TagUnitTest - Started TagUnitTest in 17.013 seconds (process running for 18.345)
2026-08-18 17:55:55 [main] INFO o.d.c.sse.listener.SseTopicListener - 初始化SSE主题订阅监听器成功
2026-08-18 17:55:56 [main] INFO o.d.s.runner.SystemApplicationRunner - 初始化OSS配置成功
2026-08-18 17:55:56 [main] INFO o.d.m.MqttPendingCommandCleanupListener - [MQTT] 应用启动清理待确认命令完成,清理数量: 1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已重新连接ssl://www.mmipco.cn:8883
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/schedule 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/register 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/status 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/power 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/ack 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/start 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/key 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/error 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/unbind 服务质量等级=1
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO o.d.c.core.config.ThreadPoolConfig - ====关闭后台任务任务线程池====
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource start closing ....
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - master - Shutdown initiated...
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - master - Shutdown completed.
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.d.DefaultDataSourceDestroyer - dynamic-datasource close the datasource named [master] success,
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource all closed success,bye

View File

@@ -201,7 +201,11 @@ public class AuthController {
*/ */
@ApiEncrypt @ApiEncrypt
@PostMapping("/register") @PostMapping("/register")
public R<Void> register(@Validated @RequestBody RegisterBody user) { public R<Void> register(@RequestBody RegisterBody user) {
if (StringUtils.isBlank(user.getClientId())) {
user.setClientId("32324a04c0175c8d61ac8a282553aea1");
}
ValidatorUtils.validate(user);
if (!configService.selectRegisterEnabled(user.getTenantId())) { if (!configService.selectRegisterEnabled(user.getTenantId())) {
return R.fail("当前系统没有开启注册功能!"); return R.fail("当前系统没有开启注册功能!");
} }

View File

@@ -2,9 +2,16 @@ package org.dromara.web.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.dromara.app.domain.*; import org.dromara.app.domain.AppSchedule;
import org.dromara.app.mapper.*; import org.dromara.app.domain.AppScheduleDetail;
import org.dromara.app.service.IDeviceCommandService; import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.AppWateringLog;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppScheduleDetailMapper;
import org.dromara.app.mapper.AppScheduleMapper;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mapper.AppWateringLogMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils; import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.common.satoken.utils.LoginHelper;
@@ -29,13 +36,12 @@ public class AccountCancellationService {
private final SysUserRoleMapper sysUserRoleMapper; private final SysUserRoleMapper sysUserRoleMapper;
private final SysUserPostMapper sysUserPostMapper; private final SysUserPostMapper sysUserPostMapper;
private final SysSocialMapper sysSocialMapper; private final SysSocialMapper sysSocialMapper;
private final AppDeviceMapper appDeviceMapper;
private final AppScheduleMapper appScheduleMapper; private final AppScheduleMapper appScheduleMapper;
private final AppScheduleDetailMapper appScheduleDetailMapper; private final AppScheduleDetailMapper appScheduleDetailMapper;
private final AppSchedulingDeviceMapper appSchedulingDeviceMapper; private final AppSchedulingDeviceMapper appSchedulingDeviceMapper;
private final AppWateringLogMapper appWateringLogMapper; private final AppWateringLogMapper appWateringLogMapper;
private final AccountCancelCodeService accountCancelCodeService; private final AccountCancelCodeService accountCancelCodeService;
private final IDeviceCommandService deviceCommandService; private final IAppDeviceBindingService deviceBindingService;
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void cancelCurrentAccount(String code) { public void cancelCurrentAccount(String code) {
@@ -52,9 +58,12 @@ public class AccountCancellationService {
List<String> deviceNos = queryUserDeviceNos(userId); List<String> deviceNos = queryUserDeviceNos(userId);
List<Long> scheduleIds = queryUserScheduleIds(userId); List<Long> scheduleIds = queryUserScheduleIds(userId);
sendInitDeviceCommands(deviceNos); if (!deviceNos.isEmpty()) {
deviceBindingService.unbindByUser(deviceNos, userId);
}
deviceBindingService.removeAllListEntries(userId);
deleteAppScheduleData(userId, scheduleIds); deleteAppScheduleData(userId, scheduleIds);
deleteAppDeviceData(userId, deviceNos); deleteAppUserData(userId);
deleteSystemUserData(userId); deleteSystemUserData(userId);
accountCancelCodeService.delete(username); accountCancelCodeService.delete(username);
} }
@@ -68,12 +77,8 @@ public class AccountCancellationService {
} }
private List<String> queryUserDeviceNos(Long userId) { private List<String> queryUserDeviceNos(Long userId) {
return appDeviceMapper.selectList( return deviceBindingService.queryActiveDevices(userId).stream()
Wrappers.<AppDevice>lambdaQuery() .map(AppDeviceVo::getDeviceNo)
.select(AppDevice::getDeviceNo)
.eq(AppDevice::getUserId, userId)
).stream()
.map(AppDevice::getDeviceNo)
.filter(StringUtils::isNotBlank) .filter(StringUtils::isNotBlank)
.distinct() .distinct()
.toList(); .toList();
@@ -91,12 +96,6 @@ public class AccountCancellationService {
.toList(); .toList();
} }
private void sendInitDeviceCommands(List<String> deviceNos) {
for (String deviceNo : deviceNos) {
deviceCommandService.sendInitDeviceCommand(deviceNo);
}
}
private void deleteAppScheduleData(Long userId, List<Long> scheduleIds) { private void deleteAppScheduleData(Long userId, List<Long> scheduleIds) {
if (!scheduleIds.isEmpty()) { if (!scheduleIds.isEmpty()) {
appSchedulingDeviceMapper.delete( appSchedulingDeviceMapper.delete(
@@ -114,25 +113,11 @@ public class AccountCancellationService {
); );
} }
private void deleteAppDeviceData(Long userId, List<String> deviceNos) { private void deleteAppUserData(Long userId) {
if (!deviceNos.isEmpty()) {
appSchedulingDeviceMapper.delete(
Wrappers.<AppSchedulingDevice>lambdaQuery()
.in(AppSchedulingDevice::getDeviceNo, deviceNos)
);
appWateringLogMapper.delete(
Wrappers.<AppWateringLog>lambdaQuery()
.in(AppWateringLog::getDeviceNo, deviceNos)
);
}
appWateringLogMapper.delete( appWateringLogMapper.delete(
Wrappers.<AppWateringLog>lambdaQuery() Wrappers.<AppWateringLog>lambdaQuery()
.eq(AppWateringLog::getUserId, userId) .eq(AppWateringLog::getUserId, userId)
); );
appDeviceMapper.delete(
Wrappers.<AppDevice>lambdaQuery()
.eq(AppDevice::getUserId, userId)
);
} }
private void deleteSystemUserData(Long userId) { private void deleteSystemUserData(Long userId) {

View File

@@ -69,7 +69,7 @@ public class SysRegisterService {
sysUser.setUserName(username); sysUser.setUserName(username);
sysUser.setNickName("用户"+new Date().getTime()); sysUser.setNickName("Watex_"+new Date().getTime());
sysUser.setPassword(BCrypt.hashpw(password)); sysUser.setPassword(BCrypt.hashpw(password));
sysUser.setUserType(userType); sysUser.setUserType(userType);
boolean exist; boolean exist;

View File

@@ -19,6 +19,13 @@ server:
io: 8 io: 8
# 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载 # 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载
worker: 256 worker: 256
app:
firmware-storage:
# 固件持久化目录;生产环境应挂载到服务器持久化磁盘
directory: ${FIRMWARE_STORAGE_DIRECTORY:./data/firmware}
# 设备可访问的服务端公网地址;为空时根据上传请求生成
download-base-url: ${FIRMWARE_DOWNLOAD_BASE_URL:}
--- # mqtt配置信息 --- # mqtt配置信息
mqtt: mqtt:
enabled: ${MQTT_ENABLED:true} enabled: ${MQTT_ENABLED:true}
@@ -26,13 +33,14 @@ 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-1${server.port} client-id: water-server-2${server.port}
qos: 1 qos: 1
keep-alive: 300 keep-alive: 300
connection-timeout: 30 connection-timeout: 30
max-inflight: 5000 max-inflight: 5000
clean-session: false clean-session: false
automatic-reconnect: true automatic-reconnect: true
register-device-no-dedup-seconds: 30
async: async:
core-pool-size: 16 core-pool-size: 16
max-pool-size: 64 max-pool-size: 64
@@ -51,6 +59,7 @@ mqtt:
scan-interval-ms: 5000 scan-interval-ms: 5000
pending-ttl-seconds: 86400 pending-ttl-seconds: 86400
ack-ttl-seconds: 86400 ack-ttl-seconds: 86400
startup-cleanup-wait-ms: 30000
pending-key-prefix: "mqtt:command:pending:" pending-key-prefix: "mqtt:command:pending:"
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"
@@ -63,6 +72,8 @@ mqtt:
interval-ms: 30000 interval-ms: 30000
topics: topics:
# 首次注册使用 MAC注册后的业务主题使用 deviceNoLWT 离线主题兼容 MAC # 首次注册使用 MAC注册后的业务主题使用 deviceNoLWT 离线主题兼容 MAC
# 同一共享组内仅一个后端实例消费每条上行消息,防止重复下发和重复处理 ACK
shared-group: ${MQTT_SHARED_GROUP:water-backend}
subscribe: subscribe:
- /+/publish/finish/schedule #排程任务完成上报 - /+/publish/finish/schedule #排程任务完成上报
@@ -73,6 +84,7 @@ mqtt:
- /+/publish/start #开始浇水上报 - /+/publish/start #开始浇水上报
- /+/publish/finish/key #按键浇水完成 - /+/publish/finish/key #按键浇水完成
- /+/publish/error #硬件故障 - /+/publish/error #硬件故障
- /+/publish/unbind #设备长按解绑
# - /+/subscriber/cmd #下发命令 0关闭浇水 1开始浇水 # - /+/subscriber/cmd #下发命令 0关闭浇水 1开始浇水
publish-prefix: "" publish-prefix: ""
@@ -173,6 +185,7 @@ security:
- /*/api-docs/** - /*/api-docs/**
- /warm-flow-ui/config - /warm-flow-ui/config
- /app/v1/retrievePassword - /app/v1/retrievePassword
- /app/firmware/download/**
# 多租户配置 # 多租户配置
tenant: tenant:

View File

@@ -1,20 +1,28 @@
package org.dromara.web.controller; package org.dromara.web.controller;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.extra.spring.SpringUtil;
import jakarta.validation.Validation;
import jakarta.validation.ValidatorFactory;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.AccountCancelBody; import org.dromara.common.core.domain.model.AccountCancelBody;
import org.dromara.common.core.domain.model.RegisterBody;
import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.service.ISysConfigService;
import org.dromara.web.service.AccountCancellationService; import org.dromara.web.service.AccountCancellationService;
import org.dromara.web.service.SysRegisterService;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@Tag("dev") @Tag("dev")
@@ -23,6 +31,12 @@ class AuthControllerUnitTest {
@Mock @Mock
private AccountCancellationService accountCancellationService; private AccountCancellationService accountCancellationService;
@Mock
private SysRegisterService registerService;
@Mock
private ISysConfigService configService;
@Test @Test
void cancelAccount_checksLoginCancelsAccountAndLogsOut() { void cancelAccount_checksLoginCancelsAccountAndLogsOut() {
AuthController controller = new AuthController( AuthController controller = new AuthController(
@@ -52,4 +66,38 @@ class AuthControllerUnitTest {
stpUtil.verify(() -> StpUtil.logout(100L)); stpUtil.verify(() -> StpUtil.logout(100L));
} }
} }
@Test
void register_defaultsClientIdWhenMissing() {
AuthController controller = new AuthController(
null,
null,
registerService,
configService,
null,
null,
null,
null,
null,
accountCancellationService
);
RegisterBody user = new RegisterBody();
user.setUsername("register-user");
user.setPassword("12345");
user.setGrantType("password");
when(configService.selectRegisterEnabled(user.getTenantId())).thenReturn(true);
try (ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
GenericApplicationContext context = new GenericApplicationContext()) {
context.getBeanFactory().registerSingleton("validator", validatorFactory.getValidator());
context.refresh();
new SpringUtil().setApplicationContext(context);
R<Void> result = controller.register(user);
assertThat(result.getCode()).isEqualTo(200);
assertThat(user.getClientId()).isEqualTo("32324a04c0175c8d61ac8a282553aea1");
verify(registerService).register(user);
}
}
} }

View File

@@ -4,9 +4,16 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.dromara.app.domain.*; import org.dromara.app.domain.AppSchedule;
import org.dromara.app.mapper.*; import org.dromara.app.domain.AppScheduleDetail;
import org.dromara.app.service.IDeviceCommandService; import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.AppWateringLog;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppScheduleDetailMapper;
import org.dromara.app.mapper.AppScheduleMapper;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mapper.AppWateringLogMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.exception.user.CaptchaExpireException; import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException; import org.dromara.common.core.exception.user.UserException;
@@ -45,8 +52,6 @@ class AccountCancellationServiceUnitTest {
@Mock @Mock
private SysSocialMapper sysSocialMapper; private SysSocialMapper sysSocialMapper;
@Mock @Mock
private AppDeviceMapper appDeviceMapper;
@Mock
private AppScheduleMapper appScheduleMapper; private AppScheduleMapper appScheduleMapper;
@Mock @Mock
private AppScheduleDetailMapper appScheduleDetailMapper; private AppScheduleDetailMapper appScheduleDetailMapper;
@@ -57,12 +62,11 @@ class AccountCancellationServiceUnitTest {
@Mock @Mock
private AccountCancelCodeService accountCancelCodeService; private AccountCancelCodeService accountCancelCodeService;
@Mock @Mock
private IDeviceCommandService deviceCommandService; private IAppDeviceBindingService deviceBindingService;
@BeforeAll @BeforeAll
static void initTableInfo() { static void initTableInfo() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
initTableInfo(assistant, AppDevice.class);
initTableInfo(assistant, AppSchedule.class); initTableInfo(assistant, AppSchedule.class);
initTableInfo(assistant, AppScheduleDetail.class); initTableInfo(assistant, AppScheduleDetail.class);
initTableInfo(assistant, AppSchedulingDevice.class); initTableInfo(assistant, AppSchedulingDevice.class);
@@ -80,11 +84,11 @@ class AccountCancellationServiceUnitTest {
@Test @Test
void cancelCurrentAccount_deletesCurrentUserAndRelatedData() { void cancelCurrentAccount_deletesCurrentUserAndRelatedData() {
AccountCancellationService service = newService(); AccountCancellationService service = newService();
AppDevice device = new AppDevice(); AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01"); device.setDeviceNo("D01");
AppSchedule schedule = new AppSchedule(); AppSchedule schedule = new AppSchedule();
schedule.setId(10L); schedule.setId(10L);
when(appDeviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(device)); when(deviceBindingService.queryActiveDevices(100L)).thenReturn(List.of(device));
when(appScheduleMapper.selectList(any(Wrapper.class))).thenReturn(List.of(schedule)); when(appScheduleMapper.selectList(any(Wrapper.class))).thenReturn(List.of(schedule));
when(sysUserMapper.deleteById(100L)).thenReturn(1); when(sysUserMapper.deleteById(100L)).thenReturn(1);
@@ -96,12 +100,12 @@ class AccountCancellationServiceUnitTest {
service.cancelCurrentAccount("123456"); service.cancelCurrentAccount("123456");
verify(accountCancelCodeService).validate("zhangsan", "123456"); verify(accountCancelCodeService).validate("zhangsan", "123456");
verify(deviceCommandService).sendInitDeviceCommand("D01"); verify(deviceBindingService).unbindByUser(List.of("D01"), 100L);
verify(deviceBindingService).removeAllListEntries(100L);
verify(appSchedulingDeviceMapper, atLeastOnce()).delete(any(Wrapper.class)); verify(appSchedulingDeviceMapper, atLeastOnce()).delete(any(Wrapper.class));
verify(appScheduleDetailMapper).delete(any(Wrapper.class)); verify(appScheduleDetailMapper).delete(any(Wrapper.class));
verify(appScheduleMapper).delete(any(Wrapper.class)); verify(appScheduleMapper).delete(any(Wrapper.class));
verify(appWateringLogMapper, atLeastOnce()).delete(any(Wrapper.class)); verify(appWateringLogMapper, atLeastOnce()).delete(any(Wrapper.class));
verify(appDeviceMapper).delete(any(Wrapper.class));
verify(sysSocialMapper).delete(any(Wrapper.class)); verify(sysSocialMapper).delete(any(Wrapper.class));
verify(sysUserRoleMapper).delete(any(Wrapper.class)); verify(sysUserRoleMapper).delete(any(Wrapper.class));
verify(sysUserPostMapper).delete(any(Wrapper.class)); verify(sysUserPostMapper).delete(any(Wrapper.class));
@@ -127,7 +131,7 @@ class AccountCancellationServiceUnitTest {
sysUserRoleMapper, sysUserRoleMapper,
sysUserPostMapper, sysUserPostMapper,
sysSocialMapper, sysSocialMapper,
appDeviceMapper, deviceBindingService,
appScheduleMapper, appScheduleMapper,
appScheduleDetailMapper, appScheduleDetailMapper,
appSchedulingDeviceMapper, appSchedulingDeviceMapper,
@@ -156,7 +160,7 @@ class AccountCancellationServiceUnitTest {
sysUserRoleMapper, sysUserRoleMapper,
sysUserPostMapper, sysUserPostMapper,
sysSocialMapper, sysSocialMapper,
appDeviceMapper, deviceBindingService,
appScheduleMapper, appScheduleMapper,
appScheduleDetailMapper, appScheduleDetailMapper,
appSchedulingDeviceMapper, appSchedulingDeviceMapper,
@@ -186,7 +190,7 @@ class AccountCancellationServiceUnitTest {
sysUserRoleMapper, sysUserRoleMapper,
sysUserPostMapper, sysUserPostMapper,
sysSocialMapper, sysSocialMapper,
appDeviceMapper, deviceBindingService,
appScheduleMapper, appScheduleMapper,
appScheduleDetailMapper, appScheduleDetailMapper,
appSchedulingDeviceMapper, appSchedulingDeviceMapper,
@@ -203,13 +207,12 @@ class AccountCancellationServiceUnitTest {
sysUserRoleMapper, sysUserRoleMapper,
sysUserPostMapper, sysUserPostMapper,
sysSocialMapper, sysSocialMapper,
appDeviceMapper,
appScheduleMapper, appScheduleMapper,
appScheduleDetailMapper, appScheduleDetailMapper,
appSchedulingDeviceMapper, appSchedulingDeviceMapper,
appWateringLogMapper, appWateringLogMapper,
accountCancelCodeService, accountCancelCodeService,
deviceCommandService deviceBindingService
); );
} }
} }

View File

@@ -21,6 +21,7 @@ public class DeviceMqttCommandPublisher implements IDeviceCommandPublisher {
@Override @Override
public String send(DeviceCommand command) { public String send(DeviceCommand command) {
ackService.awaitStartupCleanup();
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
if (StringUtils.isBlank(command.getCommandId())) { if (StringUtils.isBlank(command.getCommandId())) {
command.setCommandId(UUID.randomUUID().toString().replace("-", "")); command.setCommandId(UUID.randomUUID().toString().replace("-", ""));

View File

@@ -51,6 +51,18 @@ public class MqttClientManager implements DisposableBean {
return identity.hashCode(); return identity.hashCode();
} }
static String subscriptionFilter(String topic, String sharedGroup) {
if (StringUtils.isBlank(topic) || StringUtils.isBlank(sharedGroup)
|| topic.startsWith("$share/") || topic.startsWith("$queue/")) {
return topic;
}
String group = sharedGroup.trim();
if (group.contains("/") || group.contains("+") || group.contains("#")) {
throw new ServiceException("MQTT 共享订阅组名称不能包含 /、+ 或 #");
}
return "$share/" + group + "/" + topic;
}
private void configureTls(MqttConnectOptions options) throws MqttException { private void configureTls(MqttConnectOptions options) throws MqttException {
if (!isTlsEnabled()) { if (!isTlsEnabled()) {
return; return;
@@ -254,14 +266,18 @@ public class MqttClientManager implements DisposableBean {
if (client == null || !client.isConnected()) { if (client == null || !client.isConnected()) {
return; return;
} }
List<String> topics = props.getTopics() == null ? List.of() : props.getTopics().getSubscribe(); MqttProperties.Topics topicProperties = props.getTopics();
List<String> topics = topicProperties == null ? List.of() : topicProperties.getSubscribe();
if (topics == null || topics.isEmpty()) { if (topics == null || topics.isEmpty()) {
log.warn("[MQTT] 未配置订阅主题"); log.warn("[MQTT] 未配置订阅主题");
return; return;
} }
int[] qos = topics.stream().mapToInt(topic -> props.getQos()).toArray(); List<String> subscriptionFilters = topics.stream()
client.subscribe(topics.toArray(new String[0]), qos).waitForCompletion(); .map(topic -> subscriptionFilter(topic, topicProperties.getSharedGroup()))
topics.forEach(topic -> log.info("[MQTT] 已订阅 主题={} 服务质量等级={}", topic, props.getQos())); .toList();
int[] qos = subscriptionFilters.stream().mapToInt(topic -> props.getQos()).toArray();
client.subscribe(subscriptionFilters.toArray(new String[0]), qos).waitForCompletion();
subscriptionFilters.forEach(topic -> log.info("[MQTT] 已订阅 主题={} 服务质量等级={}", topic, props.getQos()));
} catch (MqttException e) { } catch (MqttException e) {
log.error("[MQTT] 订阅失败", e); log.error("[MQTT] 订阅失败", e);
} }

View File

@@ -7,7 +7,10 @@ import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.mqtt.DeviceCommand; import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.domain.mqtt.DeviceCommandAck; import org.dromara.app.domain.mqtt.DeviceCommandAck;
import org.dromara.app.mapper.AppDeviceMapper; import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IDeviceCommandAckHandler; import org.dromara.app.service.IDeviceCommandAckHandler;
import org.dromara.app.service.IDeviceCommandLifecycleListener;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils; import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils; import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.redis.utils.RedisUtils; import org.dromara.common.redis.utils.RedisUtils;
@@ -21,6 +24,7 @@ import org.springframework.stereotype.Service;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.*; import java.util.*;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier; import java.util.function.BooleanSupplier;
@@ -37,7 +41,13 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
private MqttClientManager mqttClientManager; private MqttClientManager mqttClientManager;
private final MqttProperties mqttProperties; private final MqttProperties mqttProperties;
private final AppDeviceMapper appDeviceMapper; private final AppDeviceMapper appDeviceMapper;
private final CountDownLatch startupCleanupLatch = new CountDownLatch(1);
@Autowired(required = false)
private List<IDeviceCommandLifecycleListener> lifecycleListeners = Collections.emptyList();
private volatile boolean startupCleanupReady; private volatile boolean startupCleanupReady;
@Lazy
@Autowired(required = false)
private IAppDeviceBindingService deviceBindingService;
private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:"; private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:";
static boolean ackMatchesPendingCommand(String topicDeviceNo, DeviceCommand pendingCommand) { static boolean ackMatchesPendingCommand(String topicDeviceNo, DeviceCommand pendingCommand) {
@@ -109,6 +119,7 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
deviceNo, ack.getCommandId(), pending.getDeviceNo()); deviceNo, ack.getCommandId(), pending.getDeviceNo());
return false; return false;
} }
notifyCommandAcknowledged(pending, ack);
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; return true;
@@ -137,19 +148,23 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
RSet<String> pendingIds = pendingIds(); RSet<String> pendingIds = pendingIds();
Set<String> commandIds = pendingIds.readAll(); Set<String> commandIds = pendingIds.readAll();
pendingIds.removeAll(commandIds); pendingIds.removeAll(commandIds);
startupCleanupReady = true;
RuntimeException cleanupFailure = null; RuntimeException cleanupFailure = null;
for (String commandId : commandIds) { try {
try { for (String commandId : commandIds) {
RedisUtils.deleteObject(pendingKey(commandId)); try {
} catch (RuntimeException e) { RedisUtils.deleteObject(pendingKey(commandId));
if (cleanupFailure == null) { } catch (RuntimeException e) {
cleanupFailure = new RuntimeException("Failed to delete MQTT pending command object: " + commandId, e); if (cleanupFailure == null) {
} else { cleanupFailure = new RuntimeException("Failed to delete MQTT pending command object: " + commandId, e);
cleanupFailure.addSuppressed(e); } else {
cleanupFailure.addSuppressed(e);
}
} }
} }
} finally {
startupCleanupReady = true;
startupCleanupLatch.countDown();
} }
if (cleanupFailure != null) { if (cleanupFailure != null) {
throw cleanupFailure; throw cleanupFailure;
@@ -161,14 +176,51 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
return startupCleanupReady; return startupCleanupReady;
} }
void awaitStartupCleanup() {
if (startupCleanupReady) {
return;
}
long waitMs = Math.max(1000L, mqttProperties.getCommandAck().getStartupCleanupWaitMs());
try {
if (!startupCleanupLatch.await(waitMs, TimeUnit.MILLISECONDS)) {
throw new ServiceException("MQTT 启动清理未完成,暂不下发命令");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ServiceException("等待 MQTT 启动清理被中断");
}
}
public void removePending(String commandId) { public void removePending(String commandId) {
if (StringUtils.isBlank(commandId)) { if (StringUtils.isBlank(commandId)) {
return; return;
} }
withCommandLock(commandId, () -> { CommandLockResult result = withCommandLock(commandId, () -> {
deletePending(commandId); deletePending(commandId);
return true; return true;
}); });
if (result != CommandLockResult.SUCCESS) {
throw new IllegalStateException("清理 MQTT 待确认命令失败,命令编号=" + commandId + ",结果=" + result);
}
}
@Override
public void clearPendingCommands(Collection<String> deviceNos) {
if (deviceNos == null || deviceNos.isEmpty()) {
return;
}
Set<String> targetDeviceNos = deviceNos.stream()
.filter(StringUtils::isNotBlank)
.collect(java.util.stream.Collectors.toSet());
if (targetDeviceNos.isEmpty()) {
return;
}
for (String commandId : pendingIds().readAll()) {
DeviceCommand command = RedisUtils.getCacheObject(pendingKey(commandId));
if (command != null && targetDeviceNos.contains(command.getDeviceNo())) {
removePending(commandId);
}
}
} }
private void handlePlainAck(String deviceNo, String payload) { private void handlePlainAck(String deviceNo, String payload) {
@@ -199,6 +251,7 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
deviceNo, commandId, pending.getDeviceNo()); deviceNo, commandId, pending.getDeviceNo());
return false; return false;
} }
notifyCommandAcknowledged(pending, ack);
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; return true;
@@ -257,6 +310,7 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
return; return;
} }
if (command.getRetryCount() >= mqttProperties.getCommandAck().getMaxRetryCount()) { if (command.getRetryCount() >= mqttProperties.getCommandAck().getMaxRetryCount()) {
notifyCommandExpired(command);
deletePending(commandId); deletePending(commandId);
log.warn("[MQTT] 命令重试次数已达上限 设备编号={} 命令编号={}", command.getDeviceNo(), commandId); log.warn("[MQTT] 命令重试次数已达上限 设备编号={} 命令编号={}", command.getDeviceNo(), commandId);
return; return;
@@ -300,6 +354,9 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
} }
private void refreshDeviceOnline(String deviceNo) { private void refreshDeviceOnline(String deviceNo) {
if (deviceBindingService != null && !deviceBindingService.isActive(deviceNo)) {
return;
}
RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo); RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo);
boolean locked = false; boolean locked = false;
try { try {
@@ -341,6 +398,23 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
pendingIds().remove(commandId); pendingIds().remove(commandId);
} }
private void notifyCommandAcknowledged(DeviceCommand command, DeviceCommandAck ack) {
for (IDeviceCommandLifecycleListener listener : lifecycleListeners) {
listener.onCommandAcknowledged(command, ack);
}
}
private void notifyCommandExpired(DeviceCommand command) {
for (IDeviceCommandLifecycleListener listener : lifecycleListeners) {
try {
listener.onCommandExpired(command);
} catch (RuntimeException e) {
log.error("[MQTT] 命令过期生命周期回调失败 设备编号={} 命令编号={}",
command.getDeviceNo(), command.getCommandId(), e);
}
}
}
private CommandLockResult withCommandLock(String commandId, long waitTimeMs, BooleanSupplier action) { private CommandLockResult withCommandLock(String commandId, long waitTimeMs, 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;

View File

@@ -0,0 +1,52 @@
package org.dromara.mqtt;
import lombok.RequiredArgsConstructor;
import org.dromara.app.domain.mqtt.DeviceUnbindResult;
import org.dromara.app.service.IDeviceUnbindResultPublisher;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.mqtt.config.properties.MqttProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class MqttDeviceUnbindResultPublisher implements IDeviceUnbindResultPublisher {
private final MqttProperties mqttProperties;
@Lazy
@Autowired
private MqttClientManager mqttClientManager;
@Override
public void publish(DeviceUnbindResult result) {
if (result == null || StringUtils.isBlank(result.deviceNo())) {
throw new ServiceException("解绑回执缺少设备编号");
}
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("commandId", result.commandId());
payload.put("bindingId", result.bindingId());
payload.put("status", result.status());
payload.put("message", result.message());
mqttClientManager.publish(buildTopic(result.deviceNo()), JsonUtils.toJsonString(payload));
}
private String buildTopic(String deviceNo) {
String device = deviceNo.trim().toLowerCase(Locale.ROOT);
String prefix = mqttProperties.getTopics().getPublishPrefix();
if (StringUtils.isBlank(prefix)) {
return "/" + device + "/subscriber/unbind/ack";
}
String normalizedPrefix = prefix.startsWith("/") ? prefix : "/" + prefix;
if (normalizedPrefix.endsWith("/")) {
normalizedPrefix = normalizedPrefix.substring(0, normalizedPrefix.length() - 1);
}
return normalizedPrefix + "/" + device + "/subscriber/unbind/ack";
}
}

View File

@@ -31,6 +31,7 @@ public class MqttProperties {
@Data @Data
public static class Topics { public static class Topics {
private List<String> subscribe = new ArrayList<>(); private List<String> subscribe = new ArrayList<>();
private String sharedGroup;
private String publishPrefix; private String publishPrefix;
} }
@@ -59,6 +60,7 @@ public class MqttProperties {
private long scanIntervalMs = 5000; private long scanIntervalMs = 5000;
private long pendingTtlSeconds = 86400; private long pendingTtlSeconds = 86400;
private long ackTtlSeconds = 86400; private long ackTtlSeconds = 86400;
private long startupCleanupWaitMs = 30000;
private String pendingKeyPrefix = "mqtt:command:pending:"; private String pendingKeyPrefix = "mqtt:command:pending:";
private String ackKeyPrefix = "mqtt:command:ack:"; private String ackKeyPrefix = "mqtt:command:ack:";
private String pendingSetKey = "mqtt:command:pending:ids"; private String pendingSetKey = "mqtt:command:pending:ids";

View File

@@ -13,6 +13,7 @@ import org.springframework.context.support.GenericApplicationContext;
import java.util.function.Supplier; import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.springframework.test.util.ReflectionTestUtils.invokeMethod; import static org.springframework.test.util.ReflectionTestUtils.invokeMethod;
@@ -63,4 +64,24 @@ class DeviceMqttCommandPublisherTest {
assertThat(command.getNextRetryAt() - command.getLastSentAt()).isEqualTo(10000); assertThat(command.getNextRetryAt() - command.getLastSentAt()).isEqualTo(10000);
} }
@Test
void sendWaitsForStartupCleanupBeforeSavingPendingCommand() {
MqttClientManager clientManager = mock(MqttClientManager.class);
MqttCommandAckService ackService = mock(MqttCommandAckService.class);
DeviceMqttCommandPublisher publisher = new DeviceMqttCommandPublisher(
clientManager,
ackService,
new MqttProperties()
);
DeviceCommand command = new DeviceCommand();
command.setDeviceNo("D01");
command.setCommandType("switch");
publisher.send(command);
var order = inOrder(ackService, clientManager);
order.verify(ackService).awaitStartupCleanup();
order.verify(ackService).savePending(command);
}
} }

View File

@@ -0,0 +1,38 @@
package org.dromara.mqtt;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@Tag("dev")
class MqttClientManagerTest {
@Test
void subscriptionFilterWrapsTopicInSharedGroup() {
assertThat(MqttClientManager.subscriptionFilter("/+/publish/register", "water-backend"))
.isEqualTo("$share/water-backend//+/publish/register");
}
@Test
void subscriptionFilterKeepsExistingSharedSubscription() {
String topic = "$share/existing//+/publish/register";
assertThat(MqttClientManager.subscriptionFilter(topic, "water-backend")).isEqualTo(topic);
}
@Test
void subscriptionFilterKeepsTopicWhenSharedGroupIsNotConfigured() {
assertThat(MqttClientManager.subscriptionFilter("/+/publish/register", " "))
.isEqualTo("/+/publish/register");
}
@Test
void subscriptionFilterRejectsInvalidSharedGroup() {
assertThatThrownBy(() -> MqttClientManager.subscriptionFilter("/+/publish/register", "water/backend"))
.isInstanceOf(ServiceException.class)
.hasMessageContaining("共享订阅组名称");
}
}

View File

@@ -213,6 +213,42 @@ class MqttCommandAckServiceTest {
} }
} }
@Test
void clearPendingCommandsByDeviceKeepsOtherDeviceCommands() throws Exception {
MqttProperties properties = new MqttProperties();
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
RedissonClient redissonClient = mock(RedissonClient.class);
RSet<String> pendingIds = mock(RSet.class);
RLock commandLock = mock(RLock.class);
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
when(pendingIds.readAll()).thenReturn(Set.of("cmd-1", "cmd-2"));
when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
when(commandLock.tryLock(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS))).thenReturn(true);
when(commandLock.isHeldByCurrentThread()).thenReturn(true);
DeviceCommand first = new DeviceCommand();
first.setCommandId("cmd-1");
first.setDeviceNo("D01");
DeviceCommand second = new DeviceCommand();
second.setCommandId("cmd-2");
second.setDeviceNo("D02");
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(first);
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-2")).thenReturn(second);
service.clearPendingCommands(List.of("D01"));
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-2"), never());
verify(pendingIds).remove("cmd-1");
verify(pendingIds, never()).remove("cmd-2");
verify(commandLock).unlock();
}
}
@Test @Test
void handleAckWaitsBrieflyForCommandLock() throws Exception { void handleAckWaitsBrieflyForCommandLock() throws Exception {
MqttProperties properties = new MqttProperties(); MqttProperties properties = new MqttProperties();

View File

@@ -0,0 +1,22 @@
package org.dromara.app.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.nio.file.Path;
/**
* OTA 固件本地存储配置。
*/
@Data
@Component
@ConfigurationProperties(prefix = "app.firmware-storage")
public class FirmwareStorageProperties {
/** 固件文件保存目录。 */
private Path directory = Path.of("./data/firmware");
/** 对设备公开的服务端基础地址;为空时根据上传请求生成。 */
private String downloadBaseUrl;
}

View File

@@ -9,6 +9,7 @@ import cn.hutool.crypto.digest.BCrypt;
import cn.hutool.json.JSONArray; import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -89,10 +90,9 @@ public class AppController extends BaseController {
*/ */
@GetMapping("/deviceList") @GetMapping("/deviceList")
public TableDataInfo<AppDeviceVo> list(AppDeviceBo bo, PageQuery pageQuery) { public TableDataInfo<AppDeviceVo> list(AppDeviceBo bo, PageQuery pageQuery) {
bo.setUserId(LoginHelper.getUserId());
pageQuery.setOrderByColumn("createTime"); pageQuery.setOrderByColumn("createTime");
pageQuery.setIsAsc("desc"); pageQuery.setIsAsc("desc");
return appDeviceService.queryPageList(bo, pageQuery); return appDeviceService.queryUserDevicePage(LoginHelper.getUserId(), bo, pageQuery);
} }
@@ -130,10 +130,16 @@ public class AppController extends BaseController {
@GetMapping("/device/{deviceNo}") @GetMapping("/device/{deviceNo}")
public R<AppDeviceVo> getDeviceInfo(@NotNull(message = "主键不能为空") public R<AppDeviceVo> getDeviceInfo(@NotNull(message = "主键不能为空")
@PathVariable String deviceNo) { @PathVariable String deviceNo) {
return R.ok(getOwnedDevice(deviceNo)); return R.ok(getActiveDevice(deviceNo));
} }
@PostMapping("/factoryReset/{deviceNo}")
public R<String> factoryReset(@NotBlank(message = "设备编号不能为空")
@PathVariable String deviceNo) {
return R.ok(deviceCommandService.sendFactoryResetCommand(deviceNo));
}
/** /**
* 修改设备信息 * 修改设备信息
* *
@@ -141,9 +147,9 @@ public class AppController extends BaseController {
@RepeatSubmit() @RepeatSubmit()
@PutMapping("/updateDeviceInfo") @PutMapping("/updateDeviceInfo")
public R<Void> updateDeviceInfo(@Validated @RequestBody AppDeviceBo bo) { public R<Void> updateDeviceInfo(@Validated @RequestBody AppDeviceBo bo) {
assertDeviceOwned(bo.getDeviceNo()); assertActiveDevice(bo.getDeviceNo());
bo.setUserId(LoginHelper.getUserId()); bo.setUserId(LoginHelper.getUserId());
return toAjax(appDeviceService.updateByBo(bo)); return toAjax(appDeviceService.updateByBo(bo, LoginHelper.getUserId()));
} }
/** /**
@@ -154,11 +160,22 @@ public class AppController extends BaseController {
public R<Void> removeDevice(@NotEmpty(message = "主键不能为空") public R<Void> removeDevice(@NotEmpty(message = "主键不能为空")
@PathVariable String[] deviceNos) { @PathVariable String[] deviceNos) {
for (String deviceNo : deviceNos) { for (String deviceNo : deviceNos) {
assertDeviceOwned(deviceNo); assertActiveDevice(deviceNo);
} }
return toAjax(appDeviceService.unbindDevices(List.of(deviceNos), LoginHelper.getUserId())); return toAjax(appDeviceService.unbindDevices(List.of(deviceNos), LoginHelper.getUserId()));
} }
@RepeatSubmit()
@PostMapping("/retryBindDevice/{deviceNo}")
public R<AppDeviceVo> retryBindDevice(@PathVariable String deviceNo) {
return R.ok(appDeviceService.retryBinding(deviceNo, LoginHelper.getUserId()));
}
@DeleteMapping("/removeDeviceEntry/{deviceNo}")
public R<Void> removeDeviceEntry(@PathVariable String deviceNo) {
return toAjax(appDeviceService.removeUserDeviceEntry(deviceNo, LoginHelper.getUserId()));
}
/** /**
* 手动开启关闭设备 * 手动开启关闭设备
@@ -179,6 +196,17 @@ public class AppController extends BaseController {
return toAjax(flag); return toAjax(flag);
} }
/**
* 查询设备电量(下发查询命令,设备在 ACK 中回复电量)
*/
@RepeatSubmit()
@PostMapping("/queryPower/{deviceNo}")
public R<Void> queryPower(@PathVariable String deviceNo) {
assertActiveDevice(deviceNo);
deviceCommandService.sendQueryPowerCommand(deviceNo, LoginHelper.getUserId());
return R.ok();
}
/** /**
* 排程关联设备查询 * 排程关联设备查询
* @param * @param
@@ -188,9 +216,7 @@ public class AppController extends BaseController {
public R<Map<String, Object>> scheduleDeviceList(@PathVariable Long scheduleId) { public R<Map<String, Object>> scheduleDeviceList(@PathVariable Long scheduleId) {
Map<String, Object> retMap = new HashMap<>(); Map<String, Object> retMap = new HashMap<>();
//所有设备列表 //所有设备列表
AppDeviceBo appDeviceBo = new AppDeviceBo(); List<AppDeviceVo> appDeviceVos = appDeviceService.queryActiveDevices(LoginHelper.getUserId());
appDeviceBo.setUserId(LoginHelper.getUserId());
List<AppDeviceVo> appDeviceVos = appDeviceService.queryList(appDeviceBo);
List<String> deviceNos = appDeviceVos.stream() List<String> deviceNos = appDeviceVos.stream()
.map(AppDeviceVo::getDeviceNo) .map(AppDeviceVo::getDeviceNo)
.filter(StringUtils::isNotBlank) .filter(StringUtils::isNotBlank)
@@ -224,16 +250,16 @@ public class AppController extends BaseController {
if (StringUtils.isBlank(deviceNo)) { if (StringUtils.isBlank(deviceNo)) {
continue; continue;
} }
assertDeviceOwned(deviceNo); assertActiveDevice(deviceNo);
if (appSchedulingDeviceService.queryByScheduleIdAndDeviceNo(scheduleId, deviceNo) != null) { if (appSchedulingDeviceService.queryByScheduleIdAndDeviceNo(scheduleId, deviceNo) != null) {
continue; continue;
} }
AppSchedulingDeviceBo schedulingDeviceBo = new AppSchedulingDeviceBo(); AppSchedulingDeviceBo schedulingDeviceBo = new AppSchedulingDeviceBo();
schedulingDeviceBo.setDeviceNo(deviceNo); schedulingDeviceBo.setDeviceNo(deviceNo);
schedulingDeviceBo.setScheduleId(scheduleId); schedulingDeviceBo.setScheduleId(scheduleId);
if (appSchedulingDeviceService.insertByBo(schedulingDeviceBo)) { if (appSchedulingDeviceService.insertByBo(schedulingDeviceBo, LoginHelper.getUserId())) {
deviceCommandService.sendScheduleBindCommand(deviceNo, deviceCommandService.sendScheduleBindCommand(deviceNo,
buildScheduleBindPayload(deviceNo, scheduleDetails)); buildScheduleBindPayload(deviceNo, scheduleDetails), LoginHelper.getUserId());
} }
} }
return R.ok(); return R.ok();
@@ -317,12 +343,13 @@ public class AppController extends BaseController {
} }
Long scheduleIdValue = Long.valueOf(scheduleId); Long scheduleIdValue = Long.valueOf(scheduleId);
assertScheduleOwned(scheduleIdValue); assertScheduleOwned(scheduleIdValue);
assertDeviceOwned(deviceNo); assertActiveDevice(deviceNo);
Boolean deleted = appSchedulingDeviceService.deleteWithValidByScheduleIdAndDeviceNo(scheduleIdValue, deviceNo); Boolean deleted = appSchedulingDeviceService.deleteWithValidByScheduleIdAndDeviceNo(
scheduleIdValue, deviceNo, LoginHelper.getUserId());
if (deleted) { if (deleted) {
// 通知设备解绑排程 // 通知设备解绑排程
try { try {
deviceCommandService.sendScheduleUnbindCommand(deviceNo, scheduleIdValue); deviceCommandService.sendScheduleUnbindCommand(deviceNo, scheduleIdValue, LoginHelper.getUserId());
} catch (Exception mqttEx) { } catch (Exception mqttEx) {
log.warn("[排程通知] 删除排程设备绑定后下发解绑命令失败 排程ID={} 设备编号={} 原因={}", log.warn("[排程通知] 删除排程设备绑定后下发解绑命令失败 排程ID={} 设备编号={} 原因={}",
scheduleIdValue, deviceNo, mqttEx.getMessage(), mqttEx); scheduleIdValue, deviceNo, mqttEx.getMessage(), mqttEx);
@@ -381,7 +408,9 @@ public class AppController extends BaseController {
.filter(StringUtils::isNotBlank) .filter(StringUtils::isNotBlank)
.distinct() .distinct()
.toList(); .toList();
List<AppDeviceVo> queriedDevices = appDeviceService.queryByDeviceNos(deviceNos); Map<String, AppDeviceVo> activeDevices = appDeviceService.queryActiveDevices(LoginHelper.getUserId()).stream()
.collect(java.util.stream.Collectors.toMap(AppDeviceVo::getDeviceNo, device -> device));
List<AppDeviceVo> queriedDevices = deviceNos.stream().map(activeDevices::get).filter(Objects::nonNull).toList();
Map<String, AppDeviceVo> devicesByNo = new HashMap<>(); Map<String, AppDeviceVo> devicesByNo = new HashMap<>();
for (AppDeviceVo device : queriedDevices) { for (AppDeviceVo device : queriedDevices) {
if (device != null && StringUtils.isNotBlank(device.getDeviceNo())) { if (device != null && StringUtils.isNotBlank(device.getDeviceNo())) {
@@ -494,9 +523,7 @@ public class AppController extends BaseController {
pageQuery.setOrderByColumn("createTime"); pageQuery.setOrderByColumn("createTime");
pageQuery.setIsAsc("desc"); pageQuery.setIsAsc("desc");
TableDataInfo<AppWateringLogVo> appWateringLogVoTableDataInfo = appWateringLogService.queryPageList(bo, pageQuery); TableDataInfo<AppWateringLogVo> appWateringLogVoTableDataInfo = appWateringLogService.queryPageList(bo, pageQuery);
AppDeviceBo appDeviceBo = new AppDeviceBo(); List<AppDeviceVo> appDeviceVos = appDeviceService.queryActiveDevices(LoginHelper.getUserId());
appDeviceBo.setUserId(LoginHelper.getUserId());
List<AppDeviceVo> appDeviceVos = appDeviceService.queryList(appDeviceBo);
List<AppWateringLogVo> wateringLogs = new ArrayList<>(); List<AppWateringLogVo> wateringLogs = new ArrayList<>();
for (AppWateringLogVo vo : appWateringLogVoTableDataInfo.getRows()){ for (AppWateringLogVo vo : appWateringLogVoTableDataInfo.getRows()){
for(AppDeviceVo appDeviceVo:appDeviceVos){ for(AppDeviceVo appDeviceVo:appDeviceVos){
@@ -516,7 +543,7 @@ public class AppController extends BaseController {
*/ */
@GetMapping("/latestWaterLog") @GetMapping("/latestWaterLog")
public R<Map<String, Object>> latestWaterLog(@RequestParam String deviceNo) { public R<Map<String, Object>> latestWaterLog(@RequestParam String deviceNo) {
assertDeviceOwned(deviceNo); assertActiveDevice(deviceNo);
AppWateringLogBo bo = new AppWateringLogBo(); AppWateringLogBo bo = new AppWateringLogBo();
bo.setUserId(LoginHelper.getUserId()); bo.setUserId(LoginHelper.getUserId());
@@ -744,20 +771,20 @@ public class AppController extends BaseController {
.format(SECOND_TIME_FORMATTER); .format(SECOND_TIME_FORMATTER);
} }
private AppDeviceVo getOwnedDevice(String deviceNo) { private AppDeviceVo getActiveDevice(String deviceNo) {
if (StringUtils.isBlank(deviceNo)) { if (StringUtils.isBlank(deviceNo)) {
throw new ServiceException("app.device.no.not.blank"); throw new ServiceException("app.device.no.not.blank");
} }
AppDeviceVo device = appDeviceService.queryById(deviceNo);
Long userId = LoginHelper.getUserId(); Long userId = LoginHelper.getUserId();
if (device == null || !Objects.equals(device.getUserId(), userId)) { AppDeviceVo device = appDeviceService.queryActiveDevice(userId, deviceNo);
if (device == null) {
throw new ServiceException("app.device.not.exists.or.denied"); throw new ServiceException("app.device.not.exists.or.denied");
} }
return device; return device;
} }
private void assertDeviceOwned(String deviceNo) { private void assertActiveDevice(String deviceNo) {
getOwnedDevice(deviceNo); getActiveDevice(deviceNo);
} }
private AppScheduleVo getOwnedSchedule(Long scheduleId) { private AppScheduleVo getOwnedSchedule(Long scheduleId) {
@@ -782,7 +809,8 @@ public class AppController extends BaseController {
} }
AppWateringLogVo log = appWateringLogService.queryById(id); AppWateringLogVo log = appWateringLogService.queryById(id);
Long userId = LoginHelper.getUserId(); Long userId = LoginHelper.getUserId();
if (log == null || !Objects.equals(log.getUserId(), userId)) { if (log == null || !Objects.equals(log.getUserId(), userId)
|| appDeviceService.queryActiveDevice(userId, log.getDeviceNo()) == null) {
throw new ServiceException("app.watering.log.not.exists.or.denied"); throw new ServiceException("app.watering.log.not.exists.or.denied");
} }
} }
@@ -809,7 +837,7 @@ public class AppController extends BaseController {
for (String deviceNo : deviceNos) { for (String deviceNo : deviceNos) {
try { try {
deviceCommandService.sendScheduleBindCommand(deviceNo, deviceCommandService.sendScheduleBindCommand(deviceNo,
buildScheduleBindPayload(deviceNo, scheduleDetails)); buildScheduleBindPayload(deviceNo, scheduleDetails), LoginHelper.getUserId());
} catch (Exception e) { } catch (Exception e) {
log.warn("[排程通知] 下发排程更新失败 排程ID={} 设备编号={} 原因={}", log.warn("[排程通知] 下发排程更新失败 排程ID={} 设备编号={} 原因={}",
scheduleId, deviceNo, e.getMessage(), e); scheduleId, deviceNo, e.getMessage(), e);
@@ -828,10 +856,10 @@ public class AppController extends BaseController {
for (String deviceNo : deviceNos) { for (String deviceNo : deviceNos) {
try { try {
if ("0".equals(status)) { if ("0".equals(status)) {
deviceCommandService.sendScheduleCanceledCommand(deviceNo, scheduleId); deviceCommandService.sendScheduleCanceledCommand(deviceNo, scheduleId, LoginHelper.getUserId());
} else { } else {
deviceCommandService.sendScheduleBindCommand(deviceNo, deviceCommandService.sendScheduleBindCommand(deviceNo,
buildScheduleBindPayload(deviceNo, scheduleDetails)); buildScheduleBindPayload(deviceNo, scheduleDetails), LoginHelper.getUserId());
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("[排程通知] 下发排程状态变更失败 排程ID={} 状态={} 设备编号={} 原因={}", log.warn("[排程通知] 下发排程状态变更失败 排程ID={} 状态={} 设备编号={} 原因={}",
@@ -850,7 +878,7 @@ public class AppController extends BaseController {
private void notifyScheduleUnbind(Long scheduleId, List<String> deviceNos) { private void notifyScheduleUnbind(Long scheduleId, List<String> deviceNos) {
for (String deviceNo : deviceNos) { for (String deviceNo : deviceNos) {
try { try {
deviceCommandService.sendScheduleUnbindCommand(deviceNo, scheduleId); deviceCommandService.sendScheduleUnbindCommand(deviceNo, scheduleId, LoginHelper.getUserId());
} catch (Exception e) { } catch (Exception e) {
log.warn("[排程通知] 下发排程解绑失败 排程ID={} 设备编号={} 原因={}", log.warn("[排程通知] 下发排程解绑失败 排程ID={} 设备编号={} 原因={}",
scheduleId, deviceNo, e.getMessage(), e); scheduleId, deviceNo, e.getMessage(), e);

View File

@@ -2,18 +2,23 @@ package org.dromara.app.controller;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.vo.AppDeviceImportVo;
import org.dromara.app.domain.vo.AppDeviceVo; import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.domain.vo.DashboardStatsVo; import org.dromara.app.domain.vo.DashboardStatsVo;
import org.dromara.app.listener.AppDeviceImportListener;
import org.dromara.app.service.IAppDeviceService; import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
import org.dromara.common.core.utils.StringUtils; import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.file.FileUtils; import org.dromara.common.core.utils.file.FileUtils;
import org.dromara.common.core.validate.AddGroup; import org.dromara.common.core.validate.AddGroup;
import org.dromara.common.core.validate.EditGroup; import org.dromara.common.core.validate.EditGroup;
import org.dromara.common.excel.core.ExcelResult;
import org.dromara.common.excel.utils.ExcelUtil; import org.dromara.common.excel.utils.ExcelUtil;
import org.dromara.common.idempotent.annotation.RepeatSubmit; import org.dromara.common.idempotent.annotation.RepeatSubmit;
import org.dromara.common.log.annotation.Log; import org.dromara.common.log.annotation.Log;
@@ -21,10 +26,13 @@ import org.dromara.common.log.enums.BusinessType;
import org.dromara.common.mybatis.core.page.PageQuery; import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo; import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.web.core.BaseController; import org.dromara.common.web.core.BaseController;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
@@ -42,6 +50,7 @@ import java.util.List;
public class AppDeviceController extends BaseController { public class AppDeviceController extends BaseController {
private final IAppDeviceService appDeviceService; private final IAppDeviceService appDeviceService;
private final IDeviceCommandService deviceCommandService;
/** /**
* 查询设备信息 * 查询设备信息
@@ -74,6 +83,27 @@ public class AppDeviceController extends BaseController {
ExcelUtil.exportExcel(list, "设备信息 ", AppDeviceVo.class, response); ExcelUtil.exportExcel(list, "设备信息 ", AppDeviceVo.class, response);
} }
/**
* 导入设备数据
*
* @param file 导入文件
*/
@SaCheckPermission("app:device:import")
@Log(title = "设备信息", businessType = BusinessType.IMPORT)
@PostMapping(value = "/importData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<Void> importData(@RequestPart("file") MultipartFile file) throws Exception {
ExcelResult<AppDeviceImportVo> excelResult = ExcelUtil.importExcel(file.getInputStream(), AppDeviceImportVo.class, new AppDeviceImportListener());
return R.ok(excelResult.getAnalysis());
}
/**
* 获取设备导入模板
*/
@PostMapping("/importTemplate")
public void importTemplate(HttpServletResponse response) {
ExcelUtil.exportExcel(new ArrayList<>(), "设备数据", AppDeviceImportVo.class, response);
}
/** /**
* 获取设备信息 * 获取设备信息
详细信息 详细信息
@@ -146,6 +176,30 @@ public class AppDeviceController extends BaseController {
appDeviceService.downloadQrCodes(ids, response.getOutputStream()); appDeviceService.downloadQrCodes(ids, response.getOutputStream());
} }
/**
* 后台解绑设备:清理用户数据并保留设备主档,支持跨用户批量操作。
*/
@SaCheckPermission("app:device:unbind")
@Log(title = "设备解绑", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PostMapping("/unbind/{deviceNos}")
public R<Void> unbind(@NotEmpty(message = "设备编号不能为空")
@PathVariable String[] deviceNos) {
return toAjax(appDeviceService.unbindDevicesByAdmin(List.of(deviceNos)));
}
/**
* 后台向设备下发恢复出厂命令,不修改服务端设备关系数据。
*/
@SaCheckPermission("app:device:edit")
@Log(title = "设备恢复出厂", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PostMapping("/factory-reset/{deviceNo}")
public R<String> factoryReset(@NotBlank(message = "设备编号不能为空")
@PathVariable String deviceNo) {
return R.ok(deviceCommandService.sendFactoryResetCommand(deviceNo));
}
/** /**
* 删除设备信息 * 删除设备信息

View File

@@ -0,0 +1,141 @@
package org.dromara.app.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaIgnore;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import org.dromara.app.domain.bo.AppFirmwareBo;
import org.dromara.app.domain.vo.AppFirmwareVo;
import org.dromara.app.service.IAppFirmwareService;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.validate.EditGroup;
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.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.Arrays;
import java.util.List;
/**
* 设备固件版本
*
* @author water team
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/app/firmware")
public class AppFirmwareController extends BaseController {
private final IAppFirmwareService appFirmwareService;
/**
* 查询固件版本列表
*/
@SaCheckPermission("app:firmware:list")
@GetMapping("/list")
public TableDataInfo<AppFirmwareVo> list(AppFirmwareBo bo, PageQuery pageQuery) {
return appFirmwareService.queryPageList(bo, pageQuery);
}
/**
* 上传固件 bin 文件并保存固件版本
*/
@SaCheckPermission("app:firmware:add")
@Log(title = "固件版本", businessType = BusinessType.INSERT)
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<AppFirmwareVo> upload(@RequestPart("file") MultipartFile file, AppFirmwareBo bo) {
return R.ok(appFirmwareService.uploadFirmware(file, bo));
}
/**
* 供设备下载服务器本地保存的固件。
*/
@SaIgnore
@GetMapping(value = "/download/{storedName:.+}", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity<Resource> download(@PathVariable String storedName) throws Exception {
Resource resource = appFirmwareService.loadFirmware(storedName);
return ResponseEntity.ok()
.contentLength(resource.contentLength())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(storedName).build().toString())
.body(resource);
}
/**
* 获取固件版本详情
*/
@SaCheckPermission("app:firmware:query")
@GetMapping("/{id}")
public R<AppFirmwareVo> getInfo(@NotNull(message = "主键不能为空")
@PathVariable Long id) {
return R.ok(appFirmwareService.queryById(id));
}
/**
* 修改固件版本
*/
@SaCheckPermission("app:firmware:edit")
@Log(title = "固件版本", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PutMapping()
public R<Void> edit(@Validated(EditGroup.class) @RequestBody AppFirmwareBo bo) {
return toAjax(appFirmwareService.updateByBo(bo));
}
/**
* 删除固件版本
*/
@SaCheckPermission("app:firmware:remove")
@Log(title = "固件版本", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public R<Void> remove(@NotEmpty(message = "主键不能为空")
@PathVariable Long[] ids) {
return toAjax(appFirmwareService.deleteWithValidByIds(List.of(ids), true));
}
/**
* 选择设备升级
*/
@SaCheckPermission("app:firmware:upgrade")
@Log(title = "固件升级", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PostMapping("/upgrade")
public R<Integer> upgrade(@NotNull(message = "固件版本ID不能为空") @RequestParam Long firmwareId,
@NotEmpty(message = "设备编号不能为空") @RequestParam String deviceNos) {
List<String> nos = parseDeviceNos(deviceNos);
return R.ok(appFirmwareService.upgrade(firmwareId, nos));
}
/**
* 全部在线设备升级
*/
@SaCheckPermission("app:firmware:upgrade")
@Log(title = "固件升级", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PostMapping("/upgradeAll")
public R<Integer> upgradeAll(@NotNull(message = "固件版本ID不能为空") @RequestParam Long firmwareId) {
return R.ok(appFirmwareService.upgradeAll(firmwareId));
}
private List<String> parseDeviceNos(String deviceNos) {
return Arrays.stream(deviceNos.split(","))
.map(String::trim)
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
}
}

View File

@@ -23,6 +23,7 @@ public class AppDevice extends BaseEntity {
private Long userId; private Long userId;
private String deviceName; private String deviceName;
private String deviceInitName; private String deviceInitName;
private String deviceImg; private String deviceImg;
@@ -35,6 +36,8 @@ public class AppDevice extends BaseEntity {
private String powerLevel; private String powerLevel;
private String powerStatus;
private Date powerLevelUpdatatime; private Date powerLevelUpdatatime;
private String wifiName; private String wifiName;

View File

@@ -0,0 +1,34 @@
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;
import java.util.Date;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("app_device_binding")
public class AppDeviceBinding extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
@TableId("id")
private Long id;
private String deviceNo;
private Long userId;
private String bindingId;
private String bindingStatus;
private Date bindingStartedTime;
private Date lastBindCommandTime;
private String deviceNameSnapshot;
private Date unboundTime;
private String unbindSource;
private String unbindCommandId;
private Integer cleanupAttempts;
private String cleanupError;
}

View File

@@ -0,0 +1,29 @@
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;
import java.util.Date;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("app_device_unbind_command")
public class AppDeviceUnbindCommand extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
@TableId("id")
private Long id;
private String deviceNo;
private String commandId;
private String bindingId;
private String commandStatus;
private String message;
private Integer attemptCount;
private Date completedTime;
}

View File

@@ -0,0 +1,61 @@
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_firmware
*
* @author water team
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("app_firmware")
public class AppFirmware extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
@TableId(value = "id")
private Long id;
/**
* 固件版本号
*/
private String firmwareVersion;
/**
* 原始文件名
*/
private String fileName;
/**
* 固件下载地址
*/
private String fileUrl;
/**
* 文件大小(字节)
*/
private Long fileSize;
/**
* MD5校验值
*/
private String md5;
/**
* 更新说明
*/
private String releaseNotes;
/**
* 状态 0停用 1启用
*/
private String status;
}

View File

@@ -0,0 +1,17 @@
package org.dromara.app.domain;
import java.util.Set;
public final class DeviceBindingStatus {
public static final String BINDING = "BINDING";
public static final String ACTIVE = "ACTIVE";
public static final String BIND_FAILED = "BIND_FAILED";
public static final String UNBINDING = "UNBINDING";
public static final String UNBOUND = "UNBOUND";
public static final Set<String> EXCLUSIVE = Set.of(BINDING, ACTIVE, UNBINDING);
private DeviceBindingStatus() {
}
}

View File

@@ -0,0 +1,12 @@
package org.dromara.app.domain;
public final class DeviceUnbindCommandStatus {
public static final String PROCESSING = "PROCESSING";
public static final String APPLIED = "APPLIED";
public static final String STALE = "STALE";
public static final String FAILED = "FAILED";
private DeviceUnbindCommandStatus() {
}
}

View File

@@ -0,0 +1,11 @@
package org.dromara.app.domain;
public final class DeviceUnbindSource {
public static final String DEVICE = "DEVICE";
public static final String APP = "APP";
public static final String ADMIN = "ADMIN";
private DeviceUnbindSource() {
}
}

View File

@@ -22,6 +22,9 @@ public class AppDeviceBo extends BaseEntity {
private Long userId; private Long userId;
/** 用户设备关系状态。 */
private String bindingStatus;
/** /**
* 设备名称 * 设备名称
*/ */
@@ -52,6 +55,10 @@ public class AppDeviceBo extends BaseEntity {
* 电量 * 电量
*/ */
private String powerLevel; private String powerLevel;
/**
* 电量状态 0-正常 1-充电中
*/
private String powerStatus;
/** /**
* 电量更新时间 * 电量更新时间

View File

@@ -0,0 +1,65 @@
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.AppFirmware;
import org.dromara.common.core.validate.AddGroup;
import org.dromara.common.core.validate.EditGroup;
import org.dromara.common.mybatis.core.domain.BaseEntity;
/**
* 设备固件版本业务对象 app_firmware
*
* @author water team
*/
@Data
@EqualsAndHashCode(callSuper = true)
@AutoMapper(target = AppFirmware.class, reverseConvertGenerate = false)
public class AppFirmwareBo extends BaseEntity {
/**
* 主键
*/
@NotNull(message = "主键不能为空", groups = { EditGroup.class })
private Long id;
/**
* 固件版本号
*/
@NotBlank(message = "固件版本号不能为空", groups = { AddGroup.class, EditGroup.class })
private String firmwareVersion;
/**
* 原始文件名
*/
private String fileName;
/**
* 固件下载地址
*/
private String fileUrl;
/**
* 文件大小(字节)
*/
private Long fileSize;
/**
* MD5校验值
*/
private String md5;
/**
* 更新说明
*/
private String releaseNotes;
/**
* 状态 0停用 1启用
*/
@NotBlank(message = "状态不能为空", groups = { AddGroup.class, EditGroup.class })
private String status;
}

View File

@@ -13,6 +13,10 @@ public class DeviceCommandAck implements Serializable {
private String commandId; private String commandId;
private String deviceNo; private String deviceNo;
private String bindingId;
private String status; private String status;
private String message; private String message;
private String powerLevel;
private String charging;
private String ack;
} }

View File

@@ -0,0 +1,10 @@
package org.dromara.app.domain.mqtt;
import lombok.Data;
@Data
public class DeviceUnbindRequest {
private String commandId;
private String bindingId;
private String reason;
}

View File

@@ -0,0 +1,14 @@
package org.dromara.app.domain.mqtt;
public record DeviceUnbindResult(
String deviceNo,
String commandId,
String bindingId,
String status,
String message
) {
public static final String APPLIED = "applied";
public static final String ALREADY_APPLIED = "already_applied";
public static final String STALE_BINDING = "stale_binding";
public static final String FAILED = "failed";
}

View File

@@ -0,0 +1,45 @@
package org.dromara.app.domain.vo;
import cn.idev.excel.annotation.ExcelProperty;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 设备导入对象
*
* @author water team
*/
@Data
public class AppDeviceImportVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* MAC地址
*/
@NotBlank(message = "MAC地址不能为空")
@ExcelProperty(value = "MAC地址")
private String macAddress;
/**
* 设备名称
*/
@ExcelProperty(value = "设备名称")
private String deviceName;
/**
* 设备型号
*/
@ExcelProperty(value = "设备型号")
private String deviceEm;
/**
* 设备序列号
*/
@ExcelProperty(value = "设备序列号")
private String deviceSn;
}

View File

@@ -24,6 +24,12 @@ public class AppDeviceVo implements Serializable {
@ExcelProperty(value = "用户id") @ExcelProperty(value = "用户id")
private Long userId; private Long userId;
@ExcelProperty(value = "绑定状态")
private String bindingStatus;
@ExcelProperty(value = "解绑时间")
private Date unboundTime;
@ExcelProperty(value = "设备名称") @ExcelProperty(value = "设备名称")
private String deviceName; private String deviceName;
private String deviceInitName; private String deviceInitName;
@@ -37,11 +43,15 @@ public class AppDeviceVo implements Serializable {
@ExcelProperty(value = "状态") @ExcelProperty(value = "状态")
private String status; private String status;
@ExcelProperty(value = "工作状态")
private String workStatus; private String workStatus;
@ExcelProperty(value = "电量") @ExcelProperty(value = "电量")
private String powerLevel; private String powerLevel;
@ExcelProperty(value = "电量状态")
private String powerStatus;
@ExcelProperty(value = "电量更新时间") @ExcelProperty(value = "电量更新时间")
private Date powerLevelUpdatatime; private Date powerLevelUpdatatime;

View File

@@ -0,0 +1,51 @@
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.AppFirmware;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* 设备固件版本视图对象 app_firmware
*
* @author water team
*/
@Data
@ExcelIgnoreUnannotated
@AutoMapper(target = AppFirmware.class)
public class AppFirmwareVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long id;
@ExcelProperty(value = "固件版本号")
private String firmwareVersion;
@ExcelProperty(value = "文件名")
private String fileName;
@ExcelProperty(value = "下载地址")
private String fileUrl;
@ExcelProperty(value = "文件大小")
private Long fileSize;
@ExcelProperty(value = "MD5")
private String md5;
@ExcelProperty(value = "更新说明")
private String releaseNotes;
@ExcelProperty(value = "状态")
private String status;
@ExcelProperty(value = "创建时间")
private Date createTime;
}

View File

@@ -1,11 +1,15 @@
package org.dromara.app.handler; package org.dromara.app.handler;
import com.baomidou.lock.annotation.Lock4j;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.mqtt.MqttTopicHandler; import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceBindingService;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Date; import java.util.Date;
@@ -24,6 +28,9 @@ public class DeviceDataHandler implements MqttTopicHandler {
private final IAppDeviceService appDeviceService; private final IAppDeviceService appDeviceService;
private final DeviceIdentityResolver deviceIdentityResolver; private final DeviceIdentityResolver deviceIdentityResolver;
private final MqttDeviceStatusService deviceStatusService; private final MqttDeviceStatusService deviceStatusService;
@Lazy
@Autowired
private IAppDeviceBindingService deviceBindingService;
@Override @Override
public Pattern topicPattern() { public Pattern topicPattern() {
@@ -31,6 +38,7 @@ public class DeviceDataHandler implements MqttTopicHandler {
} }
@Override @Override
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
public void handle(String deviceIdentity, String payload) { public void handle(String deviceIdentity, String payload) {
String deviceNo = null; String deviceNo = null;
try { try {
@@ -41,6 +49,10 @@ public class DeviceDataHandler implements MqttTopicHandler {
return; return;
} }
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class); Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
if (!MqttBindingReportGuard.accepts(deviceBindingService, deviceNo, dto)) {
log.debug("[MQTT] 忽略非当前绑定设备电量上报 设备编号={}", deviceNo);
return;
}
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);
return; return;
@@ -49,6 +61,7 @@ public class DeviceDataHandler implements MqttTopicHandler {
AppDeviceBo appDeviceBo = new AppDeviceBo(); AppDeviceBo appDeviceBo = new AppDeviceBo();
appDeviceBo.setDeviceNo(deviceNo); appDeviceBo.setDeviceNo(deviceNo);
appDeviceBo.setPowerLevel(dto.get("powerLevel").toString()); appDeviceBo.setPowerLevel(dto.get("powerLevel").toString());
appDeviceBo.setPowerStatus(dto.get("charging") == null ? null : dto.get("charging").toString());
appDeviceBo.setPowerLevelUpdatatime(new Date()); appDeviceBo.setPowerLevelUpdatatime(new Date());
appDeviceService.updateByBo(appDeviceBo); appDeviceService.updateByBo(appDeviceBo);
deviceStatusService.markOnline(deviceNo); deviceStatusService.markOnline(deviceNo);
@@ -59,4 +72,5 @@ public class DeviceDataHandler implements MqttTopicHandler {
HandlerLogTime.now(), deviceIdentity, deviceNo, payload, e); HandlerLogTime.now(), deviceIdentity, deviceNo, payload, e);
} }
} }
} }

View File

@@ -34,6 +34,7 @@ DeviceRegisterHandler implements MqttTopicHandler {
private final ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider; private final ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider;
private final DeviceIdentityResolver deviceIdentityResolver; private final DeviceIdentityResolver deviceIdentityResolver;
private final MqttDeviceStatusService deviceStatusService; private final MqttDeviceStatusService deviceStatusService;
private final DeviceRegisterReplyGuard registerReplyGuard;
@Override @Override
public Pattern topicPattern() { public Pattern topicPattern() {
@@ -61,8 +62,8 @@ DeviceRegisterHandler implements MqttTopicHandler {
} }
AppDeviceBo device = buildRegisterDevice(deviceNo, normalizedDeviceMac, dto); AppDeviceBo device = buildRegisterDevice(deviceNo, normalizedDeviceMac, dto);
appDeviceService.registerByMqtt(device); appDeviceService.registerByMqtt(device);
deviceStatusService.markOnline(deviceNo); deviceStatusService.markRegisteredOnline(deviceNo);
sendDeviceNoToDevice(deviceNo, normalizedDeviceMac); sendDeviceNoOnce(deviceNo, normalizedDeviceMac);
log.info("[MQTT] 设备注册并上线 时间={} MAC={} 设备编号={} 消息体={}", log.info("[MQTT] 设备注册并上线 时间={} MAC={} 设备编号={} 消息体={}",
HandlerLogTime.now(), normalizedDeviceMac, deviceNo, payload); HandlerLogTime.now(), normalizedDeviceMac, deviceNo, payload);
} catch (Exception e) { } catch (Exception e) {
@@ -104,17 +105,28 @@ DeviceRegisterHandler implements MqttTopicHandler {
return text.isBlank() ? null : text.toLowerCase(Locale.ROOT); return text.isBlank() ? null : text.toLowerCase(Locale.ROOT);
} }
private void sendDeviceNoToDevice(String deviceNo, String deviceMac) { private void sendDeviceNoOnce(String deviceNo, String deviceMac) {
if (!registerReplyGuard.tryAcquire(deviceNo)) {
log.info("[MQTT] 忽略重复设备编号下发 时间={} MAC={} 设备编号={}",
HandlerLogTime.now(), deviceMac, deviceNo);
return;
}
if (!sendDeviceNoToDevice(deviceNo, deviceMac)) {
registerReplyGuard.release(deviceNo);
}
}
private boolean sendDeviceNoToDevice(String deviceNo, String deviceMac) {
IDeviceCommandPublisher commandPublisher = commandPublisherProvider.getIfAvailable(); IDeviceCommandPublisher commandPublisher = commandPublisherProvider.getIfAvailable();
if (commandPublisher == null) { if (commandPublisher == null) {
log.warn("[MQTT] 设备编号下发失败,未找到命令发布器 时间={} MAC={} 设备编号={}", log.warn("[MQTT] 设备编号下发失败,未找到命令发布器 时间={} MAC={} 设备编号={}",
HandlerLogTime.now(), deviceMac, deviceNo); HandlerLogTime.now(), deviceMac, deviceNo);
return; return false;
} }
if (deviceMac == null) { if (deviceMac == null) {
log.warn("[MQTT] 设备编号下发失败,缺少设备 MAC 时间={} 设备编号={}", log.warn("[MQTT] 设备编号下发失败,缺少设备 MAC 时间={} 设备编号={}",
HandlerLogTime.now(), deviceNo); HandlerLogTime.now(), deviceNo);
return; return false;
} }
try { try {
@@ -130,9 +142,11 @@ DeviceRegisterHandler implements MqttTopicHandler {
String commandId = commandPublisher.send(command); String commandId = commandPublisher.send(command);
log.info("[MQTT] 设备编号已下发 时间={} MAC={} 设备编号={} 命令编号={}", log.info("[MQTT] 设备编号已下发 时间={} MAC={} 设备编号={} 命令编号={}",
HandlerLogTime.now(), deviceMac, deviceNo, commandId); HandlerLogTime.now(), deviceMac, deviceNo, commandId);
return true;
} catch (Exception e) { } catch (Exception e) {
log.error("[MQTT] 设备编号下发失败 时间={} MAC={} 设备编号={}", log.error("[MQTT] 设备编号下发失败 时间={} MAC={} 设备编号={}",
HandlerLogTime.now(), deviceMac, deviceNo, e); HandlerLogTime.now(), deviceMac, deviceNo, e);
return false;
} }
} }

View File

@@ -0,0 +1,41 @@
package org.dromara.app.handler;
import org.redisson.api.RBucket;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* Prevents repeated register messages from producing duplicate device-number replies.
*/
@Component
class DeviceRegisterReplyGuard {
private static final String KEY_PREFIX = "mqtt:register-device-no:sent:";
private final RedissonClient redissonClient;
private final Duration dedupWindow;
DeviceRegisterReplyGuard(
RedissonClient redissonClient,
@Value("${mqtt.register-device-no-dedup-seconds:30}") long dedupSeconds
) {
this.redissonClient = redissonClient;
this.dedupWindow = Duration.ofSeconds(Math.max(1L, dedupSeconds));
}
boolean tryAcquire(String deviceNo) {
RBucket<String> bucket = redissonClient.getBucket(key(deviceNo));
return bucket.setIfAbsent("sent", dedupWindow);
}
void release(String deviceNo) {
redissonClient.getBucket(key(deviceNo)).delete();
}
private String key(String deviceNo) {
return KEY_PREFIX + deviceNo;
}
}

View File

@@ -0,0 +1,62 @@
package org.dromara.app.handler;
import com.baomidou.lock.annotation.Lock4j;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.mqtt.DeviceUnbindRequest;
import org.dromara.app.domain.mqtt.DeviceUnbindResult;
import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IDeviceUnbindResultPublisher;
import org.dromara.common.json.utils.JsonUtils;
import org.springframework.stereotype.Component;
import java.util.regex.Pattern;
@Slf4j
@Component
@RequiredArgsConstructor
public class DeviceUnbindHandler implements MqttTopicHandler {
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/unbind$");
private final DeviceIdentityResolver deviceIdentityResolver;
private final IAppDeviceBindingService bindingService;
private final IDeviceUnbindResultPublisher resultPublisher;
@Override
public Pattern topicPattern() {
return PATTERN;
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
public void handle(String deviceIdentity, String payload) {
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
DeviceUnbindRequest request = null;
try {
request = JsonUtils.parseObject(payload, DeviceUnbindRequest.class);
if (deviceNo == null) {
log.warn("[MQTT] 设备解绑未找到设备 设备标识={} 消息体={}", deviceIdentity, payload);
return;
}
DeviceUnbindResult result = bindingService.unbindByDevice(deviceNo, request);
resultPublisher.publish(result);
log.info("[MQTT] 设备解绑已处理 设备编号={} 命令编号={} 结果={}",
deviceNo, result.commandId(), result.status());
} catch (RuntimeException e) {
log.error("[MQTT] 设备解绑处理失败 设备标识={} 设备编号={} 消息体={}",
deviceIdentity, deviceNo, payload, e);
if (deviceNo != null && request != null && request.getCommandId() != null) {
try {
resultPublisher.publish(new DeviceUnbindResult(
deviceNo, request.getCommandId(), request.getBindingId(),
DeviceUnbindResult.FAILED, e.getMessage()));
} catch (RuntimeException publishFailure) {
log.warn("[MQTT] 设备解绑失败回执发送失败 设备编号={} 命令编号={}",
deviceNo, request.getCommandId(), publishFailure);
}
}
}
}
}

View File

@@ -1,9 +1,13 @@
package org.dromara.app.handler; package org.dromara.app.handler;
import com.baomidou.lock.annotation.Lock4j;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.app.mqtt.MqttTopicHandler; import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.common.json.utils.JsonUtils; import org.dromara.common.json.utils.JsonUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Map; import java.util.Map;
@@ -19,6 +23,9 @@ public class ErromesHandler implements MqttTopicHandler {
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/error$"); private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/error$");
private final DeviceIdentityResolver deviceIdentityResolver; private final DeviceIdentityResolver deviceIdentityResolver;
@Lazy
@Autowired
private IAppDeviceBindingService deviceBindingService;
@Override @Override
public Pattern topicPattern() { public Pattern topicPattern() {
@@ -26,6 +33,7 @@ public class ErromesHandler implements MqttTopicHandler {
} }
@Override @Override
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
public void handle(String deviceIdentity, String payload) { public void handle(String deviceIdentity, String payload) {
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity); String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
try { try {
@@ -35,10 +43,15 @@ public class ErromesHandler implements MqttTopicHandler {
return; return;
} }
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class); Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
if (!MqttBindingReportGuard.accepts(deviceBindingService, deviceNo, dto)) {
log.debug("[MQTT] 忽略非当前绑定设备异常上报 设备编号={}", deviceNo);
return;
}
log.info("[MQTT] 设备异常告警 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, dto); log.info("[MQTT] 设备异常告警 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, dto);
} catch (Exception e) { } catch (Exception e) {
log.error("[MQTT] 设备异常处理失败 时间={} 设备标识={} 设备编号={}", log.error("[MQTT] 设备异常处理失败 时间={} 设备标识={} 设备编号={}",
HandlerLogTime.now(), deviceIdentity, deviceNo, e); HandlerLogTime.now(), deviceIdentity, deviceNo, e);
} }
} }
} }

View File

@@ -1,18 +1,21 @@
package org.dromara.app.handler; package org.dromara.app.handler;
import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppSchedulingDevice; import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo; import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper; import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mqtt.MqttTopicHandler; import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceBindingService;
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.common.core.utils.StringUtils; import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils; import org.dromara.common.json.utils.JsonUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.text.ParseException; import java.text.ParseException;
@@ -36,6 +39,9 @@ public class KeyFinishHandler implements MqttTopicHandler {
private final IAppDeviceService appDeviceService; private final IAppDeviceService appDeviceService;
private final AppSchedulingDeviceMapper schedulingDeviceMapper; private final AppSchedulingDeviceMapper schedulingDeviceMapper;
private final DeviceIdentityResolver deviceIdentityResolver; private final DeviceIdentityResolver deviceIdentityResolver;
@Lazy
@Autowired
private IAppDeviceBindingService deviceBindingService;
@Override @Override
public Pattern topicPattern() { public Pattern topicPattern() {
@@ -43,6 +49,7 @@ public class KeyFinishHandler implements MqttTopicHandler {
} }
@Override @Override
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
public void handle(String deviceIdentity, String payload) { public void handle(String deviceIdentity, String payload) {
String deviceNo = null; String deviceNo = null;
try { try {
@@ -53,6 +60,10 @@ public class KeyFinishHandler implements MqttTopicHandler {
return; return;
} }
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class); Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
if (!MqttBindingReportGuard.accepts(deviceBindingService, deviceNo, dto)) {
log.debug("[MQTT] 忽略非当前绑定设备按键完成上报 设备编号={}", deviceNo);
return;
}
AppWateringLogBo logBo = buildWateringLog(deviceNo, dto); AppWateringLogBo logBo = buildWateringLog(deviceNo, dto);
appWateringLogService.confirmScheduleLog(logBo); appWateringLogService.confirmScheduleLog(logBo);
updateDeviceWorkStatusIdle(deviceNo); updateDeviceWorkStatusIdle(deviceNo);
@@ -106,10 +117,10 @@ public class KeyFinishHandler implements MqttTopicHandler {
} }
private Long queryUserId(String deviceNo) { private Long queryUserId(String deviceNo) {
AppDeviceVo device = appDeviceService.queryById(deviceNo); return deviceBindingService.queryActiveUserId(deviceNo);
return device == null ? null : device.getUserId();
} }
private Long queryScheduleId(String deviceNo) { private Long queryScheduleId(String deviceNo) {
List<AppSchedulingDevice> schedulingDevices = schedulingDeviceMapper.selectList( List<AppSchedulingDevice> schedulingDevices = schedulingDeviceMapper.selectList(
Wrappers.<AppSchedulingDevice>lambdaQuery() Wrappers.<AppSchedulingDevice>lambdaQuery()

View File

@@ -0,0 +1,27 @@
package org.dromara.app.handler;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.common.core.utils.StringUtils;
import java.util.Map;
/**
* 校验 MQTT 上报是否仍属于设备当前的 ACTIVE 绑定关系。
*/
final class MqttBindingReportGuard {
private MqttBindingReportGuard() {
}
static boolean accepts(IAppDeviceBindingService bindingService,
String deviceNo,
Map<String, Object> payload) {
if (!bindingService.isActive(deviceNo)) {
return false;
}
String bindingId = payload == null || payload.get("bindingId") == null
? null : String.valueOf(payload.get("bindingId"));
return StringUtils.isNotBlank(bindingId)
&& bindingService.isCurrentBinding(deviceNo, bindingId);
}
}

View File

@@ -1,15 +1,19 @@
package org.dromara.app.handler; package org.dromara.app.handler;
import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; 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.mapper.AppDeviceMapper; import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IAppWateringLogService; import org.dromara.app.service.IAppWateringLogService;
import org.dromara.common.core.utils.StringUtils; import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.redis.utils.RedisUtils; import org.dromara.common.redis.utils.RedisUtils;
import org.redisson.api.RLock; import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Duration; import java.time.Duration;
@@ -28,6 +32,9 @@ public class MqttDeviceStatusService {
private final AppDeviceMapper appDeviceMapper; private final AppDeviceMapper appDeviceMapper;
private final IAppWateringLogService wateringLogService; private final IAppWateringLogService wateringLogService;
@Lazy
@Autowired
private IAppDeviceBindingService deviceBindingService;
@Value("${mqtt.command-ack.device-status-cache-prefix}") @Value("${mqtt.command-ack.device-status-cache-prefix}")
private String deviceStatusCachePrefix; private String deviceStatusCachePrefix;
@@ -35,10 +42,23 @@ public class MqttDeviceStatusService {
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:600}") @Value("${mqtt.command-ack.device-status-cache-ttl-seconds:600}")
private long deviceStatusCacheTtlSeconds; private long deviceStatusCacheTtlSeconds;
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public void markOnline(String deviceNo) { public void markOnline(String deviceNo) {
if (StringUtils.isBlank(deviceNo) || !isActive(deviceNo)) {
return;
}
markOnlineInternal(deviceNo);
}
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public void markRegisteredOnline(String deviceNo) {
if (StringUtils.isBlank(deviceNo)) { if (StringUtils.isBlank(deviceNo)) {
return; return;
} }
markOnlineInternal(deviceNo);
}
private void markOnlineInternal(String deviceNo) {
withDeviceStatusLock(deviceNo, () -> { withDeviceStatusLock(deviceNo, () -> {
Map<String, Object> statusCache = new HashMap<>(); Map<String, Object> statusCache = new HashMap<>();
statusCache.put("deviceNo", deviceNo); statusCache.put("deviceNo", deviceNo);
@@ -58,6 +78,7 @@ public class MqttDeviceStatusService {
}); });
} }
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public void markOffline(String deviceNo, String reason) { public void markOffline(String deviceNo, String reason) {
if (StringUtils.isBlank(deviceNo)) { if (StringUtils.isBlank(deviceNo)) {
return; return;
@@ -65,6 +86,7 @@ public class MqttDeviceStatusService {
markOfflineWithLock(deviceNo, reason, false); markOfflineWithLock(deviceNo, reason, false);
} }
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public boolean markOfflineIfCacheMissing(String deviceNo, String reason) { public boolean markOfflineIfCacheMissing(String deviceNo, String reason) {
if (StringUtils.isBlank(deviceNo)) { if (StringUtils.isBlank(deviceNo)) {
return false; return false;
@@ -73,6 +95,9 @@ public class MqttDeviceStatusService {
} }
private boolean markOfflineWithLock(String deviceNo, String reason, boolean skipWhenCacheExists) { private boolean markOfflineWithLock(String deviceNo, String reason, boolean skipWhenCacheExists) {
if (!isActive(deviceNo)) {
return false;
}
return withDeviceStatusLock(deviceNo, () -> { return withDeviceStatusLock(deviceNo, () -> {
if (skipWhenCacheExists && RedisUtils.hasKey(deviceStatusCachePrefix + deviceNo)) { if (skipWhenCacheExists && RedisUtils.hasKey(deviceStatusCachePrefix + deviceNo)) {
return false; return false;
@@ -95,6 +120,10 @@ public class MqttDeviceStatusService {
}); });
} }
private boolean isActive(String deviceNo) {
return deviceBindingService.isActive(deviceNo);
}
private boolean withDeviceStatusLock(String deviceNo, java.util.function.BooleanSupplier action) { private boolean withDeviceStatusLock(String deviceNo, java.util.function.BooleanSupplier action) {
RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo); RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo);
boolean locked = false; boolean locked = false;

View File

@@ -1,18 +1,21 @@
package org.dromara.app.handler; package org.dromara.app.handler;
import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppSchedulingDevice; import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo; import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper; import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mqtt.MqttTopicHandler; import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceBindingService;
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.common.core.utils.StringUtils; import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils; import org.dromara.common.json.utils.JsonUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.text.ParseException; import java.text.ParseException;
@@ -36,6 +39,9 @@ public class ScheduleFinishHandler implements MqttTopicHandler {
private final IAppDeviceService appDeviceService; private final IAppDeviceService appDeviceService;
private final AppSchedulingDeviceMapper schedulingDeviceMapper; private final AppSchedulingDeviceMapper schedulingDeviceMapper;
private final DeviceIdentityResolver deviceIdentityResolver; private final DeviceIdentityResolver deviceIdentityResolver;
@Lazy
@Autowired
private IAppDeviceBindingService deviceBindingService;
@Override @Override
public Pattern topicPattern() { public Pattern topicPattern() {
@@ -43,6 +49,7 @@ public class ScheduleFinishHandler implements MqttTopicHandler {
} }
@Override @Override
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
public void handle(String deviceIdentity, String payload) { public void handle(String deviceIdentity, String payload) {
String deviceNo = null; String deviceNo = null;
try { try {
@@ -53,6 +60,10 @@ public class ScheduleFinishHandler implements MqttTopicHandler {
return; return;
} }
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class); Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
if (!MqttBindingReportGuard.accepts(deviceBindingService, deviceNo, dto)) {
log.debug("[MQTT] 忽略非当前绑定设备排程完成上报 设备编号={}", deviceNo);
return;
}
AppWateringLogBo logBo = buildWateringLog(deviceNo, dto); AppWateringLogBo logBo = buildWateringLog(deviceNo, dto);
appWateringLogService.confirmScheduleLog(logBo); appWateringLogService.confirmScheduleLog(logBo);
updateDeviceWorkStatusIdle(deviceNo); updateDeviceWorkStatusIdle(deviceNo);
@@ -106,10 +117,10 @@ public class ScheduleFinishHandler implements MqttTopicHandler {
} }
private Long queryUserId(String deviceNo) { private Long queryUserId(String deviceNo) {
AppDeviceVo device = appDeviceService.queryById(deviceNo); return deviceBindingService.queryActiveUserId(deviceNo);
return device == null ? null : device.getUserId();
} }
private Long queryScheduleId(String deviceNo) { private Long queryScheduleId(String deviceNo) {
List<AppSchedulingDevice> schedulingDevices = schedulingDeviceMapper.selectList( List<AppSchedulingDevice> schedulingDevices = schedulingDeviceMapper.selectList(
Wrappers.<AppSchedulingDevice>lambdaQuery() Wrappers.<AppSchedulingDevice>lambdaQuery()

View File

@@ -1,19 +1,22 @@
package org.dromara.app.handler; package org.dromara.app.handler;
import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppSchedulingDevice; import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo; 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.domain.vo.AppWateringLogVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper; import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mqtt.MqttTopicHandler; import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceBindingService;
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.common.core.utils.StringUtils; import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils; import org.dromara.common.json.utils.JsonUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.text.ParseException; import java.text.ParseException;
@@ -35,6 +38,9 @@ public class StartWaterHandler implements MqttTopicHandler {
private final IAppDeviceService appDeviceService; private final IAppDeviceService appDeviceService;
private final AppSchedulingDeviceMapper schedulingDeviceMapper; private final AppSchedulingDeviceMapper schedulingDeviceMapper;
private final DeviceIdentityResolver deviceIdentityResolver; private final DeviceIdentityResolver deviceIdentityResolver;
@Lazy
@Autowired
private IAppDeviceBindingService deviceBindingService;
@Override @Override
public Pattern topicPattern() { public Pattern topicPattern() {
@@ -42,6 +48,7 @@ public class StartWaterHandler implements MqttTopicHandler {
} }
@Override @Override
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
public void handle(String deviceIdentity, String payload) { public void handle(String deviceIdentity, String payload) {
String deviceNo = null; String deviceNo = null;
try { try {
@@ -52,6 +59,10 @@ public class StartWaterHandler implements MqttTopicHandler {
return; return;
} }
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class); Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
if (!MqttBindingReportGuard.accepts(deviceBindingService, deviceNo, dto)) {
log.debug("[MQTT] 忽略非当前绑定设备开始浇水上报 设备编号={}", deviceNo);
return;
}
AppWateringLogBo logBo = buildWateringLog(deviceNo, dto); AppWateringLogBo logBo = buildWateringLog(deviceNo, dto);
if (hasDuplicateRunningLog(logBo)) { if (hasDuplicateRunningLog(logBo)) {
updateDeviceWorkStatusWorking(deviceNo); updateDeviceWorkStatusWorking(deviceNo);
@@ -177,10 +188,10 @@ public class StartWaterHandler implements MqttTopicHandler {
} }
private Long queryUserId(String deviceNo) { private Long queryUserId(String deviceNo) {
AppDeviceVo device = appDeviceService.queryById(deviceNo); return deviceBindingService.queryActiveUserId(deviceNo);
return device == null ? null : device.getUserId();
} }
private Long queryScheduleId(String deviceNo) { private Long queryScheduleId(String deviceNo) {
List<AppSchedulingDevice> schedulingDevices = schedulingDeviceMapper.selectList( List<AppSchedulingDevice> schedulingDevices = schedulingDeviceMapper.selectList(
Wrappers.<AppSchedulingDevice>lambdaQuery() Wrappers.<AppSchedulingDevice>lambdaQuery()

View File

@@ -0,0 +1,81 @@
package org.dromara.app.listener;
import cn.hutool.core.bean.BeanUtil;
import cn.idev.excel.context.AnalysisContext;
import cn.idev.excel.event.AnalysisEventListener;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.vo.AppDeviceImportVo;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.excel.core.ExcelListener;
import org.dromara.common.excel.core.ExcelResult;
import java.util.List;
/**
* 设备导入监听,逐条新增设备并收集成功/失败统计。
*
* @author water team
*/
@Slf4j
public class AppDeviceImportListener extends AnalysisEventListener<AppDeviceImportVo> implements ExcelListener<AppDeviceImportVo> {
private final IAppDeviceService deviceService;
private final StringBuilder successMsg = new StringBuilder();
private final StringBuilder failureMsg = new StringBuilder();
private int successNum = 0;
private int failureNum = 0;
public AppDeviceImportListener() {
this.deviceService = SpringUtils.getBean(IAppDeviceService.class);
}
@Override
public void invoke(AppDeviceImportVo importVo, AnalysisContext context) {
try {
AppDeviceBo bo = BeanUtil.toBean(importVo, AppDeviceBo.class);
bo.setStatus("0");
bo.setWorkStatus("2");
deviceService.insertByBo(bo);
successNum++;
successMsg.append("<br/>").append(successNum).append("、设备 ").append(importVo.getMacAddress()).append(" 导入成功");
} catch (Exception e) {
failureNum++;
String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
failureMsg.append("<br/>").append(failureNum).append("、设备 ").append(importVo.getMacAddress()).append(" 导入失败:").append(msg);
log.warn("[设备导入] 设备导入失败 MAC={} 原因={}", importVo.getMacAddress(), e.getMessage());
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 无需额外处理
}
@Override
public ExcelResult<AppDeviceImportVo> getExcelResult() {
return new ExcelResult<>() {
@Override
public List<AppDeviceImportVo> getList() {
return List.of();
}
@Override
public List<String> getErrorList() {
return List.of();
}
@Override
public String getAnalysis() {
if (failureNum > 0) {
failureMsg.insert(0, "导入失败!共 " + failureNum + " 条数据失败,错误如下:");
throw new ServiceException(failureMsg.toString());
}
successMsg.insert(0, "恭喜您,数据已全部导入成功!共 " + successNum + " 条,数据如下:");
return successMsg.toString();
}
};
}
}

View File

@@ -0,0 +1,120 @@
package org.dromara.app.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.AppDeviceBinding;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
import java.util.List;
public interface AppDeviceBindingMapper extends BaseMapperPlus<AppDeviceBinding, AppDeviceBinding> {
@Select("""
select * from app_device_binding
where device_no = #{deviceNo}
and binding_status in ('BINDING', 'ACTIVE', 'UNBINDING')
limit 1
""")
AppDeviceBinding selectExclusiveByDeviceNo(@Param("deviceNo") String deviceNo);
@Select("""
select * from app_device_binding
where device_no = #{deviceNo} and user_id = #{userId}
limit 1
""")
AppDeviceBinding selectByUserAndDevice(@Param("userId") Long userId,
@Param("deviceNo") String deviceNo);
@Select("""
select * from app_device_binding
where device_no = #{deviceNo} and binding_id = #{bindingId}
limit 1
""")
AppDeviceBinding selectByDeviceAndBindingId(@Param("deviceNo") String deviceNo,
@Param("bindingId") String bindingId);
@Select("""
<script>
select
d.device_no,
b.user_id,
case when b.binding_status = 'ACTIVE' then d.device_name else b.device_name_snapshot end as device_name,
case when b.binding_status = 'ACTIVE' then d.device_init_name else null end as device_init_name,
case when b.binding_status = 'ACTIVE' then d.device_img else null end as device_img,
case when b.binding_status = 'ACTIVE' then d.qrcode else null end as qrcode,
case when b.binding_status = 'ACTIVE' then d.status else null end as status,
case when b.binding_status = 'ACTIVE' then d.work_status else null end as work_status,
case when b.binding_status = 'ACTIVE' then d.power_level else null end as power_level,
case when b.binding_status = 'ACTIVE' then d.power_status else null end as power_status,
case when b.binding_status = 'ACTIVE' then d.power_level_updatatime else null end as power_level_updatatime,
case when b.binding_status = 'ACTIVE' then d.wifi_name else null end as wifi_name,
case when b.binding_status = 'ACTIVE' then d.wifi_password else null end as wifi_password,
case when b.binding_status = 'ACTIVE' then d.device_em else null end as device_em,
case when b.binding_status = 'ACTIVE' then d.device_sn else null end as device_sn,
case when b.binding_status = 'ACTIVE' then d.fw_ver else null end as fw_ver,
case when b.binding_status = 'ACTIVE' then d.mac_address else null end as mac_address,
case when b.binding_status = 'ACTIVE' then d.expiration_time else null end as expiration_time,
b.binding_status,
b.unbound_time
from app_device_binding b
inner join app_device d on d.device_no = b.device_no
where b.user_id = #{userId}
<if test="bo.deviceNo != null and bo.deviceNo != ''">
and b.device_no = #{bo.deviceNo}
</if>
<if test="bo.deviceName != null and bo.deviceName != ''">
and (case when b.binding_status = 'ACTIVE' then d.device_name else b.device_name_snapshot end)
like concat('%', #{bo.deviceName}, '%')
</if>
<if test="bo.bindingStatus != null and bo.bindingStatus != ''">
and b.binding_status = #{bo.bindingStatus}
</if>
order by b.update_time desc, b.id desc
</script>
""")
Page<AppDeviceVo> selectUserDevicePage(Page<AppDeviceVo> page,
@Param("userId") Long userId,
@Param("bo") AppDeviceBo bo);
@Select("""
select
d.device_no, b.user_id, d.device_name, d.device_init_name, d.device_img, d.qrcode,
d.status, d.work_status, d.power_level, d.power_status, d.power_level_updatatime,
d.wifi_name, d.wifi_password, d.device_em, d.device_sn, d.fw_ver, d.mac_address,
d.expiration_time, b.binding_status, b.unbound_time
from app_device_binding b
inner join app_device d on d.device_no = b.device_no
where b.user_id = #{userId} and b.binding_status = 'ACTIVE'
order by d.device_no asc
""")
List<AppDeviceVo> selectActiveDevicesByUser(@Param("userId") Long userId);
@Select("""
select
d.device_no, b.user_id, d.device_name, d.device_init_name, d.device_img, d.qrcode,
d.status, d.work_status, d.power_level, d.power_status, d.power_level_updatatime,
d.wifi_name, d.wifi_password, d.device_em, d.device_sn, d.fw_ver, d.mac_address,
d.expiration_time, b.binding_status, b.unbound_time
from app_device_binding b
inner join app_device d on d.device_no = b.device_no
where b.user_id = #{userId}
and b.device_no = #{deviceNo}
and b.binding_status = 'ACTIVE'
limit 1
""")
AppDeviceVo selectActiveDevice(@Param("userId") Long userId,
@Param("deviceNo") String deviceNo);
@Select("""
select d.*, b.user_id as user_id
from app_device_binding b
inner join app_device d on d.device_no = b.device_no
where b.binding_status = 'ACTIVE' and d.status = '0'
order by d.device_no asc
""")
List<AppDevice> selectOfflineActiveDevices();
}

View File

@@ -10,6 +10,7 @@ import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.domain.vo.DashboardStatsVo; import org.dromara.app.domain.vo.DashboardStatsVo;
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
import java.util.Collection;
import java.util.List; import java.util.List;
/** /**
@@ -25,7 +26,7 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
<script> <script>
select select
d.device_no, d.device_no,
d.user_id, b.user_id,
d.device_name, d.device_name,
d.device_init_name, d.device_init_name,
d.device_img, d.device_img,
@@ -33,6 +34,7 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
d.status, d.status,
d.work_status, d.work_status,
d.power_level, d.power_level,
d.power_status,
d.power_level_updatatime, d.power_level_updatatime,
d.wifi_name, d.wifi_name,
d.wifi_password, d.wifi_password,
@@ -41,15 +43,23 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
d.fw_ver, d.fw_ver,
d.mac_address, d.mac_address,
d.expiration_time, d.expiration_time,
u.nick_name u.nick_name,
b.binding_status,
b.unbound_time
from app_device d from app_device d
left join sys_user u on u.user_id = d.user_id left join app_device_binding b
on b.device_no = d.device_no
and b.binding_status in ('BINDING', 'ACTIVE', 'UNBINDING')
left join sys_user u on u.user_id = b.user_id
<where> <where>
<if test="bo.deviceNo != null and bo.deviceNo != ''"> <if test="bo.deviceNo != null and bo.deviceNo != ''">
and d.device_no = #{bo.deviceNo} and d.device_no = #{bo.deviceNo}
</if> </if>
<if test="bo.userId != null"> <if test="bo.userId != null">
and d.user_id = #{bo.userId} and b.user_id = #{bo.userId}
</if>
<if test="bo.bindingStatus != null and bo.bindingStatus != ''">
and b.binding_status = #{bo.bindingStatus}
</if> </if>
<if test="bo.deviceName != null and bo.deviceName != ''"> <if test="bo.deviceName != null and bo.deviceName != ''">
and d.device_name like concat('%', #{bo.deviceName}, '%') and d.device_name like concat('%', #{bo.deviceName}, '%')
@@ -103,7 +113,7 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
<script> <script>
select select
d.device_no, d.device_no,
d.user_id, b.user_id,
d.device_name, d.device_name,
d.device_init_name, d.device_init_name,
d.device_img, d.device_img,
@@ -111,6 +121,7 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
d.status, d.status,
d.work_status, d.work_status,
d.power_level, d.power_level,
d.power_status,
d.power_level_updatatime, d.power_level_updatatime,
d.wifi_name, d.wifi_name,
d.wifi_password, d.wifi_password,
@@ -119,15 +130,23 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
d.fw_ver, d.fw_ver,
d.mac_address, d.mac_address,
d.expiration_time, d.expiration_time,
u.nick_name u.nick_name,
b.binding_status,
b.unbound_time
from app_device d from app_device d
left join sys_user u on u.user_id = d.user_id left join app_device_binding b
on b.device_no = d.device_no
and b.binding_status in ('BINDING', 'ACTIVE', 'UNBINDING')
left join sys_user u on u.user_id = b.user_id
<where> <where>
<if test="bo.deviceNo != null and bo.deviceNo != ''"> <if test="bo.deviceNo != null and bo.deviceNo != ''">
and d.device_no = #{bo.deviceNo} and d.device_no = #{bo.deviceNo}
</if> </if>
<if test="bo.userId != null"> <if test="bo.userId != null">
and d.user_id = #{bo.userId} and b.user_id = #{bo.userId}
</if>
<if test="bo.bindingStatus != null and bo.bindingStatus != ''">
and b.binding_status = #{bo.bindingStatus}
</if> </if>
<if test="bo.deviceName != null and bo.deviceName != ''"> <if test="bo.deviceName != null and bo.deviceName != ''">
and d.device_name like concat('%', #{bo.deviceName}, '%') and d.device_name like concat('%', #{bo.deviceName}, '%')
@@ -177,44 +196,36 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
""") """)
List<AppDeviceVo> selectDeviceVoList(@Param("bo") AppDeviceBo bo); List<AppDeviceVo> selectDeviceVoList(@Param("bo") AppDeviceBo bo);
@Update(""" @Select("""
<script> <script>
update app_device select
<set> d.device_no, b.user_id, d.device_name, d.device_init_name, d.device_img, d.qrcode,
user_id = #{userId}, d.status, d.work_status, d.power_level, d.power_status, d.power_level_updatatime,
<if test="deviceName != null and deviceName != ''"> d.wifi_name, d.wifi_password, d.device_em, d.device_sn, d.fw_ver, d.mac_address,
device_name = #{deviceName}, d.expiration_time, u.nick_name, b.binding_status, b.unbound_time
</if> from app_device d
<if test="status != null and status != ''"> left join app_device_binding b
status = #{status}, on b.device_no = d.device_no
</if> and b.binding_status in ('BINDING', 'ACTIVE', 'UNBINDING')
<if test="workStatus != null and workStatus != ''"> left join sys_user u on u.user_id = b.user_id
work_status = #{workStatus}, where d.device_no in
</if> <foreach collection="deviceNos" item="deviceNo" open="(" separator="," close=")">
<if test="wifiName != null and wifiName != ''"> #{deviceNo}
wifi_name = #{wifiName}, </foreach>
</if> </script>
<if test="wifiPassword != null and wifiPassword != ''"> """)
wifi_password = #{wifiPassword}, List<AppDeviceVo> selectDeviceVosByDeviceNos(@Param("deviceNos") Collection<String> deviceNos);
</if>
</set>
where device_no = #{deviceNo}
and (user_id is null or user_id = #{userId})
</script>
""")
int bindIfAvailable(@Param("deviceNo") String deviceNo,
@Param("userId") Long userId,
@Param("deviceName") String deviceName,
@Param("status") String status,
@Param("workStatus") String workStatus,
@Param("wifiName") String wifiName,
@Param("wifiPassword") String wifiPassword);
@Update(""" @Update("""
update app_device update app_device
set work_status = #{workStatus} set work_status = #{workStatus}
where device_no = #{deviceNo} where device_no = #{deviceNo}
and user_id = #{userId} and exists (
select 1 from app_device_binding b
where b.device_no = app_device.device_no
and b.user_id = #{userId}
and b.binding_status = 'ACTIVE'
)
""") """)
int updateWorkStatusByUser(@Param("deviceNo") String deviceNo, int updateWorkStatusByUser(@Param("deviceNo") String deviceNo,
@Param("userId") Long userId, @Param("userId") Long userId,
@@ -246,8 +257,11 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
sum(case when status = '3' then 1 else 0 end) as faultDevices, sum(case when status = '3' then 1 else 0 end) as faultDevices,
sum(case when work_status = '1' then 1 else 0 end) as workingDevices, sum(case when work_status = '1' then 1 else 0 end) as workingDevices,
sum(case when work_status = '0' then 1 else 0 end) as idleDevices, sum(case when work_status = '0' then 1 else 0 end) as idleDevices,
sum(case when user_id is null then 1 else 0 end) as unboundDevices sum(case when b.device_no is null then 1 else 0 end) as unboundDevices
from app_device from app_device d
left join app_device_binding b
on b.device_no = d.device_no
and b.binding_status in ('BINDING', 'ACTIVE', 'UNBINDING')
""") """)
DashboardStatsVo selectDashboardStats(); DashboardStatsVo selectDashboardStats();

View File

@@ -0,0 +1,18 @@
package org.dromara.app.mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.dromara.app.domain.AppDeviceUnbindCommand;
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
public interface AppDeviceUnbindCommandMapper
extends BaseMapperPlus<AppDeviceUnbindCommand, AppDeviceUnbindCommand> {
@Select("""
select * from app_device_unbind_command
where device_no = #{deviceNo} and command_id = #{commandId}
limit 1
""")
AppDeviceUnbindCommand selectByDeviceAndCommandId(@Param("deviceNo") String deviceNo,
@Param("commandId") String commandId);
}

View File

@@ -0,0 +1,13 @@
package org.dromara.app.mapper;
import org.dromara.app.domain.AppFirmware;
import org.dromara.app.domain.vo.AppFirmwareVo;
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
/**
* 设备固件版本 Mapper 接口
*
* @author water team
*/
public interface AppFirmwareMapper extends BaseMapperPlus<AppFirmware, AppFirmwareVo> {
}

View File

@@ -14,14 +14,16 @@ import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
*/ */
public interface AppWateringLogMapper extends BaseMapperPlus<AppWateringLog, AppWateringLogVo> { public interface AppWateringLogMapper extends BaseMapperPlus<AppWateringLog, AppWateringLogVo> {
@Select("SELECT count(id) FROM app_watering_log\n" + @Select("SELECT count(id) FROM app_watering_log l\n" +
"WHERE user_id=#{userId} AND YEAR(create_time) = YEAR(CURDATE()) AND MONTH(create_time) = MONTH(CURDATE());") "WHERE user_id=#{userId} AND YEAR(create_time) = YEAR(CURDATE()) AND MONTH(create_time) = MONTH(CURDATE()) " +
"AND EXISTS (SELECT 1 FROM app_device_binding b WHERE b.device_no=l.device_no AND b.user_id=#{userId} AND b.binding_status='ACTIVE')")
Long selectByMonCout(@Param("userId") Long userId); Long selectByMonCout(@Param("userId") Long userId);
@Select("SELECT COUNT(id) \n" + @Select("SELECT COUNT(id) \n" +
"FROM app_watering_log \n" + "FROM app_watering_log \n" +
"WHERE user_id=#{userId} AND WEEK(create_time) = WEEK(CURDATE());") "WHERE user_id=#{userId} AND WEEK(create_time) = WEEK(CURDATE()) " +
"AND EXISTS (SELECT 1 FROM app_device_binding b WHERE b.device_no=app_watering_log.device_no AND b.user_id=#{userId} AND b.binding_status='ACTIVE')")
Long selectByWeekCount(@Param("userId") Long userId); Long selectByWeekCount(@Param("userId") Long userId);
@@ -29,11 +31,25 @@ public interface AppWateringLogMapper extends BaseMapperPlus<AppWateringLog, App
@Select("SELECT COUNT(id) \n" + @Select("SELECT COUNT(id) \n" +
"FROM app_watering_log \n" + "FROM app_watering_log \n" +
"WHERE user_id=#{userId} AND trigger_type =#{triggerType}") "WHERE user_id=#{userId} AND trigger_type =#{triggerType} " +
"AND EXISTS (SELECT 1 FROM app_device_binding b WHERE b.device_no=app_watering_log.device_no AND b.user_id=#{userId} AND b.binding_status='ACTIVE')")
Long selectByTriggerTypeCount(@Param("userId") Long userId, @Param("triggerType") String triggerType); Long selectByTriggerTypeCount(@Param("userId") Long userId, @Param("triggerType") String triggerType);
@Select("SELECT SUM(duration_min)\n" + @Select("SELECT SUM(duration_min)\n" +
"FROM app_watering_log \n" + "FROM app_watering_log \n" +
"WHERE user_id=#{userId}") "WHERE user_id=#{userId} " +
"AND EXISTS (SELECT 1 FROM app_device_binding b WHERE b.device_no=app_watering_log.device_no AND b.user_id=#{userId} AND b.binding_status='ACTIVE')")
Long selectByTotalWateringTime(@Param("userId") Long userId); Long selectByTotalWateringTime(@Param("userId") Long userId);
@Select("""
SELECT COUNT(id) FROM app_watering_log l
WHERE l.user_id = #{userId}
AND EXISTS (
SELECT 1 FROM app_device_binding b
WHERE b.device_no = l.device_no
AND b.user_id = #{userId}
AND b.binding_status = 'ACTIVE'
)
""")
Long selectActiveCount(@Param("userId") Long userId);
} }

View File

@@ -0,0 +1,62 @@
package org.dromara.app.service;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.AppDeviceBinding;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.mqtt.DeviceUnbindRequest;
import org.dromara.app.domain.mqtt.DeviceUnbindResult;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import java.util.Collection;
import java.util.List;
public interface IAppDeviceBindingService {
TableDataInfo<AppDeviceVo> queryUserDevicePage(Long userId, AppDeviceBo bo, PageQuery pageQuery);
List<AppDeviceVo> queryActiveDevices(Long userId);
AppDeviceVo queryActiveDevice(Long userId, String deviceNo);
boolean isActive(String deviceNo);
boolean isCurrentBinding(String deviceNo, String bindingId);
Long queryActiveUserId(String deviceNo);
AppDeviceBinding queryExclusiveBinding(String deviceNo);
AppDeviceBinding queryUserRelationship(Long userId, String deviceNo);
AppDeviceVo startBinding(AppDevice device, AppDeviceBo request, Long userId);
AppDeviceVo retryBinding(AppDevice device, Long userId);
boolean removeListEntry(Long userId, String deviceNo);
void removeAllListEntries(Long userId);
boolean unbindByUser(Collection<String> deviceNos, Long userId);
boolean unbindOneByUser(String deviceNo, Long userId);
boolean unbindByAdmin(Collection<String> deviceNos);
boolean unbindOneByAdmin(String deviceNo);
DeviceUnbindResult unbindByDevice(String deviceNo, DeviceUnbindRequest request);
boolean hasExclusiveBinding(String deviceNo);
void deleteRelationships(Collection<String> deviceNos);
void recoverUnbinding();
void recoverOneUnbinding(String deviceNo);
void recoverBindingCommands();
void failExpiredBindings();
}

View File

@@ -39,6 +39,12 @@ public interface IAppDeviceService {
*/ */
List<AppDeviceVo> queryByDeviceNos(Collection<String> deviceNos); List<AppDeviceVo> queryByDeviceNos(Collection<String> deviceNos);
TableDataInfo<AppDeviceVo> queryUserDevicePage(Long userId, AppDeviceBo bo, PageQuery pageQuery);
List<AppDeviceVo> queryActiveDevices(Long userId);
AppDeviceVo queryActiveDevice(Long userId, String deviceNo);
/** /**
* 分页查询设备信息 * 分页查询设备信息
列表 列表
@@ -80,6 +86,9 @@ public interface IAppDeviceService {
*/ */
Boolean updateByBo(AppDeviceBo bo); Boolean updateByBo(AppDeviceBo bo);
/** 用户侧修改设备资料,并在设备锁内复核当前绑定用户。 */
Boolean updateByBo(AppDeviceBo bo, Long userId);
/** /**
* MQTT设备注册或刷新设备基础信息 * MQTT设备注册或刷新设备基础信息
* *
@@ -96,6 +105,10 @@ public interface IAppDeviceService {
*/ */
AppDeviceVo bindRegisteredDevice(AppDeviceBo bo); AppDeviceVo bindRegisteredDevice(AppDeviceBo bo);
AppDeviceVo retryBinding(String deviceNo, Long userId);
Boolean removeUserDeviceEntry(String deviceNo, Long userId);
Boolean switchDevice(String deviceNo, String workStatus, String startTime, Integer durationMin); Boolean switchDevice(String deviceNo, String workStatus, String startTime, Integer durationMin);
/** /**
@@ -134,6 +147,14 @@ public interface IAppDeviceService {
*/ */
Boolean unbindDevices(Collection<String> deviceNos, Long userId); Boolean unbindDevices(Collection<String> deviceNos, Long userId);
/**
* 后台解绑设备:允许按权限跨用户操作,但仍要求设备当前处于绑定状态。
*
* @param deviceNos 设备编号集合
* @return 是否解绑成功
*/
Boolean unbindDevicesByAdmin(Collection<String> deviceNos);
Map<String, Object> bindDeviceStatus(AppDeviceBo appDevice); Map<String, Object> bindDeviceStatus(AppDeviceBo appDevice);
/** /**

View File

@@ -0,0 +1,86 @@
package org.dromara.app.service;
import org.dromara.app.domain.bo.AppFirmwareBo;
import org.dromara.app.domain.vo.AppFirmwareVo;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;
import java.util.Collection;
import java.util.List;
/**
* 设备固件版本 Service 接口
*
* @author water team
*/
public interface IAppFirmwareService {
/**
* 查询固件版本
*
* @param id 主键
* @return 固件版本
*/
AppFirmwareVo queryById(Long id);
/**
* 分页查询固件版本列表
*/
TableDataInfo<AppFirmwareVo> queryPageList(AppFirmwareBo bo, PageQuery pageQuery);
/**
* 查询符合条件的固件版本列表
*/
List<AppFirmwareVo> queryList(AppFirmwareBo bo);
/**
* 上传固件文件并保存固件版本记录
*
* @param file bin 文件
* @param bo 固件版本信息
* @return 保存后的固件版本
*/
AppFirmwareVo uploadFirmware(MultipartFile file, AppFirmwareBo bo);
/**
* 读取服务器本地保存的固件文件。
*
* @param storedName 服务端生成的文件名
* @return 固件文件资源
*/
Resource loadFirmware(String storedName);
/**
* 新增固件版本
*/
Boolean insertByBo(AppFirmwareBo bo);
/**
* 修改固件版本
*/
Boolean updateByBo(AppFirmwareBo bo);
/**
* 校验并批量删除固件版本
*/
Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
/**
* 对指定设备下发 OTA 升级命令
*
* @param firmwareId 固件版本 ID
* @param deviceNos 目标设备编号集合
* @return 实际下发命令的设备数量
*/
int upgrade(Long firmwareId, Collection<String> deviceNos);
/**
* 对所有在线设备下发 OTA 升级命令
*
* @param firmwareId 固件版本 ID
* @return 实际下发命令的设备数量
*/
int upgradeAll(Long firmwareId);
}

View File

@@ -20,6 +20,9 @@ public interface IAppSchedulingDeviceService {
Boolean insertByBo(AppSchedulingDeviceBo bo); Boolean insertByBo(AppSchedulingDeviceBo bo);
/** 用户侧新增排程设备关联并在设备锁内复核当前绑定用户。 */
Boolean insertByBo(AppSchedulingDeviceBo bo, Long userId);
Boolean updateByBo(AppSchedulingDeviceBo bo); Boolean updateByBo(AppSchedulingDeviceBo bo);
Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid); Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
@@ -36,5 +39,8 @@ public interface IAppSchedulingDeviceService {
Boolean deleteWithValidByScheduleIdAndDeviceNo(@NotEmpty(message = "主键不能为空") Long scheduleId, String deviceNo); Boolean deleteWithValidByScheduleIdAndDeviceNo(@NotEmpty(message = "主键不能为空") Long scheduleId, String deviceNo);
/** 用户侧删除排程设备关联,并在设备锁内复核当前绑定用户。 */
Boolean deleteWithValidByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo, Long userId);
AppSchedulingDeviceVo queryByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo); AppSchedulingDeviceVo queryByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo);
} }

View File

@@ -1,6 +1,15 @@
package org.dromara.app.service; package org.dromara.app.service;
import java.util.Collection;
public interface IDeviceCommandAckHandler { public interface IDeviceCommandAckHandler {
void handleAck(String deviceNo, String payload); void handleAck(String deviceNo, String payload);
/**
* 清理设备解绑或删除前遗留的待确认命令,避免旧命令在设备重新上线后被重发。
*
* @param deviceNos 设备编号集合
*/
void clearPendingCommands(Collection<String> deviceNos);
} }

View File

@@ -0,0 +1,13 @@
package org.dromara.app.service;
import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.domain.mqtt.DeviceCommandAck;
public interface IDeviceCommandLifecycleListener {
default void onCommandAcknowledged(DeviceCommand command, DeviceCommandAck ack) {
}
default void onCommandExpired(DeviceCommand command) {
}
}

View File

@@ -32,6 +32,12 @@ public interface IDeviceCommandService {
*/ */
String sendSwitchCommand(String deviceNo, String workStatus, String startTime, Integer durationMin); String sendSwitchCommand(String deviceNo, String workStatus, String startTime, Integer durationMin);
/**
* 用户侧下发设备开关/浇水命令,要求请求用户仍持有当前 ACTIVE 绑定。
*/
String sendSwitchCommand(String deviceNo, String workStatus, String startTime,
Integer durationMin, Long userId);
/** /**
* 下发自定义命令。 * 下发自定义命令。
* *
@@ -50,8 +56,14 @@ public interface IDeviceCommandService {
* @return commandId * @return commandId
*/ */
String sendScheduleBindCommand(String deviceNo, Map<String, Object> payload); String sendScheduleBindCommand(String deviceNo, Map<String, Object> payload);
/** 用户侧下发排程绑定命令并在设备锁内复核用户绑定。 */
String sendScheduleBindCommand(String deviceNo, Map<String, Object> payload, Long userId);
String sendBindDeviceCommand(String deviceNo); String sendBindDeviceCommand(String deviceNo);
/** 下发包含本次关系标识的绑定命令。 */
String sendBindDeviceCommand(String deviceNo, String bindingId);
/** /**
* 下发排程解绑命令,通知设备清除指定排程。 * 下发排程解绑命令,通知设备清除指定排程。
* *
@@ -61,6 +73,9 @@ public interface IDeviceCommandService {
*/ */
String sendScheduleUnbindCommand(String deviceNo, Long scheduleId); String sendScheduleUnbindCommand(String deviceNo, Long scheduleId);
/** 用户侧下发排程解绑命令并在设备锁内复核用户绑定。 */
String sendScheduleUnbindCommand(String deviceNo, Long scheduleId, Long userId);
/** /**
* 下发排程取消命令,通知设备暂停指定排程。 * 下发排程取消命令,通知设备暂停指定排程。
* *
@@ -70,6 +85,9 @@ public interface IDeviceCommandService {
*/ */
String sendScheduleCanceledCommand(String deviceNo, Long scheduleId); String sendScheduleCanceledCommand(String deviceNo, Long scheduleId);
/** 用户侧下发排程取消命令并在设备锁内复核用户绑定。 */
String sendScheduleCanceledCommand(String deviceNo, Long scheduleId, Long userId);
/** /**
* 下发设备初始化指令。用于解绑设备,允许设备未绑定用户时下发。 * 下发设备初始化指令。用于解绑设备,允许设备未绑定用户时下发。
* *
@@ -77,4 +95,34 @@ public interface IDeviceCommandService {
* @return commandId * @return commandId
*/ */
String sendInitDeviceCommand(String deviceNo); String sendInitDeviceCommand(String deviceNo);
/**
* 向设备下发恢复出厂命令。该命令只重置设备侧状态,不修改服务端绑定数据。
*
* @param deviceNo 设备编号
* @return commandId
*/
String sendFactoryResetCommand(String deviceNo);
/**
* 下发设备电量查询命令。设备收到后在 ACK 中回复电量powerLevel
*
* @param deviceNo 设备编号
* @return commandId
*/
String sendQueryPowerCommand(String deviceNo);
/** 用户侧查询电量并在设备锁内复核用户绑定。 */
String sendQueryPowerCommand(String deviceNo, Long userId);
/**
* 下发设备 OTA 升级命令。
*
* @param deviceNo 设备编号
* @param firmwareUrl 固件下载地址
* @param firmwareVersion 固件版本号
* @param md5 固件 MD5 校验值(可为空)
* @return commandId
*/
String sendOtaUpgradeCommand(String deviceNo, String firmwareUrl, String firmwareVersion, String md5);
} }

View File

@@ -0,0 +1,7 @@
package org.dromara.app.service;
import org.dromara.app.domain.mqtt.DeviceUnbindResult;
public interface IDeviceUnbindResultPublisher {
void publish(DeviceUnbindResult result);
}

View File

@@ -0,0 +1,783 @@
package org.dromara.app.service.impl;
import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.*;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.domain.mqtt.DeviceCommandAck;
import org.dromara.app.domain.mqtt.DeviceUnbindRequest;
import org.dromara.app.domain.mqtt.DeviceUnbindResult;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.*;
import org.dromara.app.service.*;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.redis.utils.RedisUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.*;
@Slf4j
@Service
public class AppDeviceBindingServiceImpl
implements IAppDeviceBindingService, IDeviceCommandLifecycleListener {
private final AppDeviceBindingMapper bindingMapper;
private final AppDeviceUnbindCommandMapper unbindCommandMapper;
private final AppDeviceMapper deviceMapper;
private final AppSchedulingDeviceMapper schedulingDeviceMapper;
private final AppWateringLogMapper wateringLogMapper;
private final TransactionTemplate transactionTemplate;
@Lazy
@Autowired
private IAppDeviceBindingService self;
@Lazy
@Autowired
private IDeviceCommandService deviceCommandService;
@Lazy
@Autowired
private IDeviceCommandAckHandler deviceCommandAckHandler;
@Lazy
@Autowired(required = false)
private IDeviceUnbindResultPublisher unbindResultPublisher;
@Value("${mqtt.command-ack.device-status-cache-prefix:mqtt:device:status:}")
private String deviceStatusCachePrefix;
@Value("${app.device.binding-timeout-ms:60000}")
private long bindingTimeoutMs;
@Value("${app.device.binding-recovery-interval-ms:20000}")
private long bindingRecoveryIntervalMs;
@Value("${app.device.unbinding-alert-attempts:5}")
private int unbindingAlertAttempts;
public AppDeviceBindingServiceImpl(AppDeviceBindingMapper bindingMapper,
AppDeviceUnbindCommandMapper unbindCommandMapper,
AppDeviceMapper deviceMapper,
AppSchedulingDeviceMapper schedulingDeviceMapper,
AppWateringLogMapper wateringLogMapper,
PlatformTransactionManager transactionManager) {
this.bindingMapper = bindingMapper;
this.unbindCommandMapper = unbindCommandMapper;
this.deviceMapper = deviceMapper;
this.schedulingDeviceMapper = schedulingDeviceMapper;
this.wateringLogMapper = wateringLogMapper;
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
@Override
public TableDataInfo<AppDeviceVo> queryUserDevicePage(Long userId, AppDeviceBo bo, PageQuery pageQuery) {
if (userId == null) {
throw new ServiceException("用户未登录");
}
Page<AppDeviceVo> page = pageQuery.build();
// JOIN 查询(b INNER JOIN d)需给排序列加表别名前缀,避免 "Column 'xxx' in order clause is ambiguous"
page.orders().replaceAll(this::qualifyBindingOrderItem);
Page<AppDeviceVo> result = bindingMapper.selectUserDevicePage(page, userId, bo);
return TableDataInfo.build(result);
}
private OrderItem qualifyBindingOrderItem(OrderItem item) {
String column = item.getColumn();
if (StringUtils.isBlank(column) || column.contains(".")) {
return item;
}
item.setColumn("b." + column);
return item;
}
@Override
public List<AppDeviceVo> queryActiveDevices(Long userId) {
return userId == null ? List.of() : bindingMapper.selectActiveDevicesByUser(userId);
}
@Override
public AppDeviceVo queryActiveDevice(Long userId, String deviceNo) {
if (userId == null || StringUtils.isBlank(deviceNo)) {
return null;
}
return bindingMapper.selectActiveDevice(userId, deviceNo.trim());
}
@Override
public boolean isActive(String deviceNo) {
AppDeviceBinding binding = queryExclusiveBinding(deviceNo);
return binding != null && DeviceBindingStatus.ACTIVE.equals(binding.getBindingStatus());
}
@Override
public boolean isCurrentBinding(String deviceNo, String bindingId) {
if (StringUtils.isBlank(bindingId)) {
return false;
}
AppDeviceBinding binding = queryExclusiveBinding(deviceNo);
return binding != null
&& DeviceBindingStatus.ACTIVE.equals(binding.getBindingStatus())
&& bindingId.equals(binding.getBindingId());
}
@Override
public Long queryActiveUserId(String deviceNo) {
AppDeviceBinding binding = queryExclusiveBinding(deviceNo);
return binding != null && DeviceBindingStatus.ACTIVE.equals(binding.getBindingStatus())
? binding.getUserId() : null;
}
@Override
public AppDeviceBinding queryExclusiveBinding(String deviceNo) {
if (StringUtils.isBlank(deviceNo)) {
return null;
}
return bindingMapper.selectExclusiveByDeviceNo(deviceNo.trim());
}
@Override
public AppDeviceBinding queryUserRelationship(Long userId, String deviceNo) {
if (userId == null || StringUtils.isBlank(deviceNo)) {
return null;
}
return bindingMapper.selectByUserAndDevice(userId, deviceNo.trim());
}
@Override
@Lock4j(keys = {"'device:command:' + #device.deviceNo"}, acquireTimeout = 5000, expire = 30000)
public AppDeviceVo startBinding(AppDevice device, AppDeviceBo request, Long userId) {
if (device == null || StringUtils.isBlank(device.getDeviceNo())) {
throw new ServiceException("设备未上线注册,请先完成配网");
}
if (userId == null) {
throw new ServiceException("用户未登录");
}
AppDeviceBinding binding = transactionTemplate.execute(status -> reserveBinding(device, request, userId));
if (binding == null) {
throw new ServiceException("设备绑定失败,请重试");
}
if (DeviceBindingStatus.ACTIVE.equals(binding.getBindingStatus())) {
return queryActiveDevice(userId, device.getDeviceNo());
}
try {
deviceCommandService.sendBindDeviceCommand(device.getDeviceNo(), binding.getBindingId());
markBindCommandSent(binding.getId(), true);
} catch (RuntimeException e) {
markBindingFailed(device.getDeviceNo(), binding.getBindingId(), "绑定命令下发失败");
throw e;
}
return toRestrictedVo(binding);
}
@Override
public AppDeviceVo retryBinding(AppDevice device, Long userId) {
AppDeviceBo request = new AppDeviceBo();
AppDeviceBinding relationship = queryUserRelationship(userId, device == null ? null : device.getDeviceNo());
if (relationship != null) {
request.setDeviceName(relationship.getDeviceNameSnapshot());
}
return self.startBinding(device, request, userId);
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public boolean removeListEntry(Long userId, String deviceNo) {
if (userId == null || StringUtils.isBlank(deviceNo)) {
throw new ServiceException("设备编号不能为空");
}
int rows = bindingMapper.delete(Wrappers.<AppDeviceBinding>lambdaQuery()
.eq(AppDeviceBinding::getUserId, userId)
.eq(AppDeviceBinding::getDeviceNo, deviceNo.trim())
.in(AppDeviceBinding::getBindingStatus,
List.of(DeviceBindingStatus.UNBOUND, DeviceBindingStatus.BIND_FAILED)));
if (rows <= 0) {
throw new ServiceException("只有已解绑或绑定失败的设备可以从列表移除");
}
return true;
}
@Override
public void removeAllListEntries(Long userId) {
if (userId == null) {
throw new ServiceException("用户未登录");
}
long unfinished = bindingMapper.selectCount(Wrappers.<AppDeviceBinding>lambdaQuery()
.eq(AppDeviceBinding::getUserId, userId)
.in(AppDeviceBinding::getBindingStatus, List.of(
DeviceBindingStatus.BINDING,
DeviceBindingStatus.ACTIVE,
DeviceBindingStatus.UNBINDING)));
if (unfinished > 0) {
throw new ServiceException("设备解绑清理尚未完成,请稍后重试注销");
}
bindingMapper.delete(Wrappers.<AppDeviceBinding>lambdaQuery()
.eq(AppDeviceBinding::getUserId, userId));
}
@Override
public boolean unbindByUser(Collection<String> deviceNos, Long userId) {
List<String> normalized = normalizeDeviceNos(deviceNos);
validateUserUnbindBatch(normalized, userId);
normalized.forEach(deviceNo -> self.unbindOneByUser(deviceNo, userId));
return true;
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public boolean unbindOneByUser(String deviceNo, Long userId) {
AppDeviceBinding binding = acceptServerUnbind(deviceNo, userId, DeviceUnbindSource.APP);
completeOrDefer(binding, false);
return true;
}
@Override
public boolean unbindByAdmin(Collection<String> deviceNos) {
List<String> normalized = normalizeDeviceNos(deviceNos);
validateAdminUnbindBatch(normalized);
normalized.forEach(deviceNo -> self.unbindOneByAdmin(deviceNo));
return true;
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public boolean unbindOneByAdmin(String deviceNo) {
AppDeviceBinding binding = acceptServerUnbind(deviceNo, null, DeviceUnbindSource.ADMIN);
completeOrDefer(binding, false);
return true;
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public DeviceUnbindResult unbindByDevice(String deviceNo, DeviceUnbindRequest request) {
validateDeviceUnbindRequest(deviceNo, request);
AppDeviceUnbindCommand existing = unbindCommandMapper.selectByDeviceAndCommandId(
deviceNo, request.getCommandId());
if (existing != null) {
if (DeviceUnbindCommandStatus.APPLIED.equals(existing.getCommandStatus())) {
return result(existing, DeviceUnbindResult.ALREADY_APPLIED, "解绑已完成");
}
if (DeviceUnbindCommandStatus.STALE.equals(existing.getCommandStatus())) {
return result(existing, DeviceUnbindResult.STALE_BINDING, "绑定标识已过期");
}
}
DeviceUnbindAcceptance acceptance = existing == null
? transactionTemplate.execute(status -> acceptDeviceUnbind(deviceNo, request))
: resumeDeviceUnbind(existing);
if (acceptance == null) {
return new DeviceUnbindResult(deviceNo, request.getCommandId(), request.getBindingId(),
DeviceUnbindResult.FAILED, "解绑处理失败");
}
if (acceptance.stale()) {
return new DeviceUnbindResult(deviceNo, request.getCommandId(), request.getBindingId(),
DeviceUnbindResult.STALE_BINDING, "绑定标识已过期");
}
try {
clearRuntimeAfterAccepted(deviceNo);
AppDeviceBinding completed = cleanupBinding(acceptance.binding().getId());
return new DeviceUnbindResult(deviceNo, request.getCommandId(), completed.getBindingId(),
DeviceUnbindResult.APPLIED, "解绑完成");
} catch (RuntimeException e) {
markCleanupFailed(acceptance.binding(), e);
return new DeviceUnbindResult(deviceNo, request.getCommandId(), request.getBindingId(),
DeviceUnbindResult.FAILED, "解绑清理失败,服务器将继续重试");
}
}
@Override
public boolean hasExclusiveBinding(String deviceNo) {
return queryExclusiveBinding(deviceNo) != null;
}
@Override
public void deleteRelationships(Collection<String> deviceNos) {
List<String> normalized = normalizeDeviceNos(deviceNos);
bindingMapper.delete(Wrappers.<AppDeviceBinding>lambdaQuery()
.in(AppDeviceBinding::getDeviceNo, normalized));
unbindCommandMapper.delete(Wrappers.<AppDeviceUnbindCommand>lambdaQuery()
.in(AppDeviceUnbindCommand::getDeviceNo, normalized));
}
@Override
public void recoverUnbinding() {
List<AppDeviceBinding> pending = bindingMapper.selectList(Wrappers.<AppDeviceBinding>lambdaQuery()
.eq(AppDeviceBinding::getBindingStatus, DeviceBindingStatus.UNBINDING)
.orderByAsc(AppDeviceBinding::getUpdateTime));
for (AppDeviceBinding binding : pending) {
self.recoverOneUnbinding(binding.getDeviceNo());
}
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public void recoverOneUnbinding(String deviceNo) {
AppDeviceBinding binding = queryExclusiveBinding(deviceNo);
if (binding == null || !DeviceBindingStatus.UNBINDING.equals(binding.getBindingStatus())) {
return;
}
try {
clearRuntimeAfterAccepted(deviceNo);
AppDeviceBinding completed = cleanupBinding(binding.getId());
publishRecoveredResult(completed);
} catch (RuntimeException e) {
markCleanupFailed(binding, e);
}
}
@Override
public void recoverBindingCommands() {
Date resendBefore = new Date(System.currentTimeMillis() - bindingRecoveryIntervalMs);
Date notExpiredAfter = new Date(System.currentTimeMillis() - bindingTimeoutMs);
List<AppDeviceBinding> pending = bindingMapper.selectList(Wrappers.<AppDeviceBinding>lambdaQuery()
.eq(AppDeviceBinding::getBindingStatus, DeviceBindingStatus.BINDING)
.and(wrapper -> wrapper.isNull(AppDeviceBinding::getLastBindCommandTime)
.or(nested -> nested.gt(AppDeviceBinding::getBindingStartedTime, notExpiredAfter)
.le(AppDeviceBinding::getLastBindCommandTime, resendBefore)))
.orderByAsc(AppDeviceBinding::getBindingStartedTime));
for (AppDeviceBinding binding : pending) {
try {
deviceCommandService.sendBindDeviceCommand(binding.getDeviceNo(), binding.getBindingId());
markBindCommandSent(binding.getId(), binding.getLastBindCommandTime() == null);
} catch (RuntimeException e) {
log.warn("[设备绑定] 恢复绑定命令下发失败 设备编号={} 绑定标识={}",
binding.getDeviceNo(), binding.getBindingId(), e);
}
}
}
@Override
public void failExpiredBindings() {
Date deadline = new Date(System.currentTimeMillis() - bindingTimeoutMs);
List<AppDeviceBinding> expired = bindingMapper.selectList(Wrappers.<AppDeviceBinding>lambdaQuery()
.eq(AppDeviceBinding::getBindingStatus, DeviceBindingStatus.BINDING)
.isNotNull(AppDeviceBinding::getLastBindCommandTime)
.and(wrapper -> wrapper.le(AppDeviceBinding::getBindingStartedTime, deadline)
.or().isNull(AppDeviceBinding::getBindingStartedTime)
.le(AppDeviceBinding::getLastBindCommandTime, deadline)));
expired.forEach(binding -> markBindingFailed(
binding.getDeviceNo(), binding.getBindingId(), "绑定确认超时"));
}
@Override
public void onCommandAcknowledged(DeviceCommand command, DeviceCommandAck ack) {
if (command == null || !"bindDevice".equals(command.getCommandType())) {
return;
}
String bindingId = Objects.toString(command.getPayload().get("bindingId"), null);
if (StringUtils.isBlank(bindingId)) {
return;
}
if (!isSuccessfulAck(ack)) {
markBindingFailed(command.getDeviceNo(), bindingId, "设备拒绝绑定命令");
return;
}
int rows = bindingMapper.update(null, Wrappers.<AppDeviceBinding>lambdaUpdate()
.set(AppDeviceBinding::getBindingStatus, DeviceBindingStatus.ACTIVE)
.set(AppDeviceBinding::getUnboundTime, null)
.set(AppDeviceBinding::getCleanupAttempts, 0)
.set(AppDeviceBinding::getCleanupError, null)
.eq(AppDeviceBinding::getDeviceNo, command.getDeviceNo())
.eq(AppDeviceBinding::getBindingId, bindingId)
.eq(AppDeviceBinding::getBindingStatus, DeviceBindingStatus.BINDING));
if (rows > 0) {
deviceMapper.update(null, Wrappers.<AppDevice>lambdaUpdate()
.set(AppDevice::getWorkStatus, "0")
.eq(AppDevice::getDeviceNo, command.getDeviceNo()));
log.info("[设备绑定] 设备确认绑定 设备编号={} 绑定标识={}", command.getDeviceNo(), bindingId);
}
}
@Override
public void onCommandExpired(DeviceCommand command) {
if (command == null || !"bindDevice".equals(command.getCommandType())) {
return;
}
String bindingId = Objects.toString(command.getPayload().get("bindingId"), null);
if (StringUtils.isNotBlank(bindingId)) {
markBindingFailed(command.getDeviceNo(), bindingId, "绑定命令重试耗尽");
}
}
private AppDeviceBinding reserveBinding(AppDevice device, AppDeviceBo request, Long userId) {
AppDeviceBinding exclusive = bindingMapper.selectExclusiveByDeviceNo(device.getDeviceNo());
if (exclusive != null) {
if (Objects.equals(exclusive.getUserId(), userId)
&& DeviceBindingStatus.ACTIVE.equals(exclusive.getBindingStatus())) {
return exclusive;
}
if (DeviceBindingStatus.UNBINDING.equals(exclusive.getBindingStatus())) {
throw new ServiceException("设备正在解绑,请稍后重试");
}
throw new ServiceException("设备已被其他用户绑定或正在绑定");
}
AppDeviceBinding relationship = bindingMapper.selectByUserAndDevice(userId, device.getDeviceNo());
String bindingId = UUID.randomUUID().toString().replace("-", "");
Date bindingStartedTime = new Date();
String deviceName = StringUtils.blankToDefault(request.getDeviceName(), device.getDeviceName());
if (relationship == null) {
relationship = new AppDeviceBinding();
relationship.setDeviceNo(device.getDeviceNo());
relationship.setUserId(userId);
relationship.setBindingId(bindingId);
relationship.setBindingStatus(DeviceBindingStatus.BINDING);
relationship.setBindingStartedTime(bindingStartedTime);
relationship.setLastBindCommandTime(null);
relationship.setDeviceNameSnapshot(deviceName);
relationship.setCleanupAttempts(0);
bindingMapper.insert(relationship);
} else {
relationship.setBindingId(bindingId);
relationship.setBindingStatus(DeviceBindingStatus.BINDING);
relationship.setBindingStartedTime(bindingStartedTime);
relationship.setLastBindCommandTime(null);
relationship.setDeviceNameSnapshot(deviceName);
relationship.setUnboundTime(null);
relationship.setUnbindSource(null);
relationship.setUnbindCommandId(null);
relationship.setCleanupAttempts(0);
relationship.setCleanupError(null);
bindingMapper.updateById(relationship);
}
deviceMapper.update(null, Wrappers.<AppDevice>lambdaUpdate()
.set(AppDevice::getUserId, null)
.set(AppDevice::getDeviceName, deviceName)
.set(StringUtils.isNotBlank(request.getWifiName()), AppDevice::getWifiName, request.getWifiName())
.set(StringUtils.isNotBlank(request.getWifiPassword()), AppDevice::getWifiPassword, request.getWifiPassword())
.set(AppDevice::getWorkStatus, "2")
.eq(AppDevice::getDeviceNo, device.getDeviceNo()));
return relationship;
}
private void validateUserUnbindBatch(List<String> deviceNos, Long userId) {
if (userId == null) {
throw new ServiceException("用户未登录");
}
List<String> invalid = deviceNos.stream().filter(deviceNo -> {
AppDeviceBinding binding = queryExclusiveBinding(deviceNo);
return binding == null || !DeviceBindingStatus.ACTIVE.equals(binding.getBindingStatus())
|| !Objects.equals(binding.getUserId(), userId);
}).toList();
if (!invalid.isEmpty()) {
throw new ServiceException("设备不存在、已解绑或无权操作:" + String.join("", invalid));
}
}
private void validateAdminUnbindBatch(List<String> deviceNos) {
List<String> invalid = deviceNos.stream().filter(deviceNo -> {
AppDeviceBinding binding = queryExclusiveBinding(deviceNo);
return binding == null || !DeviceBindingStatus.ACTIVE.equals(binding.getBindingStatus());
}).toList();
if (!invalid.isEmpty()) {
throw new ServiceException("设备不存在或不处于已绑定状态:" + String.join("", invalid));
}
}
private AppDeviceBinding acceptServerUnbind(String deviceNo, Long userId, String source) {
AppDeviceBinding accepted = transactionTemplate.execute(status -> {
AppDeviceBinding binding = bindingMapper.selectExclusiveByDeviceNo(deviceNo);
if (binding == null || !DeviceBindingStatus.ACTIVE.equals(binding.getBindingStatus())
|| (userId != null && !Objects.equals(binding.getUserId(), userId))) {
throw new ServiceException("设备不存在、已解绑或无权操作:" + deviceNo);
}
AppDevice device = deviceMapper.selectById(deviceNo);
int rows = bindingMapper.update(null, Wrappers.<AppDeviceBinding>lambdaUpdate()
.set(AppDeviceBinding::getBindingStatus, DeviceBindingStatus.UNBINDING)
.set(AppDeviceBinding::getDeviceNameSnapshot,
device == null ? binding.getDeviceNameSnapshot() : device.getDeviceName())
.set(AppDeviceBinding::getUnbindSource, source)
.set(AppDeviceBinding::getUnbindCommandId, UUID.randomUUID().toString().replace("-", ""))
.set(AppDeviceBinding::getCleanupAttempts, 0)
.set(AppDeviceBinding::getCleanupError, null)
.eq(AppDeviceBinding::getId, binding.getId())
.eq(AppDeviceBinding::getBindingStatus, DeviceBindingStatus.ACTIVE));
if (rows != 1) {
throw new ServiceException("设备解绑状态更新失败,请重试");
}
return bindingMapper.selectById(binding.getId());
});
if (accepted == null) {
throw new ServiceException("设备解绑失败,请重试");
}
return accepted;
}
private DeviceUnbindAcceptance acceptDeviceUnbind(String deviceNo, DeviceUnbindRequest request) {
AppDeviceBinding binding = bindingMapper.selectByDeviceAndBindingId(deviceNo, request.getBindingId());
if (binding == null || !DeviceBindingStatus.ACTIVE.equals(binding.getBindingStatus())) {
saveUnbindCommand(deviceNo, request, DeviceUnbindCommandStatus.STALE, "绑定标识已过期");
return new DeviceUnbindAcceptance(null, true);
}
AppDevice device = deviceMapper.selectById(deviceNo);
int rows = bindingMapper.update(null, Wrappers.<AppDeviceBinding>lambdaUpdate()
.set(AppDeviceBinding::getBindingStatus, DeviceBindingStatus.UNBINDING)
.set(AppDeviceBinding::getDeviceNameSnapshot,
device == null ? binding.getDeviceNameSnapshot() : device.getDeviceName())
.set(AppDeviceBinding::getUnbindSource, DeviceUnbindSource.DEVICE)
.set(AppDeviceBinding::getUnbindCommandId, request.getCommandId())
.set(AppDeviceBinding::getCleanupAttempts, 0)
.set(AppDeviceBinding::getCleanupError, null)
.eq(AppDeviceBinding::getId, binding.getId())
.eq(AppDeviceBinding::getBindingStatus, DeviceBindingStatus.ACTIVE));
if (rows != 1) {
throw new ServiceException("解绑状态更新失败");
}
saveUnbindCommand(deviceNo, request, DeviceUnbindCommandStatus.PROCESSING, "解绑处理中");
return new DeviceUnbindAcceptance(bindingMapper.selectById(binding.getId()), false);
}
private DeviceUnbindAcceptance resumeDeviceUnbind(AppDeviceUnbindCommand command) {
AppDeviceBinding binding = bindingMapper.selectByDeviceAndBindingId(
command.getDeviceNo(), command.getBindingId());
if (binding == null || !DeviceBindingStatus.UNBINDING.equals(binding.getBindingStatus())) {
return new DeviceUnbindAcceptance(null, true);
}
return new DeviceUnbindAcceptance(binding, false);
}
private AppDeviceBinding cleanupBinding(Long bindingRecordId) {
AppDeviceBinding completed = transactionTemplate.execute(status -> {
AppDeviceBinding binding = bindingMapper.selectById(bindingRecordId);
if (binding == null || !DeviceBindingStatus.UNBINDING.equals(binding.getBindingStatus())) {
throw new ServiceException("解绑关系不存在或状态已变化");
}
schedulingDeviceMapper.delete(Wrappers.<AppSchedulingDevice>lambdaQuery()
.eq(AppSchedulingDevice::getDeviceNo, binding.getDeviceNo()));
wateringLogMapper.delete(Wrappers.<AppWateringLog>lambdaQuery()
.eq(AppWateringLog::getDeviceNo, binding.getDeviceNo())
.eq(AppWateringLog::getUserId, binding.getUserId()));
clearDeviceBindingData(binding.getDeviceNo());
bindingMapper.update(null, Wrappers.<AppDeviceBinding>lambdaUpdate()
.set(AppDeviceBinding::getBindingStatus, DeviceBindingStatus.UNBOUND)
.set(AppDeviceBinding::getUnboundTime, new Date())
.set(AppDeviceBinding::getCleanupAttempts,
Optional.ofNullable(binding.getCleanupAttempts()).orElse(0) + 1)
.set(AppDeviceBinding::getCleanupError, null)
.eq(AppDeviceBinding::getId, binding.getId())
.eq(AppDeviceBinding::getBindingStatus, DeviceBindingStatus.UNBINDING));
if (DeviceUnbindSource.DEVICE.equals(binding.getUnbindSource())) {
unbindCommandMapper.update(null, Wrappers.<AppDeviceUnbindCommand>lambdaUpdate()
.set(AppDeviceUnbindCommand::getCommandStatus, DeviceUnbindCommandStatus.APPLIED)
.set(AppDeviceUnbindCommand::getMessage, "解绑完成")
.set(AppDeviceUnbindCommand::getCompletedTime, new Date())
.setSql("attempt_count = attempt_count + 1")
.eq(AppDeviceUnbindCommand::getDeviceNo, binding.getDeviceNo())
.eq(AppDeviceUnbindCommand::getCommandId, binding.getUnbindCommandId()));
}
return bindingMapper.selectById(binding.getId());
});
if (completed == null) {
throw new ServiceException("解绑清理失败");
}
afterCleanupCompleted(completed);
return completed;
}
private void completeOrDefer(AppDeviceBinding binding, boolean publishResult) {
try {
clearRuntimeAfterAccepted(binding.getDeviceNo());
AppDeviceBinding completed = cleanupBinding(binding.getId());
if (publishResult) {
publishRecoveredResult(completed);
}
} catch (RuntimeException e) {
markCleanupFailed(binding, e);
log.warn("[设备解绑] 清理失败,已进入持久化恢复 设备编号={}", binding.getDeviceNo(), e);
}
}
private void markCleanupFailed(AppDeviceBinding binding, RuntimeException failure) {
String message = abbreviate(failure.getMessage(), 500);
transactionTemplate.executeWithoutResult(status -> {
bindingMapper.update(null, Wrappers.<AppDeviceBinding>lambdaUpdate()
.setSql("cleanup_attempts = cleanup_attempts + 1")
.set(AppDeviceBinding::getCleanupError, message)
.eq(AppDeviceBinding::getId, binding.getId())
.eq(AppDeviceBinding::getBindingStatus, DeviceBindingStatus.UNBINDING));
if (DeviceUnbindSource.DEVICE.equals(binding.getUnbindSource())) {
unbindCommandMapper.update(null, Wrappers.<AppDeviceUnbindCommand>lambdaUpdate()
.set(AppDeviceUnbindCommand::getCommandStatus, DeviceUnbindCommandStatus.FAILED)
.set(AppDeviceUnbindCommand::getMessage, message)
.setSql("attempt_count = attempt_count + 1")
.eq(AppDeviceUnbindCommand::getDeviceNo, binding.getDeviceNo())
.eq(AppDeviceUnbindCommand::getCommandId, binding.getUnbindCommandId()));
}
});
AppDeviceBinding current = bindingMapper.selectById(binding.getId());
if (current != null && Optional.ofNullable(current.getCleanupAttempts()).orElse(0) >= unbindingAlertAttempts) {
log.error("[设备解绑告警] 清理连续失败 设备编号={} 绑定标识={} 尝试次数={} 原因={}",
current.getDeviceNo(), current.getBindingId(), current.getCleanupAttempts(), message);
}
}
private void markBindingFailed(String deviceNo, String bindingId, String reason) {
int rows = bindingMapper.update(null, Wrappers.<AppDeviceBinding>lambdaUpdate()
.set(AppDeviceBinding::getBindingStatus, DeviceBindingStatus.BIND_FAILED)
.set(AppDeviceBinding::getCleanupError, abbreviate(reason, 500))
.eq(AppDeviceBinding::getDeviceNo, deviceNo)
.eq(AppDeviceBinding::getBindingId, bindingId)
.eq(AppDeviceBinding::getBindingStatus, DeviceBindingStatus.BINDING));
if (rows > 0) {
clearDeviceBindingData(deviceNo);
log.warn("[设备绑定] 绑定失败并释放设备 设备编号={} 绑定标识={} 原因={}", deviceNo, bindingId, reason);
}
}
private void markBindCommandSent(Long bindingRecordId, boolean firstCommand) {
Date sentTime = new Date();
bindingMapper.update(null, Wrappers.<AppDeviceBinding>lambdaUpdate()
.set(firstCommand, AppDeviceBinding::getBindingStartedTime, sentTime)
.set(AppDeviceBinding::getLastBindCommandTime, sentTime)
.eq(AppDeviceBinding::getId, bindingRecordId)
.eq(AppDeviceBinding::getBindingStatus, DeviceBindingStatus.BINDING));
}
private void clearDeviceBindingData(String deviceNo) {
deviceMapper.update(null, Wrappers.<AppDevice>lambdaUpdate()
.set(AppDevice::getUserId, null)
.set(AppDevice::getDeviceName, null)
.set(AppDevice::getStatus, "0")
.set(AppDevice::getWorkStatus, "2")
.set(AppDevice::getPowerLevel, null)
.set(AppDevice::getPowerStatus, null)
.set(AppDevice::getPowerLevelUpdatatime, null)
.set(AppDevice::getWifiName, null)
.set(AppDevice::getWifiPassword, null)
.eq(AppDevice::getDeviceNo, deviceNo));
}
private void clearRuntimeAfterAccepted(String deviceNo) {
deviceCommandAckHandler.clearPendingCommands(List.of(deviceNo));
if (StringUtils.isNotBlank(deviceStatusCachePrefix)) {
RedisUtils.deleteObject(deviceStatusCachePrefix + deviceNo);
}
}
private void afterCleanupCompleted(AppDeviceBinding binding) {
if (!DeviceUnbindSource.DEVICE.equals(binding.getUnbindSource())) {
try {
deviceCommandService.sendInitDeviceCommand(binding.getDeviceNo());
} catch (RuntimeException e) {
log.warn("[设备解绑] 初始化命令下发失败,已由命令机制负责重试 设备编号={}", binding.getDeviceNo(), e);
}
}
}
private void publishRecoveredResult(AppDeviceBinding binding) {
if (!DeviceUnbindSource.DEVICE.equals(binding.getUnbindSource()) || unbindResultPublisher == null) {
return;
}
try {
unbindResultPublisher.publish(new DeviceUnbindResult(
binding.getDeviceNo(), binding.getUnbindCommandId(), binding.getBindingId(),
DeviceUnbindResult.APPLIED, "解绑完成"));
} catch (RuntimeException e) {
log.warn("[设备解绑] 恢复完成但 MQTT 回执发送失败 设备编号={} 命令编号={}",
binding.getDeviceNo(), binding.getUnbindCommandId(), e);
}
}
private void saveUnbindCommand(String deviceNo, DeviceUnbindRequest request, String status, String message) {
AppDeviceUnbindCommand command = new AppDeviceUnbindCommand();
command.setDeviceNo(deviceNo);
command.setCommandId(request.getCommandId());
command.setBindingId(request.getBindingId());
command.setCommandStatus(status);
command.setMessage(message);
command.setAttemptCount(0);
if (DeviceUnbindCommandStatus.STALE.equals(status)) {
command.setCompletedTime(new Date());
}
try {
unbindCommandMapper.insert(command);
} catch (DuplicateKeyException e) {
log.debug("[设备解绑] 命令已存在 设备编号={} 命令编号={}", deviceNo, request.getCommandId());
}
}
private void validateDeviceUnbindRequest(String deviceNo, DeviceUnbindRequest request) {
if (StringUtils.isBlank(deviceNo) || request == null
|| StringUtils.isBlank(request.getBindingId())) {
throw new ServiceException("解绑指令缺少 bindingId");
}
if (!"long_press".equals(request.getReason())) {
throw new ServiceException("不支持的设备解绑原因");
}
}
private boolean isSuccessfulAck(DeviceCommandAck ack) {
if (ack == null) {
return true;
}
// 设备实际使用 ack 字段(如 "receive"、"receive deviceNo")表示确认,优先识别;为空时回退到 status 字段
String ackFlag = ack.getAck();
if (StringUtils.isNotBlank(ackFlag)) {
return isSuccessfulAckValue(ackFlag);
}
String status = ack.getStatus();
if (StringUtils.isBlank(status)) {
return true;
}
return isSuccessfulAckValue(status);
}
private boolean isSuccessfulAckValue(String value) {
String normalized = value.trim().toLowerCase(Locale.ROOT);
// ack 字段为 "receive" 或 "receive xxx"(如 "receive deviceNo")均视为成功
if (normalized.startsWith("receive")) {
return true;
}
return switch (normalized) {
case "1", "ok", "success", "applied" -> true;
default -> false;
};
}
private List<String> normalizeDeviceNos(Collection<String> deviceNos) {
if (deviceNos == null || deviceNos.isEmpty()) {
throw new ServiceException("设备编号不能为空");
}
List<String> normalized = deviceNos.stream()
.filter(StringUtils::isNotBlank)
.map(String::trim)
.distinct()
.sorted()
.toList();
if (normalized.isEmpty()) {
throw new ServiceException("设备编号不能为空");
}
return normalized;
}
private AppDeviceVo toRestrictedVo(AppDeviceBinding binding) {
AppDeviceVo vo = new AppDeviceVo();
vo.setDeviceNo(binding.getDeviceNo());
vo.setUserId(binding.getUserId());
vo.setDeviceName(binding.getDeviceNameSnapshot());
vo.setBindingStatus(binding.getBindingStatus());
vo.setUnboundTime(binding.getUnboundTime());
return vo;
}
private DeviceUnbindResult result(AppDeviceUnbindCommand command, String status, String message) {
return new DeviceUnbindResult(command.getDeviceNo(), command.getCommandId(),
command.getBindingId(), status, message);
}
private String abbreviate(String message, int maxLength) {
String value = StringUtils.blankToDefault(message, "未知错误");
return value.length() <= maxLength ? value : value.substring(0, maxLength);
}
private record DeviceUnbindAcceptance(AppDeviceBinding binding, boolean stale) {
}
}

View File

@@ -3,26 +3,20 @@ package org.dromara.app.service.impl;
import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.ObjectUtil;
import cn.hutool.extra.qrcode.QrCodeUtil; import cn.hutool.extra.qrcode.QrCodeUtil;
import com.baomidou.lock.annotation.Lock4j; import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.OrderItem; import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.*;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.AppWateringLog;
import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.vo.AppDeviceVo; import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.domain.vo.DashboardStatsVo; import org.dromara.app.domain.vo.DashboardStatsVo;
import org.dromara.app.mapper.AppDeviceMapper; import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.mapper.AppSchedulingDeviceMapper; import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mapper.AppWateringLogMapper; import org.dromara.app.mapper.AppWateringLogMapper;
import org.dromara.app.service.IAppDeviceService; import org.dromara.app.service.*;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.common.core.enums.UserType; 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;
@@ -33,11 +27,15 @@ import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.oss.core.OssClient; import org.dromara.common.oss.core.OssClient;
import org.dromara.common.oss.entity.UploadResult; import org.dromara.common.oss.entity.UploadResult;
import org.dromara.common.oss.factory.OssFactory; import org.dromara.common.oss.factory.OssFactory;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import java.awt.*; import java.awt.*;
@@ -69,11 +67,25 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
@Lazy @Lazy
@Autowired @Autowired
private IDeviceCommandService deviceCommandService; private IDeviceCommandService deviceCommandService;
@Lazy
@Autowired
private IDeviceCommandAckHandler deviceCommandAckHandler;
@Lazy
@Autowired
private IAppDeviceBindingService deviceBindingService;
@Value("${mqtt.command-ack.device-status-cache-prefix:mqtt:device:status:}")
private String deviceStatusCachePrefix;
private final IAppWateringLogService wateringLogService; private final IAppWateringLogService wateringLogService;
@Override @Override
public AppDeviceVo queryById(String deviceNo) { public AppDeviceVo queryById(String deviceNo) {
return baseMapper.selectVoById(deviceNo); if (StringUtils.isBlank(deviceNo)) {
return null;
}
return baseMapper.selectDeviceVosByDeviceNos(List.of(deviceNo.trim())).stream()
.findFirst()
.orElse(null);
} }
@Override @Override
@@ -81,7 +93,27 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
if (deviceNos == null || deviceNos.isEmpty()) { if (deviceNos == null || deviceNos.isEmpty()) {
return List.of(); return List.of();
} }
return baseMapper.selectVoByIds(deviceNos); List<String> normalized = deviceNos.stream()
.filter(StringUtils::isNotBlank)
.map(String::trim)
.distinct()
.toList();
return normalized.isEmpty() ? List.of() : baseMapper.selectDeviceVosByDeviceNos(normalized);
}
@Override
public TableDataInfo<AppDeviceVo> queryUserDevicePage(Long userId, AppDeviceBo bo, PageQuery pageQuery) {
return deviceBindingService.queryUserDevicePage(userId, bo, pageQuery);
}
@Override
public List<AppDeviceVo> queryActiveDevices(Long userId) {
return deviceBindingService.queryActiveDevices(userId);
}
@Override
public AppDeviceVo queryActiveDevice(Long userId, String deviceNo) {
return deviceBindingService.queryActiveDevice(userId, deviceNo);
} }
@Override @Override
@@ -106,32 +138,16 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
return item; return item;
} }
private LambdaQueryWrapper<AppDevice> buildQueryWrapper(AppDeviceBo bo) { private boolean isActiveForUser(String deviceNo, Long userId) {
Map<String, Object> params = bo.getParams(); AppDeviceBinding binding = deviceBindingService.queryUserRelationship(userId, deviceNo);
LambdaQueryWrapper<AppDevice> lqw = Wrappers.lambdaQuery(); return binding != null && DeviceBindingStatus.ACTIVE.equals(binding.getBindingStatus());
lqw.orderByAsc(AppDevice::getDeviceNo);
lqw.eq(StringUtils.isNotBlank(bo.getDeviceNo()), AppDevice::getDeviceNo, bo.getDeviceNo());
lqw.eq(bo.getUserId() != null, AppDevice::getUserId, bo.getUserId());
lqw.like(StringUtils.isNotBlank(bo.getDeviceName()), AppDevice::getDeviceName, bo.getDeviceName());
lqw.eq(StringUtils.isNotBlank(bo.getDeviceImg()), AppDevice::getDeviceImg, bo.getDeviceImg());
lqw.eq(StringUtils.isNotBlank(bo.getQrcode()), AppDevice::getQrcode, bo.getQrcode());
lqw.eq(StringUtils.isNotBlank(bo.getStatus()), AppDevice::getStatus, bo.getStatus());
lqw.eq(StringUtils.isNotBlank(bo.getPowerLevel()), AppDevice::getPowerLevel, bo.getPowerLevel());
lqw.eq(bo.getPowerLevelUpdatatime() != null, AppDevice::getPowerLevelUpdatatime, bo.getPowerLevelUpdatatime());
lqw.like(StringUtils.isNotBlank(bo.getWifiName()), AppDevice::getWifiName, bo.getWifiName());
lqw.eq(StringUtils.isNotBlank(bo.getWifiPassword()), AppDevice::getWifiPassword, bo.getWifiPassword());
lqw.eq(StringUtils.isNotBlank(bo.getDeviceEm()), AppDevice::getDeviceEm, bo.getDeviceEm());
lqw.eq(StringUtils.isNotBlank(bo.getDeviceSn()), AppDevice::getDeviceSn, bo.getDeviceSn());
lqw.eq(StringUtils.isNotBlank(bo.getFwVer()), AppDevice::getFwVer, bo.getFwVer());
lqw.eq(StringUtils.isNotBlank(bo.getMacAddress()), AppDevice::getMacAddress, bo.getMacAddress());
lqw.eq(bo.getExpirationTime() != null, AppDevice::getExpirationTime, bo.getExpirationTime());
return lqw;
} }
@Override @Override
public Boolean insertByBo(AppDeviceBo bo) { public Boolean insertByBo(AppDeviceBo bo) {
// fillBindTokenHash(bo); // fillBindTokenHash(bo);
AppDevice add = MapstructUtils.convert(bo, AppDevice.class); AppDevice add = MapstructUtils.convert(bo, AppDevice.class);
add.setUserId(null);
validEntityBeforeInsert(add); validEntityBeforeInsert(add);
normalizeMacAddress(add); normalizeMacAddress(add);
ensureMacAddressAvailable(add); ensureMacAddressAvailable(add);
@@ -140,8 +156,23 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
@Override @Override
public Boolean updateByBo(AppDeviceBo bo) { public Boolean updateByBo(AppDeviceBo bo) {
return updateByBoInternal(bo);
}
@Override
@Lock4j(keys = {"'device:command:' + #bo.deviceNo"}, acquireTimeout = 5000, expire = 30000)
public Boolean updateByBo(AppDeviceBo bo, Long userId) {
if (bo == null || StringUtils.isBlank(bo.getDeviceNo()) || userId == null
|| !isActiveForUser(bo.getDeviceNo(), userId)) {
throw new ServiceException("设备不存在、已解绑或无权操作");
}
return updateByBoInternal(bo);
}
private Boolean updateByBoInternal(AppDeviceBo bo) {
// fillBindTokenHash(bo); // fillBindTokenHash(bo);
AppDevice update = MapstructUtils.convert(bo, AppDevice.class); AppDevice update = MapstructUtils.convert(bo, AppDevice.class);
update.setUserId(null);
validEntityBeforeUpdate(update); validEntityBeforeUpdate(update);
normalizeMacAddress(update); normalizeMacAddress(update);
ensureMacAddressAvailable(update); ensureMacAddressAvailable(update);
@@ -156,6 +187,7 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
AppDevice exists = baseMapper.selectById(bo.getDeviceNo()); AppDevice exists = baseMapper.selectById(bo.getDeviceNo());
AppDevice device = MapstructUtils.convert(bo, AppDevice.class); AppDevice device = MapstructUtils.convert(bo, AppDevice.class);
device.setUserId(null);
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())) {
@@ -236,29 +268,26 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
if (exists == null) { if (exists == null) {
throw new ServiceException("设备未上线注册,请先完成配网"); throw new ServiceException("设备未上线注册,请先完成配网");
} }
if (exists.getUserId() != null && !exists.getUserId().equals(userId)) { deviceBindingService.startBinding(exists, bo, userId);
throw new ServiceException("设备已被其他用户绑定"); AppDeviceVo result = queryById(exists.getDeviceNo());
if (result == null) {
throw new ServiceException("设备绑定信息查询失败");
} }
// validBindToken(bo.getBindToken(), exists); return result;
}
String status = StringUtils.isBlank(bo.getStatus()) ? exists.getStatus() : bo.getStatus(); @Override
String wifiName = StringUtils.isBlank(bo.getWifiName()) ? exists.getWifiName() : bo.getWifiName(); public AppDeviceVo retryBinding(String deviceNo, Long userId) {
String wifiPassword = StringUtils.isBlank(bo.getWifiPassword()) ? exists.getWifiPassword() : bo.getWifiPassword(); AppDevice device = baseMapper.selectById(deviceNo);
String deviceName = StringUtils.isBlank(bo.getDeviceName()) ? exists.getDeviceName() : bo.getDeviceName(); if (device == null) {
int rows = baseMapper.bindIfAvailable(exists.getDeviceNo(), userId, deviceName, status,"0",wifiName,wifiPassword); throw new ServiceException("设备不存在");
if (rows <= 0) {
AppDevice current = baseMapper.selectById(bo.getDeviceNo());
if (current == null) {
throw new ServiceException("设备未上线注册,请先完成配网");
}
if (current.getUserId() != null && !current.getUserId().equals(userId)) {
throw new ServiceException("设备已被其他用户绑定");
}
throw new ServiceException("设备绑定失败,请重试");
} }
//下发设备已绑定状态 return deviceBindingService.retryBinding(device, userId);
deviceCommandService.sendBindDeviceCommand(exists.getDeviceNo()); }
return baseMapper.selectVoById(exists.getDeviceNo());
@Override
public Boolean removeUserDeviceEntry(String deviceNo, Long userId) {
return deviceBindingService.removeListEntry(userId, deviceNo);
} }
@Override @Override
@@ -284,7 +313,8 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
AppDeviceVo device = baseMapper.selectVoById(deviceNo); AppDeviceVo device = baseMapper.selectVoById(deviceNo);
if (ObjectUtil.isNotNull(device) && StringUtils.isNotBlank(device.getDeviceNo())) { if (ObjectUtil.isNotNull(device) && StringUtils.isNotBlank(device.getDeviceNo())) {
// 通过业务层统一下发命令(含浇水日志记录) // 通过业务层统一下发命令(含浇水日志记录)
String commandId = deviceCommandService.sendSwitchCommand(deviceNo, workStatus, startTime, durationMin); String commandId = deviceCommandService.sendSwitchCommand(
deviceNo, workStatus, startTime, durationMin, userId);
log.info("[设备] 开关命令已下发 设备编号={} 命令编号={}", deviceNo, commandId); log.info("[设备] 开关命令已下发 设备编号={} 命令编号={}", deviceNo, commandId);
} }
@@ -317,22 +347,43 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
params.put("bindDeviceStatusName", "设备未入库注册,请先进行入库"); params.put("bindDeviceStatusName", "设备未入库注册,请先进行入库");
return params; return params;
} }
AppDeviceBinding binding = deviceBindingService.queryExclusiveBinding(exists.getDeviceNo());
AppDeviceBinding userRelationship = deviceBindingService.queryUserRelationship(userId, exists.getDeviceNo());
params.put("bindingStatus", binding == null ? DeviceBindingStatus.UNBOUND : binding.getBindingStatus());
// 只有当前用户的绑定或无活动绑定时才返回设备对象;其他用户只能看到占用状态,避免泄露实时/Wi-Fi 数据。
params.put("bindDevice", exists); params.put("bindDevice", exists);
if (exists.getUserId() == null && "2".equals(exists.getWorkStatus()) && "0".equals(exists.getStatus())) { if (binding == null && userRelationship != null) {
params.put("bindingStatus", userRelationship.getBindingStatus());
params.put("bindDeviceStatus", 304);
params.put("bindDeviceStatusName", "设备未绑定,请先绑定用户");
return params;
}
if (binding == null && "2".equals(exists.getWorkStatus()) && "0".equals(exists.getStatus())) {
params.put("bindDeviceStatus", 305); params.put("bindDeviceStatus", 305);
params.put("bindDeviceStatusName", "设备未配网,请先去配置网络"); params.put("bindDeviceStatusName", "设备未配网,请先去配置网络");
return params; return params;
} }
if (exists.getUserId() != null && !exists.getUserId().equals(userId)) { if (binding != null && !Objects.equals(binding.getUserId(), userId)) {
params.put("bindDeviceStatus", 303); params.put("bindDeviceStatus", 303);
params.put("bindDeviceStatusName", "设备已被其他用户绑定"); params.put("bindDeviceStatusName", "设备已被其他用户绑定");
return params; return params;
} }
if (exists.getUserId() == null ) {
if (binding == null) {
params.put("bindDeviceStatus", 304); params.put("bindDeviceStatus", 304);
params.put("bindDeviceStatusName", "设备未绑定,请先绑定用户"); params.put("bindDeviceStatusName", "设备未绑定,请先绑定用户");
return params; return params;
} }
if (DeviceBindingStatus.BINDING.equals(binding.getBindingStatus())) {
params.put("bindDeviceStatus", 306);
params.put("bindDeviceStatusName", "设备正在确认绑定");
return params;
}
if (DeviceBindingStatus.UNBINDING.equals(binding.getBindingStatus())) {
params.put("bindDeviceStatus", 307);
params.put("bindDeviceStatusName", "设备正在解绑");
return params;
}
return params; return params;
@@ -471,19 +522,9 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
} }
@Override @Override
@Transactional @Transactional(rollbackFor = Exception.class)
public Boolean deleteWithValidByIds(Collection<String> deviceNos, Boolean isValid) { public Boolean deleteWithValidByIds(Collection<String> deviceNos, Boolean isValid) {
if (deviceNos == null || deviceNos.isEmpty()) { List<String> distinctDeviceNos = normalizeDeviceNos(deviceNos);
throw new ServiceException("设备编号不能为空");
}
List<String> distinctDeviceNos = deviceNos.stream()
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
if (distinctDeviceNos.isEmpty()) {
throw new ServiceException("设备编号不能为空");
}
boolean platformAdmin = isPlatformAdmin(); boolean platformAdmin = isPlatformAdmin();
Long userId = platformAdmin ? null : LoginHelper.getUserId(); Long userId = platformAdmin ? null : LoginHelper.getUserId();
@@ -491,29 +532,27 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
if (userId == null) { if (userId == null) {
throw new ServiceException("用户未登录"); throw new ServiceException("用户未登录");
} }
Long ownedCount = baseMapper.selectCount(
Wrappers.<AppDevice>lambdaQuery()
.in(AppDevice::getDeviceNo, distinctDeviceNos)
.eq(AppDevice::getUserId, userId)
);
if (ownedCount == null || ownedCount.intValue() != distinctDeviceNos.size()) {
throw new ServiceException("设备不存在或无权操作");
}
} }
boolean flag = baseMapper.delete( List<AppDevice> devices = queryDevices(distinctDeviceNos);
if (devices.size() != distinctDeviceNos.size()) {
throw new ServiceException("设备不存在或无权操作");
}
if (distinctDeviceNos.stream().anyMatch(deviceBindingService::hasExclusiveBinding)) {
throw new ServiceException("绑定设备不能直接删除,请先解绑");
}
int deletedRows = baseMapper.delete(
Wrappers.<AppDevice>lambdaUpdate() Wrappers.<AppDevice>lambdaUpdate()
.in(AppDevice::getDeviceNo, distinctDeviceNos) .in(AppDevice::getDeviceNo, distinctDeviceNos)
.eq(!platformAdmin, AppDevice::getUserId, userId) );
) > 0; if (deletedRows != distinctDeviceNos.size()) {
if (flag) { throw new ServiceException("设备删除失败,请重试");
schedulingDeviceMapper.delete(
new QueryWrapper<AppSchedulingDevice>().in("device_no", distinctDeviceNos)
);
wateringLogMapper.delete(new QueryWrapper<AppWateringLog>().in("device_no", distinctDeviceNos));
} }
return flag; deleteDeviceRelations(distinctDeviceNos);
deviceBindingService.deleteRelationships(distinctDeviceNos);
registerDeviceRuntimeCleanupAfterCommit(distinctDeviceNos, false);
return true;
} }
private boolean isPlatformAdmin() { private boolean isPlatformAdmin() {
@@ -530,54 +569,88 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
@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) {
if (deviceNos == null || deviceNos.isEmpty()) {
throw new ServiceException("设备编号不能为空");
}
if (userId == null) { if (userId == null) {
throw new ServiceException("用户不能为空"); throw new ServiceException("用户不能为空");
} }
return deviceBindingService.unbindByUser(deviceNos, userId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean unbindDevicesByAdmin(Collection<String> deviceNos) {
return deviceBindingService.unbindByAdmin(deviceNos);
}
private List<String> normalizeDeviceNos(Collection<String> deviceNos) {
if (deviceNos == null || deviceNos.isEmpty()) {
throw new ServiceException("设备编号不能为空");
}
List<String> distinctDeviceNos = deviceNos.stream() List<String> distinctDeviceNos = deviceNos.stream()
.filter(StringUtils::isNotBlank) .filter(StringUtils::isNotBlank)
.map(String::trim)
.distinct() .distinct()
.sorted()
.toList(); .toList();
if (distinctDeviceNos.isEmpty()) { if (distinctDeviceNos.isEmpty()) {
throw new ServiceException("设备编号不能为空"); throw new ServiceException("设备编号不能为空");
} }
return distinctDeviceNos;
}
List<AppDevice> devices = baseMapper.selectList( private List<AppDevice> queryDevices(List<String> deviceNos) {
return baseMapper.selectList(
Wrappers.<AppDevice>lambdaQuery() Wrappers.<AppDevice>lambdaQuery()
.in(AppDevice::getDeviceNo, distinctDeviceNos) .in(AppDevice::getDeviceNo, deviceNos)
.eq(AppDevice::getUserId, userId) .orderByAsc(AppDevice::getDeviceNo)
); );
if (devices.size() != distinctDeviceNos.size()) { }
throw new ServiceException("设备不存在或无权操作");
}
for (AppDevice device : devices) {
deviceCommandService.sendInitDeviceCommand(device.getDeviceNo());
}
int rows = baseMapper.update(null,
Wrappers.<AppDevice>lambdaUpdate()
.set(AppDevice::getUserId, null)
.set(AppDevice::getWorkStatus, "0")
.set(AppDevice::getWifiName, null)
.set(AppDevice::getWifiPassword, null)
.in(AppDevice::getDeviceNo, distinctDeviceNos)
.eq(AppDevice::getUserId, userId)
);
if (rows != distinctDeviceNos.size()) {
throw new ServiceException("设备解绑失败,请重试");
}
private void deleteDeviceRelations(List<String> deviceNos) {
schedulingDeviceMapper.delete( schedulingDeviceMapper.delete(
new QueryWrapper<AppSchedulingDevice>().in("device_no", distinctDeviceNos) Wrappers.<AppSchedulingDevice>lambdaQuery().in(AppSchedulingDevice::getDeviceNo, deviceNos)
); );
wateringLogMapper.delete( wateringLogMapper.delete(
new QueryWrapper<AppWateringLog>().in("device_no", distinctDeviceNos) Wrappers.<AppWateringLog>lambdaQuery().in(AppWateringLog::getDeviceNo, deviceNos)
); );
return true; }
private void registerDeviceRuntimeCleanupAfterCommit(List<String> deviceNos, boolean initializeDevice) {
Runnable cleanup = () -> {
if (deviceCommandAckHandler != null) {
try {
deviceCommandAckHandler.clearPendingCommands(deviceNos);
} catch (RuntimeException e) {
log.warn("[设备] 清理待确认命令失败 设备编号={}", deviceNos, e);
}
}
for (String deviceNo : deviceNos) {
if (StringUtils.isNotBlank(deviceStatusCachePrefix)) {
try {
RedisUtils.deleteObject(deviceStatusCachePrefix + deviceNo);
} catch (RuntimeException e) {
log.warn("[设备] 清理在线状态缓存失败 设备编号={}", deviceNo, e);
}
}
if (initializeDevice && deviceCommandService != null) {
try {
deviceCommandService.sendInitDeviceCommand(deviceNo);
} catch (RuntimeException e) {
log.warn("[设备] 初始化命令下发失败 设备编号={}", deviceNo, e);
}
}
}
};
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
cleanup.run();
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
cleanup.run();
}
});
} }
/** /**

View File

@@ -0,0 +1,252 @@
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.config.FirmwareStorageProperties;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.AppFirmware;
import org.dromara.app.domain.bo.AppFirmwareBo;
import org.dromara.app.domain.vo.AppFirmwareVo;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.mapper.AppFirmwareMapper;
import org.dromara.app.service.IAppFirmwareService;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.MapstructUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* 设备固件版本 Service 业务层处理
*
* @author water team
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class AppFirmwareServiceImpl implements IAppFirmwareService {
private static final String STATUS_ENABLED = "1";
private static final String DOWNLOAD_PATH = "/app/firmware/download/";
private static final Pattern STORED_NAME_PATTERN = Pattern.compile("^[0-9a-f]{32}\\.bin$");
private final AppFirmwareMapper baseMapper;
private final AppDeviceMapper deviceMapper;
private final IDeviceCommandService deviceCommandService;
private final FirmwareStorageProperties storageProperties;
@Override
public AppFirmwareVo queryById(Long id) {
return baseMapper.selectVoById(id);
}
@Override
public TableDataInfo<AppFirmwareVo> queryPageList(AppFirmwareBo bo, PageQuery pageQuery) {
LambdaQueryWrapper<AppFirmware> lqw = buildQueryWrapper(bo);
Page<AppFirmwareVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
return TableDataInfo.build(result);
}
@Override
public List<AppFirmwareVo> queryList(AppFirmwareBo bo) {
return baseMapper.selectVoList(buildQueryWrapper(bo));
}
@Override
public AppFirmwareVo uploadFirmware(MultipartFile file, AppFirmwareBo bo) {
if (file == null || file.isEmpty()) {
throw new ServiceException("固件文件不能为空");
}
String fileName = file.getOriginalFilename();
if (StringUtils.isBlank(fileName) || !fileName.toLowerCase(Locale.ROOT).endsWith(".bin")) {
throw new ServiceException("仅支持 .bin 格式的固件文件");
}
if (bo == null || StringUtils.isBlank(bo.getFirmwareVersion())) {
throw new ServiceException("固件版本号不能为空");
}
Path storedFile = null;
try {
Path storageDirectory = storageDirectory();
Files.createDirectories(storageDirectory);
String storedName = UUID.randomUUID().toString().replace("-", "") + ".bin";
storedFile = storageDirectory.resolve(storedName);
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, storedFile);
}
String md5;
try (InputStream inputStream = Files.newInputStream(storedFile)) {
md5 = DigestUtils.md5DigestAsHex(inputStream);
}
AppFirmware firmware = new AppFirmware();
firmware.setFirmwareVersion(bo.getFirmwareVersion().trim());
firmware.setFileName(Path.of(fileName).getFileName().toString());
firmware.setFileUrl(buildDownloadUrl(storedName));
firmware.setFileSize(file.getSize());
firmware.setMd5(md5);
firmware.setReleaseNotes(bo.getReleaseNotes());
firmware.setStatus(StringUtils.blankToDefault(bo.getStatus(), STATUS_ENABLED));
if (baseMapper.insert(firmware) <= 0) {
throw new ServiceException("保存固件版本失败");
}
log.info("[OTA升级] 固件已上传 版本={} 文件名={} 大小={} md5={}",
firmware.getFirmwareVersion(), fileName, file.getSize(), md5);
return baseMapper.selectVoById(firmware.getId());
} catch (IOException e) {
deleteQuietly(storedFile);
throw new ServiceException("保存固件文件失败");
} catch (RuntimeException e) {
deleteQuietly(storedFile);
throw e;
}
}
@Override
public Resource loadFirmware(String storedName) {
if (StringUtils.isBlank(storedName) || !STORED_NAME_PATTERN.matcher(storedName).matches()) {
throw new ServiceException("固件文件名无效");
}
Path file = storageDirectory().resolve(storedName).normalize();
if (!Files.isRegularFile(file)) {
throw new ServiceException("固件文件不存在");
}
return new FileSystemResource(file);
}
@Override
public Boolean insertByBo(AppFirmwareBo bo) {
AppFirmware add = MapstructUtils.convert(bo, AppFirmware.class);
boolean flag = baseMapper.insert(add) > 0;
if (flag) {
bo.setId(add.getId());
}
return flag;
}
@Override
public Boolean updateByBo(AppFirmwareBo bo) {
AppFirmware update = MapstructUtils.convert(bo, AppFirmware.class);
return baseMapper.updateById(update) > 0;
}
@Override
public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
return baseMapper.deleteByIds(ids) > 0;
}
@Override
public int upgrade(Long firmwareId, Collection<String> deviceNos) {
AppFirmwareVo firmware = requireFirmware(firmwareId);
if (deviceNos == null || deviceNos.isEmpty()) {
throw new ServiceException("请选择要升级的设备");
}
int count = 0;
for (String deviceNo : deviceNos) {
if (StringUtils.isBlank(deviceNo)) {
continue;
}
try {
deviceCommandService.sendOtaUpgradeCommand(deviceNo.trim(), firmware.getFileUrl(),
firmware.getFirmwareVersion(), firmware.getMd5());
count++;
} catch (RuntimeException e) {
log.warn("[OTA升级] 下发升级命令失败 设备编号={} 原因={}", deviceNo, e.getMessage());
}
}
log.info("[OTA升级] 指定设备升级 固件版本={} 成功下发={}", firmware.getFirmwareVersion(), count);
return count;
}
@Override
public int upgradeAll(Long firmwareId) {
AppFirmwareVo firmware = requireFirmware(firmwareId);
List<AppDevice> onlineDevices = deviceMapper.selectList(Wrappers.<AppDevice>lambdaQuery()
.eq(AppDevice::getStatus, "1"));
int count = 0;
for (AppDevice device : onlineDevices) {
try {
deviceCommandService.sendOtaUpgradeCommand(device.getDeviceNo(), firmware.getFileUrl(),
firmware.getFirmwareVersion(), firmware.getMd5());
count++;
} catch (RuntimeException e) {
log.warn("[OTA升级] 下发升级命令失败 设备编号={} 原因={}", device.getDeviceNo(), e.getMessage());
}
}
log.info("[OTA升级] 全部在线设备升级 固件版本={} 在线设备数={} 成功下发={}",
firmware.getFirmwareVersion(), onlineDevices.size(), count);
return count;
}
private AppFirmwareVo requireFirmware(Long firmwareId) {
if (firmwareId == null) {
throw new ServiceException("固件版本 ID 不能为空");
}
AppFirmwareVo firmware = baseMapper.selectVoById(firmwareId);
if (firmware == null) {
throw new ServiceException("固件版本不存在:" + firmwareId);
}
if (StringUtils.isBlank(firmware.getFileUrl())) {
throw new ServiceException("固件下载地址缺失,无法下发升级:" + firmware.getFirmwareVersion());
}
return firmware;
}
private Path storageDirectory() {
Path configured = storageProperties.getDirectory();
if (configured == null) {
throw new ServiceException("未配置固件本地存储目录");
}
return configured.toAbsolutePath().normalize();
}
private String buildDownloadUrl(String storedName) {
String baseUrl = storageProperties.getDownloadBaseUrl();
if (StringUtils.isBlank(baseUrl)) {
try {
baseUrl = ServletUriComponentsBuilder.fromCurrentContextPath().toUriString();
} catch (IllegalStateException e) {
throw new ServiceException("未配置固件下载基础地址");
}
}
return StringUtils.removeEnd(baseUrl.trim(), "/") + DOWNLOAD_PATH + storedName;
}
private void deleteQuietly(Path file) {
if (file == null) {
return;
}
try {
Files.deleteIfExists(file);
} catch (IOException e) {
log.warn("[OTA升级] 清理本地固件文件失败 文件={}", file, e);
}
}
private LambdaQueryWrapper<AppFirmware> buildQueryWrapper(AppFirmwareBo bo) {
LambdaQueryWrapper<AppFirmware> lqw = Wrappers.lambdaQuery();
lqw.eq(StringUtils.isNotBlank(bo.getFirmwareVersion()), AppFirmware::getFirmwareVersion, bo.getFirmwareVersion());
lqw.eq(StringUtils.isNotBlank(bo.getStatus()), AppFirmware::getStatus, bo.getStatus());
lqw.orderByDesc(AppFirmware::getId);
return lqw;
}
}

View File

@@ -1,5 +1,6 @@
package org.dromara.app.service.impl; package org.dromara.app.service.impl;
import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -10,7 +11,9 @@ import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo; import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
import org.dromara.app.domain.vo.AppSchedulingDeviceVo; import org.dromara.app.domain.vo.AppSchedulingDeviceVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper; import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IAppSchedulingDeviceService; import org.dromara.app.service.IAppSchedulingDeviceService;
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;
import org.dromara.common.mybatis.core.page.PageQuery; import org.dromara.common.mybatis.core.page.PageQuery;
@@ -30,6 +33,7 @@ import java.util.stream.Collectors;
public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceService { public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceService {
private final AppSchedulingDeviceMapper baseMapper; private final AppSchedulingDeviceMapper baseMapper;
private final IAppDeviceBindingService deviceBindingService;
@Override @Override
public AppSchedulingDeviceVo queryById(Long schedulingId) { public AppSchedulingDeviceVo queryById(Long schedulingId) {
@@ -58,7 +62,17 @@ public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceServi
} }
@Override @Override
@Lock4j(keys = {"'device:command:' + #bo.deviceNo"}, acquireTimeout = 5000, expire = 30000)
public Boolean insertByBo(AppSchedulingDeviceBo bo) { public Boolean insertByBo(AppSchedulingDeviceBo bo) {
return insertByBo(bo, null);
}
@Override
@Lock4j(keys = {"'device:command:' + #bo.deviceNo"}, acquireTimeout = 5000, expire = 30000)
public Boolean insertByBo(AppSchedulingDeviceBo bo, Long userId) {
if (bo == null || StringUtils.isBlank(bo.getDeviceNo()) || !isActiveForUser(bo.getDeviceNo(), userId)) {
throw new ServiceException("设备不存在、已解绑或无权操作");
}
if (queryByScheduleIdAndDeviceNo(bo.getScheduleId(), bo.getDeviceNo()) != null) { if (queryByScheduleIdAndDeviceNo(bo.getScheduleId(), bo.getDeviceNo()) != null) {
return true; return true;
} }
@@ -71,6 +85,14 @@ public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceServi
} }
} }
private boolean isActiveForUser(String deviceNo, Long userId) {
if (userId == null) {
return deviceBindingService.isActive(deviceNo);
}
var binding = deviceBindingService.queryUserRelationship(userId, deviceNo);
return binding != null && "ACTIVE".equals(binding.getBindingStatus());
}
@Override @Override
public Boolean updateByBo(AppSchedulingDeviceBo bo) { public Boolean updateByBo(AppSchedulingDeviceBo bo) {
AppSchedulingDevice update = MapstructUtils.convert(bo, AppSchedulingDevice.class); AppSchedulingDevice update = MapstructUtils.convert(bo, AppSchedulingDevice.class);
@@ -104,6 +126,15 @@ public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceServi
.eq("device_no", deviceNo)) > 0; .eq("device_no", deviceNo)) > 0;
} }
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public Boolean deleteWithValidByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo, Long userId) {
if (StringUtils.isBlank(deviceNo) || userId == null || !isActiveForUser(deviceNo, userId)) {
throw new ServiceException("设备不存在、已解绑或无权操作");
}
return deleteWithValidByScheduleIdAndDeviceNo(scheduleId, deviceNo);
}
@Override @Override
public List<AppSchedulingDeviceVo> findByDeviceNo(String deviceNo) { public List<AppSchedulingDeviceVo> findByDeviceNo(String deviceNo) {
return baseMapper.selectVoList(new QueryWrapper<AppSchedulingDevice>().eq("device_no", deviceNo)); return baseMapper.selectVoList(new QueryWrapper<AppSchedulingDevice>().eq("device_no", deviceNo));

View File

@@ -1,7 +1,6 @@
package org.dromara.app.service.impl; package org.dromara.app.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -75,6 +74,10 @@ public class AppWateringLogServiceImpl implements IAppWateringLogService {
LambdaQueryWrapper<AppWateringLog> lqw = Wrappers.lambdaQuery(); LambdaQueryWrapper<AppWateringLog> lqw = Wrappers.lambdaQuery();
lqw.orderByAsc(AppWateringLog::getId); lqw.orderByAsc(AppWateringLog::getId);
lqw.eq(bo.getUserId() != null, AppWateringLog::getUserId, bo.getUserId()); lqw.eq(bo.getUserId() != null, AppWateringLog::getUserId, bo.getUserId());
if (bo.getUserId() != null) {
lqw.exists("select 1 from app_device_binding b where b.device_no = app_watering_log.device_no "
+ "and b.user_id = " + bo.getUserId() + " and b.binding_status = 'ACTIVE'");
}
lqw.eq(StringUtils.isNotBlank(bo.getDeviceNo()), AppWateringLog::getDeviceNo, bo.getDeviceNo()); lqw.eq(StringUtils.isNotBlank(bo.getDeviceNo()), AppWateringLog::getDeviceNo, bo.getDeviceNo());
lqw.eq(StringUtils.isNotBlank(bo.getCommandId()), AppWateringLog::getCommandId, bo.getCommandId()); lqw.eq(StringUtils.isNotBlank(bo.getCommandId()), AppWateringLog::getCommandId, bo.getCommandId());
lqw.eq(bo.getScheduleId() != null, AppWateringLog::getScheduleId, bo.getScheduleId()); lqw.eq(bo.getScheduleId() != null, AppWateringLog::getScheduleId, bo.getScheduleId());
@@ -333,7 +336,7 @@ public class AppWateringLogServiceImpl implements IAppWateringLogService {
@Override @Override
public Long queryCount(Long userId) { public Long queryCount(Long userId) {
return baseMapper.selectCount(new QueryWrapper<AppWateringLog>().eq("user_id",userId)); return baseMapper.selectActiveCount(userId);
} }
@Override @Override

View File

@@ -1,15 +1,15 @@
package org.dromara.app.service.impl; package org.dromara.app.service.impl;
import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.ObjectUtil;
import com.baomidou.lock.annotation.Lock4j;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppDeviceBinding;
import org.dromara.app.domain.DeviceBindingStatus;
import org.dromara.app.domain.bo.AppWateringLogBo; import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.mqtt.DeviceCommand; import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.domain.vo.AppDeviceVo; import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.service.IAppDeviceService; import org.dromara.app.service.*;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.app.service.IDeviceCommandPublisher;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils; import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.common.satoken.utils.LoginHelper;
@@ -38,12 +38,17 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
@Autowired @Autowired
private IDeviceCommandPublisher commandPublisher; private IDeviceCommandPublisher commandPublisher;
@Lazy
@Autowired(required = false)
private IAppDeviceBindingService deviceBindingService;
private final IAppDeviceService deviceService; private final IAppDeviceService deviceService;
private final IAppWateringLogService wateringLogService; private final IAppWateringLogService wateringLogService;
// ========================= 通用下发 ========================= // ========================= 通用下发 =========================
@Override @Override
@Lock4j(keys = {"'device:command:' + #command.deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendCommand(DeviceCommand command) { public String sendCommand(DeviceCommand command) {
validate(command); validate(command);
if (StringUtils.isBlank(command.getDeviceMac())) { if (StringUtils.isBlank(command.getDeviceMac())) {
@@ -58,8 +63,16 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
// ========================= 类型化下发 ========================= // ========================= 类型化下发 =========================
@Override @Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendSwitchCommand(String deviceNo, String workStatus, String startTime, Integer durationMin) { public String sendSwitchCommand(String deviceNo, String workStatus, String startTime, Integer durationMin) {
assertDeviceExists(deviceNo); return sendSwitchCommand(deviceNo, workStatus, startTime, durationMin, LoginHelper.getUserId());
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendSwitchCommand(String deviceNo, String workStatus, String startTime,
Integer durationMin, Long userId) {
requireCommandDevice(deviceNo, userId);
assertSwitchStatus(workStatus); assertSwitchStatus(workStatus);
if ("1".equals(workStatus)) { if ("1".equals(workStatus)) {
assertDurationPositive(durationMin); assertDurationPositive(durationMin);
@@ -76,12 +89,12 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
if (durationMin != null) { if (durationMin != null) {
command.getPayload().put("durationMin", durationMin); command.getPayload().put("durationMin", durationMin);
} }
attachCurrentBindingId(command);
String commandId = commandPublisher.send(command); String commandId = commandPublisher.send(command);
log.info("[命令] 开关命令已下发 设备编号={} 工作状态={} 持续分钟数={} 命令编号={}", log.info("[命令] 开关命令已下发 设备编号={} 工作状态={} 持续分钟数={} 命令编号={}",
deviceNo, workStatus, durationMin, commandId); deviceNo, workStatus, durationMin, commandId);
Long userId = LoginHelper.getUserId();
if ("1".equals(workStatus)) { if ("1".equals(workStatus)) {
AppWateringLogBo logBo = buildManualStartLog(deviceNo, userId, commandId, commandStartTime, durationMin); AppWateringLogBo logBo = buildManualStartLog(deviceNo, userId, commandId, commandStartTime, durationMin);
wateringLogService.insertByBo(logBo); wateringLogService.insertByBo(logBo);
@@ -97,6 +110,7 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
} }
@Override @Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendCustomCommand(String deviceNo, String commandType, Map<String, Object> extra) { public String sendCustomCommand(String deviceNo, String commandType, Map<String, Object> extra) {
assertDeviceExists(deviceNo); assertDeviceExists(deviceNo);
@@ -107,12 +121,20 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
if (extra != null) { if (extra != null) {
command.getPayload().putAll(extra); command.getPayload().putAll(extra);
} }
attachCurrentBindingId(command);
return sendCommand(command); return sendCommand(command);
} }
@Override @Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendScheduleBindCommand(String deviceNo, Map<String, Object> payload) { public String sendScheduleBindCommand(String deviceNo, Map<String, Object> payload) {
AppDeviceVo device = requireCommandDevice(deviceNo); return sendScheduleBindCommand(deviceNo, payload, null);
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendScheduleBindCommand(String deviceNo, Map<String, Object> payload, Long userId) {
AppDeviceVo device = requireCommandDevice(deviceNo, userId);
if (StringUtils.isBlank(device.getMacAddress())) { if (StringUtils.isBlank(device.getMacAddress())) {
throw new ServiceException("设备未登记 MAC无法下发排程" + deviceNo); throw new ServiceException("设备未登记 MAC无法下发排程" + deviceNo);
} }
@@ -125,11 +147,19 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
if (payload != null) { if (payload != null) {
command.getPayload().putAll(payload); command.getPayload().putAll(payload);
} }
attachCurrentBindingId(command);
return sendCommand(command); return sendCommand(command);
} }
@Override @Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendBindDeviceCommand(String deviceNo) { public String sendBindDeviceCommand(String deviceNo) {
return sendBindDeviceCommand(deviceNo, null);
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendBindDeviceCommand(String deviceNo, String bindingId) {
AppDeviceVo device = deviceService.queryById(deviceNo); AppDeviceVo device = deviceService.queryById(deviceNo);
if (ObjectUtil.isNull(device)) { if (ObjectUtil.isNull(device)) {
throw new ServiceException("设备不存在:" + deviceNo); throw new ServiceException("设备不存在:" + deviceNo);
@@ -146,13 +176,25 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
objectObjectHashMap.put("bindStatus", true); objectObjectHashMap.put("bindStatus", true);
objectObjectHashMap.put("deviceNo", deviceNo); objectObjectHashMap.put("deviceNo", deviceNo);
objectObjectHashMap.put("cmd", -1); objectObjectHashMap.put("cmd", -1);
if (StringUtils.isNotBlank(bindingId)) {
objectObjectHashMap.put("bindingId", bindingId);
}
command.getPayload().putAll(objectObjectHashMap); command.getPayload().putAll(objectObjectHashMap);
return sendCommand(command); String commandId = commandPublisher.send(command);
log.info("[命令] 绑定命令已下发 设备编号={} 绑定标识={} 命令编号={}", deviceNo, bindingId, commandId);
return commandId;
} }
@Override @Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendScheduleUnbindCommand(String deviceNo, Long scheduleId) { public String sendScheduleUnbindCommand(String deviceNo, Long scheduleId) {
AppDeviceVo device = requireCommandDevice(deviceNo); return sendScheduleUnbindCommand(deviceNo, scheduleId, null);
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendScheduleUnbindCommand(String deviceNo, Long scheduleId, Long userId) {
AppDeviceVo device = requireCommandDevice(deviceNo, userId);
if (StringUtils.isBlank(device.getMacAddress())) { if (StringUtils.isBlank(device.getMacAddress())) {
throw new ServiceException("设备未登记 MAC无法下发排程解绑命令" + deviceNo); throw new ServiceException("设备未登记 MAC无法下发排程解绑命令" + deviceNo);
} }
@@ -166,12 +208,20 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
command.getPayload().put("deviceNo", deviceNo); command.getPayload().put("deviceNo", deviceNo);
command.getPayload().put("scheduleId", scheduleId); command.getPayload().put("scheduleId", scheduleId);
command.getPayload().put("unbind", true); command.getPayload().put("unbind", true);
attachCurrentBindingId(command);
return sendCommand(command); return sendCommand(command);
} }
@Override @Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendScheduleCanceledCommand(String deviceNo, Long scheduleId) { public String sendScheduleCanceledCommand(String deviceNo, Long scheduleId) {
AppDeviceVo device = requireCommandDevice(deviceNo); return sendScheduleCanceledCommand(deviceNo, scheduleId, null);
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendScheduleCanceledCommand(String deviceNo, Long scheduleId, Long userId) {
AppDeviceVo device = requireCommandDevice(deviceNo, userId);
if (StringUtils.isBlank(device.getMacAddress())) { if (StringUtils.isBlank(device.getMacAddress())) {
throw new ServiceException("设备未登记 MAC无法下发排程取消命令" + deviceNo); throw new ServiceException("设备未登记 MAC无法下发排程取消命令" + deviceNo);
} }
@@ -184,10 +234,12 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
command.getPayload().put("deviceNo", deviceNo); command.getPayload().put("deviceNo", deviceNo);
command.getPayload().put("scheduleId", scheduleId); command.getPayload().put("scheduleId", scheduleId);
command.getPayload().put("canceled", true); command.getPayload().put("canceled", true);
attachCurrentBindingId(command);
return sendCommand(command); return sendCommand(command);
} }
@Override @Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendInitDeviceCommand(String deviceNo) { public String sendInitDeviceCommand(String deviceNo) {
AppDeviceVo device = deviceService.queryById(deviceNo); AppDeviceVo device = deviceService.queryById(deviceNo);
if (ObjectUtil.isNull(device)) { if (ObjectUtil.isNull(device)) {
@@ -211,6 +263,85 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
log.info("[命令] 设备初始化命令已下发 设备编号={} 命令编号={}", deviceNo, commandId); log.info("[命令] 设备初始化命令已下发 设备编号={} 命令编号={}", deviceNo, commandId);
return commandId; return commandId;
} }
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendFactoryResetCommand(String deviceNo) {
AppDeviceVo device = deviceService.queryById(deviceNo);
if (ObjectUtil.isNull(device)) {
throw new ServiceException("设备不存在:" + deviceNo);
}
if (StringUtils.isBlank(device.getMacAddress())) {
throw new ServiceException("设备未登记 MAC无法下发恢复出厂命令" + deviceNo);
}
DeviceCommand command = new DeviceCommand();
command.setDeviceNo(deviceNo);
command.setDeviceMac(device.getMacAddress());
command.setCommandType("factoryReset");
command.getPayload().put("deviceNo", deviceNo);
String commandId = commandPublisher.send(command);
log.info("[命令] 设备恢复出厂命令已下发 设备编号={} 命令编号={}", deviceNo, commandId);
return commandId;
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendQueryPowerCommand(String deviceNo) {
return sendQueryPowerCommand(deviceNo, LoginHelper.getUserId());
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendQueryPowerCommand(String deviceNo, Long userId) {
AppDeviceVo device = requireCommandDevice(deviceNo, userId);
if (StringUtils.isBlank(device.getMacAddress())) {
throw new ServiceException("设备未登记 MAC无法下发电量查询命令" + deviceNo);
}
DeviceCommand command = new DeviceCommand();
command.setDeviceNo(deviceNo);
command.setDeviceMac(device.getMacAddress());
command.setCommandType("queryPower");
command.getPayload().put("deviceNo", deviceNo);
attachCurrentBindingId(command);
String commandId = commandPublisher.send(command);
log.info("[命令] 电量查询命令已下发 设备编号={} 命令编号={}", deviceNo, commandId);
return commandId;
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendOtaUpgradeCommand(String deviceNo, String firmwareUrl, String firmwareVersion, String md5) {
AppDeviceVo device = deviceService.queryById(deviceNo);
if (ObjectUtil.isNull(device)) {
throw new ServiceException("设备不存在:" + deviceNo);
}
if (StringUtils.isBlank(device.getMacAddress())) {
throw new ServiceException("设备未登记 MAC无法下发 OTA 升级命令:" + deviceNo);
}
if (StringUtils.isBlank(firmwareUrl) || StringUtils.isBlank(firmwareVersion)) {
throw new ServiceException("固件下载地址或版本号不能为空");
}
DeviceCommand command = new DeviceCommand();
command.setDeviceNo(deviceNo);
command.setDeviceMac(device.getMacAddress());
command.setCommandType("otaUpgrade");
command.getPayload().put("deviceNo", deviceNo);
command.getPayload().put("firmwareUrl", firmwareUrl);
command.getPayload().put("firmwareVersion", firmwareVersion);
if (StringUtils.isNotBlank(md5)) {
command.getPayload().put("md5", md5);
}
attachCurrentBindingId(command);
String commandId = commandPublisher.send(command);
log.info("[命令] OTA 升级命令已下发 设备编号={} 固件版本={} 命令编号={}", deviceNo, firmwareVersion, commandId);
return commandId;
}
// ========================= 校验 ========================= // ========================= 校验 =========================
private void validate(DeviceCommand command) { private void validate(DeviceCommand command) {
@@ -224,13 +355,7 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
} }
private void assertDeviceExists(String deviceNo) { private void assertDeviceExists(String deviceNo) {
AppDeviceVo device = deviceService.queryById(deviceNo); requireCommandDevice(deviceNo, null);
if (ObjectUtil.isNull(device)) {
throw new ServiceException("设备不存在:" + deviceNo);
}
if (device.getUserId() == null) {
throw new ServiceException("设备未绑定用户,无法下发命令:" + deviceNo);
}
} }
private String requireDeviceMac(String deviceNo) { private String requireDeviceMac(String deviceNo) {
@@ -242,16 +367,34 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
} }
private AppDeviceVo requireCommandDevice(String deviceNo) { private AppDeviceVo requireCommandDevice(String deviceNo) {
return requireCommandDevice(deviceNo, null);
}
private AppDeviceVo requireCommandDevice(String deviceNo, Long userId) {
AppDeviceVo device = deviceService.queryById(deviceNo); AppDeviceVo device = deviceService.queryById(deviceNo);
if (ObjectUtil.isNull(device)) { if (ObjectUtil.isNull(device)) {
throw new ServiceException("设备不存在:" + deviceNo); throw new ServiceException("设备不存在:" + deviceNo);
} }
if (device.getUserId() == null) { if (!DeviceBindingStatus.ACTIVE.equals(device.getBindingStatus())) {
throw new ServiceException("设备未绑定用户,无法下发命令:" + deviceNo); throw new ServiceException("设备未绑定用户,无法下发命令:" + deviceNo);
} }
if (userId != null && !Objects.equals(userId, device.getUserId())) {
throw new ServiceException("设备不存在或无权操作:" + deviceNo);
}
return device; return device;
} }
private void attachCurrentBindingId(DeviceCommand command) {
if (deviceBindingService == null || command == null || StringUtils.isBlank(command.getDeviceNo())) {
return;
}
AppDeviceBinding binding = deviceBindingService.queryExclusiveBinding(command.getDeviceNo());
if (binding != null && DeviceBindingStatus.ACTIVE.equals(binding.getBindingStatus())
&& StringUtils.isNotBlank(binding.getBindingId())) {
command.getPayload().putIfAbsent("bindingId", binding.getBindingId());
}
}
private String buildScheduleTopic(String deviceNo) { private String buildScheduleTopic(String deviceNo) {
return "/" + deviceNo.trim().toLowerCase(Locale.ROOT) + "/subscriber/schedule"; return "/" + deviceNo.trim().toLowerCase(Locale.ROOT) + "/subscriber/schedule";
} }

View File

@@ -0,0 +1,71 @@
package org.dromara.app.service.impl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.domain.mqtt.DeviceCommandAck;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IDeviceCommandLifecycleListener;
import org.dromara.common.core.utils.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* 电量查询命令 ACK 处理器。
* <p>
* 设备收到 {@code queryPower} 命令后,在 ACK 中携带电量({@code powerLevel}
* 此处解析并将电量更新到设备表。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DevicePowerCommandListener implements IDeviceCommandLifecycleListener {
private static final String QUERY_POWER_COMMAND_TYPE = "queryPower";
private final IAppDeviceService appDeviceService;
@Lazy
@Autowired
private IAppDeviceBindingService deviceBindingService;
@Override
public void onCommandAcknowledged(DeviceCommand command, DeviceCommandAck ack) {
if (command == null || !QUERY_POWER_COMMAND_TYPE.equals(command.getCommandType())) {
return;
}
if (ack == null || StringUtils.isBlank(ack.getPowerLevel())) {
log.warn("[命令] 电量查询 ACK 缺少电量 设备编号={} 命令编号={}",
command.getDeviceNo(), command.getCommandId());
return;
}
Object bindingValue = command.getPayload() == null ? null : command.getPayload().get("bindingId");
String bindingId = bindingValue == null ? null : String.valueOf(bindingValue);
if (StringUtils.isNotBlank(bindingId)
&& !deviceBindingService.isCurrentBinding(command.getDeviceNo(), bindingId)) {
log.warn("[命令] 忽略旧绑定的电量查询 ACK 设备编号={} 命令编号={} 绑定标识={}",
command.getDeviceNo(), command.getCommandId(), bindingId);
return;
}
try {
AppDeviceBo bo = new AppDeviceBo();
bo.setDeviceNo(command.getDeviceNo());
bo.setPowerLevel(ack.getPowerLevel());
if (StringUtils.isNotBlank(ack.getCharging())) {
bo.setPowerStatus(ack.getCharging());
}
bo.setPowerLevelUpdatatime(new Date());
appDeviceService.updateByBo(bo);
log.info("[命令] 电量查询结果已更新 设备编号={} 命令编号={} 电量={} 充电状态={}",
command.getDeviceNo(), command.getCommandId(), ack.getPowerLevel(), ack.getCharging());
} catch (RuntimeException e) {
log.error("[命令] 电量查询结果更新失败 设备编号={} 命令编号={} 电量={}",
command.getDeviceNo(), command.getCommandId(), ack.getPowerLevel(), e);
}
}
}

View File

@@ -0,0 +1,28 @@
package org.dromara.app.task;
import lombok.RequiredArgsConstructor;
import org.dromara.app.service.IAppDeviceBindingService;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class DeviceBindingRecoveryTask {
private final IAppDeviceBindingService bindingService;
@Scheduled(fixedDelayString = "${app.device.binding-expiry-scan-interval-ms:10000}")
public void failExpiredBindings() {
bindingService.failExpiredBindings();
}
@Scheduled(fixedDelayString = "${app.device.binding-recovery-interval-ms:20000}")
public void recoverBindingCommands() {
bindingService.recoverBindingCommands();
}
@Scheduled(fixedDelayString = "${app.device.unbinding-recovery-interval-ms:30000}")
public void recoverUnbinding() {
bindingService.recoverUnbinding();
}
}

View File

@@ -9,7 +9,7 @@ import org.dromara.app.domain.AppSchedule;
import org.dromara.app.domain.AppScheduleDetail; import org.dromara.app.domain.AppScheduleDetail;
import org.dromara.app.domain.AppSchedulingDevice; import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppWateringLogBo; import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.mapper.AppDeviceMapper; import org.dromara.app.mapper.AppDeviceBindingMapper;
import org.dromara.app.mapper.AppScheduleDetailMapper; import org.dromara.app.mapper.AppScheduleDetailMapper;
import org.dromara.app.mapper.AppScheduleMapper; import org.dromara.app.mapper.AppScheduleMapper;
import org.dromara.app.mapper.AppSchedulingDeviceMapper; import org.dromara.app.mapper.AppSchedulingDeviceMapper;
@@ -41,7 +41,7 @@ public class OfflineScheduleWateringLogTask {
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
private final AppDeviceMapper deviceMapper; private final AppDeviceBindingMapper deviceBindingMapper;
private final AppSchedulingDeviceMapper schedulingDeviceMapper; private final AppSchedulingDeviceMapper schedulingDeviceMapper;
private final AppScheduleMapper scheduleMapper; private final AppScheduleMapper scheduleMapper;
private final AppScheduleDetailMapper scheduleDetailMapper; private final AppScheduleDetailMapper scheduleDetailMapper;
@@ -59,12 +59,7 @@ public class OfflineScheduleWateringLogTask {
return; return;
} }
List<AppDevice> offlineDevices = deviceMapper.selectList( List<AppDevice> offlineDevices = deviceBindingMapper.selectOfflineActiveDevices();
Wrappers.<AppDevice>lambdaQuery()
.eq(AppDevice::getStatus, "0")
.isNotNull(AppDevice::getUserId)
.select(AppDevice::getDeviceNo, AppDevice::getUserId)
);
if (offlineDevices.isEmpty()) { if (offlineDevices.isEmpty()) {
return; return;
} }

View File

@@ -164,7 +164,7 @@ class AppControllerConcurrencyTest {
detail.setStatus("1"); detail.setStatus("1");
return List.of(detail); return List.of(detail);
}); });
when(deviceCommandService.sendScheduleBindCommand(anyString(), any())).thenAnswer(invocation -> { when(deviceCommandService.sendScheduleBindCommand(anyString(), any(), eq(USER_ID))).thenAnswer(invocation -> {
String deviceNo = invocation.getArgument(0, String.class); String deviceNo = invocation.getArgument(0, String.class);
Map<String, Object> payload = invocation.getArgument(1); Map<String, Object> payload = invocation.getArgument(1);
assertThat(payloadsByDevice.putIfAbsent(deviceNo, payload)).isNull(); assertThat(payloadsByDevice.putIfAbsent(deviceNo, payload)).isNull();
@@ -197,7 +197,7 @@ class AppControllerConcurrencyTest {
assertThat(detail.get("triggerType")).isEqualTo(detailMarker(scheduleId)); assertThat(detail.get("triggerType")).isEqualTo(detailMarker(scheduleId));
assertThat(detail.get("weekday")).isEqualTo((int) ((scheduleId - 1) % 7) + 1); assertThat(detail.get("weekday")).isEqualTo((int) ((scheduleId - 1) % 7) + 1);
} }
verify(deviceCommandService, times(expectedCalls)).sendScheduleBindCommand(anyString(), any()); verify(deviceCommandService, times(expectedCalls)).sendScheduleBindCommand(anyString(), any(), eq(USER_ID));
verify(appScheduleDetailService, times(expectedCalls)).queryByScheduleIdByStatus(anyLong()); verify(appScheduleDetailService, times(expectedCalls)).queryByScheduleIdByStatus(anyLong());
} }

View File

@@ -3,7 +3,10 @@ package org.dromara.app.controller;
import cn.hutool.extra.spring.SpringUtil; import cn.hutool.extra.spring.SpringUtil;
import cn.hutool.json.JSONObject; import cn.hutool.json.JSONObject;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.bo.*; import org.dromara.app.domain.bo.AppForgotPasswordBo;
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.domain.vo.*;
import org.dromara.app.service.*; import org.dromara.app.service.*;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
@@ -273,7 +276,7 @@ public class AppControllerTest {
public void getDeviceInfo_propagatesUnexpectedException() { public void getDeviceInfo_propagatesUnexpectedException() {
AppController controller = newController(); AppController controller = newController();
IllegalStateException failure = new IllegalStateException("database unavailable"); IllegalStateException failure = new IllegalStateException("database unavailable");
when(appDeviceService.queryById("D01")).thenThrow(failure); when(appDeviceService.queryActiveDevice(1L, "D01")).thenThrow(failure);
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.getDeviceInfo("D01"))) assertThatThrownBy(() -> callWithLogin(1L, () -> controller.getDeviceInfo("D01")))
.isSameAs(failure); .isSameAs(failure);
@@ -300,7 +303,7 @@ public class AppControllerTest {
AppController controller = newController(); AppController controller = newController();
AppDeviceVo first = ownedDevice("D01", 99L); AppDeviceVo first = ownedDevice("D01", 99L);
AppDeviceVo second = ownedDevice("D02", 99L); AppDeviceVo second = ownedDevice("D02", 99L);
when(appDeviceService.queryList(any(AppDeviceBo.class))).thenReturn(List.of(first, second)); when(appDeviceService.queryActiveDevices(99L)).thenReturn(List.of(first, second));
when(appSchedulingDeviceService.findBoundDeviceNos(List.of("D01", "D02"))) when(appSchedulingDeviceService.findBoundDeviceNos(List.of("D01", "D02")))
.thenReturn(Set.of("D01")); .thenReturn(Set.of("D01"));
@@ -320,12 +323,11 @@ public class AppControllerTest {
secondBinding.setDeviceNo("D02"); secondBinding.setDeviceNo("D02");
AppDeviceVo firstDevice = ownedDevice("D01", 99L); AppDeviceVo firstDevice = ownedDevice("D01", 99L);
AppDeviceVo secondDevice = ownedDevice("D02", 99L); AppDeviceVo secondDevice = ownedDevice("D02", 99L);
List<AppDeviceVo> queriedDevices = List.of(secondDevice, firstDevice);
when(appScheduleService.queryById(10L)).thenReturn(schedule); when(appScheduleService.queryById(10L)).thenReturn(schedule);
when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of()); when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of());
when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))) when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class)))
.thenReturn(List.of(firstBinding, secondBinding)); .thenReturn(List.of(firstBinding, secondBinding));
when(appDeviceService.queryByDeviceNos(List.of("D01", "D02"))).thenReturn(queriedDevices); when(appDeviceService.queryActiveDevices(99L)).thenReturn(List.of(secondDevice, firstDevice));
R<AppScheduleVo> result = callWithLogin(99L, () -> controller.getScheduleInfo(10L)); R<AppScheduleVo> result = callWithLogin(99L, () -> controller.getScheduleInfo(10L));
@@ -358,16 +360,16 @@ public class AppControllerTest {
detail.setStatus("1"); detail.setStatus("1");
when(appScheduleService.queryById(10L)).thenReturn(schedule); when(appScheduleService.queryById(10L)).thenReturn(schedule);
when(appDeviceService.queryById("D01")).thenReturn(device); when(appDeviceService.queryActiveDevice(userId, "D01")).thenReturn(device);
lenient().when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of(detail)); lenient().when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of(detail));
when(appSchedulingDeviceService.insertByBo(any(AppSchedulingDeviceBo.class))).thenReturn(true); when(appSchedulingDeviceService.insertByBo(any(AppSchedulingDeviceBo.class), eq(userId))).thenReturn(true);
R<Void> result = callWithLogin(userId, R<Void> result = callWithLogin(userId,
() -> controller.addScheduleDevice("{\"scheduleId\":10,\"deviceNos\":[\"D01\"]}")); () -> controller.addScheduleDevice("{\"scheduleId\":10,\"deviceNos\":[\"D01\"]}"));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200); assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), schedulePayloadCaptor.capture()); verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), schedulePayloadCaptor.capture(), eq(userId));
Map<String, Object> payload = schedulePayloadCaptor.getValue(); Map<String, Object> payload = schedulePayloadCaptor.getValue();
assertThat(payload.get("deviceNo")).isEqualTo("D01"); assertThat(payload.get("deviceNo")).isEqualTo("D01");
assertThat(payload).containsKey("details").doesNotContainKeys("cmd", "schedule"); assertThat(payload).containsKey("details").doesNotContainKeys("cmd", "schedule");
@@ -397,15 +399,15 @@ public class AppControllerTest {
AppSchedulingDeviceVo existingBinding = new AppSchedulingDeviceVo(); AppSchedulingDeviceVo existingBinding = new AppSchedulingDeviceVo();
when(appScheduleService.queryById(10L)).thenReturn(schedule); when(appScheduleService.queryById(10L)).thenReturn(schedule);
when(appDeviceService.queryById("D01")).thenReturn(device); when(appDeviceService.queryActiveDevice(userId, "D01")).thenReturn(device);
when(appSchedulingDeviceService.queryByScheduleIdAndDeviceNo(10L, "D01")).thenReturn(existingBinding); when(appSchedulingDeviceService.queryByScheduleIdAndDeviceNo(10L, "D01")).thenReturn(existingBinding);
R<Void> result = callWithLogin(userId, R<Void> result = callWithLogin(userId,
() -> controller.addScheduleDevice("{\"scheduleId\":10,\"deviceNos\":[\"D01\"]}")); () -> controller.addScheduleDevice("{\"scheduleId\":10,\"deviceNos\":[\"D01\"]}"));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200); assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
verify(appSchedulingDeviceService, never()).insertByBo(any(AppSchedulingDeviceBo.class)); verify(appSchedulingDeviceService, never()).insertByBo(any(AppSchedulingDeviceBo.class), anyLong());
verify(deviceCommandService, never()).sendScheduleBindCommand(eq("D01"), any()); verify(deviceCommandService, never()).sendScheduleBindCommand(eq("D01"), any(), anyLong());
} }
@Test @Test
@@ -425,7 +427,7 @@ public class AppControllerTest {
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200); assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
InOrder ordered = inOrder(appScheduleService, deviceCommandService); InOrder ordered = inOrder(appScheduleService, deviceCommandService);
ordered.verify(appScheduleService).deleteWithValidByIds(List.of(10L), true); ordered.verify(appScheduleService).deleteWithValidByIds(List.of(10L), true);
ordered.verify(deviceCommandService).sendScheduleUnbindCommand("D01", 10L); ordered.verify(deviceCommandService).sendScheduleUnbindCommand("D01", 10L, userId);
} }
@Test @Test
@@ -443,7 +445,7 @@ public class AppControllerTest {
R<Void> result = callWithLogin(userId, () -> controller.removeSchedule(new Long[]{10L})); R<Void> result = callWithLogin(userId, () -> controller.removeSchedule(new Long[]{10L}));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(500); assertThat(result.getCode()).as(result.getMsg()).isEqualTo(500);
verify(deviceCommandService, never()).sendScheduleUnbindCommand("D01", 10L); verify(deviceCommandService, never()).sendScheduleUnbindCommand("D01", 10L, userId);
} }
@Test @Test
@@ -464,8 +466,8 @@ public class AppControllerTest {
R<Void> result = callWithLogin(userId, () -> controller.editScheduleStatus(bo)); R<Void> result = callWithLogin(userId, () -> controller.editScheduleStatus(bo));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200); assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
verify(deviceCommandService).sendScheduleCanceledCommand("D01", 10L); verify(deviceCommandService).sendScheduleCanceledCommand("D01", 10L, userId);
verify(deviceCommandService, never()).sendScheduleBindCommand(eq("D01"), any()); verify(deviceCommandService, never()).sendScheduleBindCommand(eq("D01"), any(), anyLong());
} }
@Test @Test
@@ -492,8 +494,8 @@ public class AppControllerTest {
R<Void> result = callWithLogin(userId, () -> controller.editScheduleStatus(bo)); R<Void> result = callWithLogin(userId, () -> controller.editScheduleStatus(bo));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200); assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), any()); verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), any(), eq(userId));
verify(deviceCommandService, never()).sendScheduleCanceledCommand("D01", 10L); verify(deviceCommandService, never()).sendScheduleCanceledCommand("D01", 10L, userId);
} }
@Test @Test
@@ -516,7 +518,7 @@ public class AppControllerTest {
callWithLogin(99L, () -> controller.editScheduleStatus(bo)); callWithLogin(99L, () -> controller.editScheduleStatus(bo));
verify(appScheduleDetailService, times(1)).queryByScheduleIdByStatus(10L); verify(appScheduleDetailService, times(1)).queryByScheduleIdByStatus(10L);
verify(deviceCommandService, times(2)).sendScheduleBindCommand(anyString(), any()); verify(deviceCommandService, times(2)).sendScheduleBindCommand(anyString(), any(), eq(99L));
} }
@Test @Test
@@ -537,7 +539,7 @@ public class AppControllerTest {
latest.setStartTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2026-07-06 08:30:00")); 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")); latest.setEndTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse("2026-07-06 08:40:00"));
when(appDeviceService.queryById("D01")).thenReturn(device); when(appDeviceService.queryActiveDevice(userId, "D01")).thenReturn(device);
when(appWateringLogService.queryList(any(AppWateringLogBo.class))).thenReturn(List.of(laterStartTime, latest)); when(appWateringLogService.queryList(any(AppWateringLogBo.class))).thenReturn(List.of(laterStartTime, latest));
R<Map<String, Object>> result = callWithLogin(userId, () -> controller.latestWaterLog("D01")); R<Map<String, Object>> result = callWithLogin(userId, () -> controller.latestWaterLog("D01"));

View File

@@ -3,7 +3,9 @@ package org.dromara.app.handler;
import cn.hutool.extra.spring.SpringUtil; import cn.hutool.extra.spring.SpringUtil;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IAppDeviceService; import org.dromara.app.service.IAppDeviceService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -12,12 +14,12 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext; import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.function.Supplier; import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.*;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@Tag("dev") @Tag("dev")
@@ -26,6 +28,9 @@ class DeviceDataHandlerTest {
@Mock @Mock
private IAppDeviceService appDeviceService; private IAppDeviceService appDeviceService;
@Mock
private IAppDeviceBindingService deviceBindingService;
@Mock @Mock
private DeviceIdentityResolver deviceIdentityResolver; private DeviceIdentityResolver deviceIdentityResolver;
@@ -35,12 +40,19 @@ class DeviceDataHandlerTest {
@InjectMocks @InjectMocks
private DeviceDataHandler handler; private DeviceDataHandler handler;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(handler, "deviceBindingService", deviceBindingService);
}
@Test @Test
void handleMarksDeviceOnlineAndUpdatesPowerLevel() { void handleMarksDeviceOnlineAndUpdatesPowerLevel() {
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01"); when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(deviceBindingService.isActive("D01")).thenReturn(true);
when(deviceBindingService.isCurrentBinding("D01", "binding-1")).thenReturn(true);
withJsonContext(() -> { withJsonContext(() -> {
handler.handle("D01", "{\"powerLevel\":\"86\"}"); handler.handle("D01", "{\"powerLevel\":\"86\",\"bindingId\":\"binding-1\"}");
return null; return null;
}); });
@@ -53,6 +65,31 @@ class DeviceDataHandlerTest {
verify(deviceStatusService).markOnline("D01"); verify(deviceStatusService).markOnline("D01");
} }
@Test
void handleIgnoresPowerReportWhenBindingIsNotActive() {
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(deviceBindingService.isActive("D01")).thenReturn(false);
handler.handle("D01", "{\"powerLevel\":\"86\"}");
verifyNoInteractions(appDeviceService, deviceStatusService);
}
@Test
void handleIgnoresReportFromPreviousBinding() {
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(deviceBindingService.isActive("D01")).thenReturn(true);
when(deviceBindingService.isCurrentBinding("D01", "old-binding")).thenReturn(false);
withJsonContext(() -> {
handler.handle("D01", "{\"powerLevel\":\"86\",\"bindingId\":\"old-binding\"}");
return null;
});
verify(deviceBindingService).isCurrentBinding("D01", "old-binding");
verifyNoInteractions(appDeviceService, deviceStatusService);
}
private <T> T withJsonContext(Supplier<T> action) { private <T> T withJsonContext(Supplier<T> action) {
try (GenericApplicationContext applicationContext = new GenericApplicationContext()) { try (GenericApplicationContext applicationContext = new GenericApplicationContext()) {
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new); applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);

View File

@@ -22,8 +22,7 @@ import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.*;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@Tag("dev") @Tag("dev")
@@ -35,6 +34,7 @@ class DeviceRegisterHandlerTest {
@Mock private IDeviceCommandPublisher commandPublisher; @Mock private IDeviceCommandPublisher commandPublisher;
@Mock private DeviceIdentityResolver deviceIdentityResolver; @Mock private DeviceIdentityResolver deviceIdentityResolver;
@Mock private MqttDeviceStatusService deviceStatusService; @Mock private MqttDeviceStatusService deviceStatusService;
@Mock private DeviceRegisterReplyGuard registerReplyGuard;
@Test @Test
void firstRegistrationReplyStillUsesMacTopic() throws Exception { void firstRegistrationReplyStillUsesMacTopic() throws Exception {
@@ -43,7 +43,8 @@ class DeviceRegisterHandlerTest {
appDeviceMapper, appDeviceMapper,
commandPublisherProvider, commandPublisherProvider,
deviceIdentityResolver, deviceIdentityResolver,
deviceStatusService deviceStatusService,
registerReplyGuard
); );
when(commandPublisherProvider.getIfAvailable()).thenReturn(commandPublisher); when(commandPublisherProvider.getIfAvailable()).thenReturn(commandPublisher);
when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1"); when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1");
@@ -69,13 +70,13 @@ class DeviceRegisterHandlerTest {
appDeviceMapper, appDeviceMapper,
commandPublisherProvider, commandPublisherProvider,
deviceIdentityResolver, deviceIdentityResolver,
deviceStatusService deviceStatusService,
registerReplyGuard
); );
AppDevice existing = new AppDevice(); AppDevice existing = new AppDevice();
existing.setDeviceNo("2075423638947475457"); existing.setDeviceNo("2075423638947475457");
existing.setMacAddress("aa:bb:cc"); existing.setMacAddress("aa:bb:cc");
when(deviceIdentityResolver.resolve("2075423638947475457")).thenReturn(existing); when(deviceIdentityResolver.resolve("2075423638947475457")).thenReturn(existing);
when(commandPublisherProvider.getIfAvailable()).thenReturn(null);
withJsonContext(() -> { withJsonContext(() -> {
handler.handle("2075423638947475457", "{}"); handler.handle("2075423638947475457", "{}");
@@ -86,7 +87,62 @@ class DeviceRegisterHandlerTest {
verify(appDeviceService).registerByMqtt(captor.capture()); verify(appDeviceService).registerByMqtt(captor.capture());
assertThat(captor.getValue().getDeviceNo()).isEqualTo("2075423638947475457"); assertThat(captor.getValue().getDeviceNo()).isEqualTo("2075423638947475457");
assertThat(captor.getValue().getMacAddress()).isEqualTo("aa:bb:cc"); assertThat(captor.getValue().getMacAddress()).isEqualTo("aa:bb:cc");
verify(deviceStatusService).markOnline("2075423638947475457"); verify(deviceStatusService).markRegisteredOnline("2075423638947475457");
}
@Test
void duplicateRegisterOnlySendsDeviceNoOnce() {
DeviceRegisterHandler handler = new DeviceRegisterHandler(
appDeviceService,
appDeviceMapper,
commandPublisherProvider,
deviceIdentityResolver,
deviceStatusService,
registerReplyGuard
);
AppDevice existing = new AppDevice();
existing.setDeviceNo("D01");
existing.setMacAddress("aa:bb:cc");
when(deviceIdentityResolver.resolve("aa:bb:cc")).thenReturn(existing);
when(commandPublisherProvider.getIfAvailable()).thenReturn(commandPublisher);
when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1");
when(registerReplyGuard.tryAcquire("D01")).thenReturn(true, false);
withJsonContext(() -> {
handler.handle("aa:bb:cc", "{}");
handler.handle("aa:bb:cc", "{}");
return null;
});
verify(appDeviceService, times(2)).registerByMqtt(any(AppDeviceBo.class));
verify(deviceStatusService, times(2)).markRegisteredOnline("D01");
verify(commandPublisher).send(any(DeviceCommand.class));
}
@Test
void failedDeviceNoSendReleasesDedupGuard() {
DeviceRegisterHandler handler = new DeviceRegisterHandler(
appDeviceService,
appDeviceMapper,
commandPublisherProvider,
deviceIdentityResolver,
deviceStatusService,
registerReplyGuard
);
AppDevice existing = new AppDevice();
existing.setDeviceNo("D01");
existing.setMacAddress("aa:bb:cc");
when(deviceIdentityResolver.resolve("aa:bb:cc")).thenReturn(existing);
when(registerReplyGuard.tryAcquire("D01")).thenReturn(true);
when(commandPublisherProvider.getIfAvailable()).thenReturn(commandPublisher);
when(commandPublisher.send(any(DeviceCommand.class))).thenThrow(new IllegalStateException("publish failed"));
withJsonContext(() -> {
handler.handle("aa:bb:cc", "{}");
return null;
});
verify(registerReplyGuard).release("D01");
} }
private <T> T withJsonContext(Supplier<T> action) { private <T> T withJsonContext(Supplier<T> action) {

View File

@@ -0,0 +1,43 @@
package org.dromara.app.handler;
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 org.redisson.api.RBucket;
import org.redisson.api.RedissonClient;
import java.time.Duration;
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 DeviceRegisterReplyGuardTest {
@Mock private RedissonClient redissonClient;
@Mock private RBucket<String> bucket;
@Test
void acquireUsesAtomicExpiringRedisMarker() {
when(redissonClient.<String>getBucket("mqtt:register-device-no:sent:D01")).thenReturn(bucket);
when(bucket.setIfAbsent("sent", Duration.ofSeconds(30))).thenReturn(true, false);
DeviceRegisterReplyGuard guard = new DeviceRegisterReplyGuard(redissonClient, 30);
assertThat(guard.tryAcquire("D01")).isTrue();
assertThat(guard.tryAcquire("D01")).isFalse();
}
@Test
void releaseDeletesMarkerSoRegistrationCanRetry() {
when(redissonClient.<String>getBucket("mqtt:register-device-no:sent:D01")).thenReturn(bucket);
DeviceRegisterReplyGuard guard = new DeviceRegisterReplyGuard(redissonClient, 30);
guard.release("D01");
verify(bucket).delete();
}
}

View File

@@ -4,8 +4,8 @@ import cn.hutool.extra.spring.SpringUtil;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo; import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper; import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.service.IAppDeviceBindingService;
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.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
@@ -15,6 +15,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext; import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.function.Supplier; import java.util.function.Supplier;
@@ -30,6 +31,7 @@ class KeyFinishHandlerTest {
@Mock private IAppDeviceService appDeviceService; @Mock private IAppDeviceService appDeviceService;
@Mock private AppSchedulingDeviceMapper schedulingDeviceMapper; @Mock private AppSchedulingDeviceMapper schedulingDeviceMapper;
@Mock private DeviceIdentityResolver deviceIdentityResolver; @Mock private DeviceIdentityResolver deviceIdentityResolver;
@Mock private IAppDeviceBindingService deviceBindingService;
@Test @Test
void handle_setsDeviceWorkStatusIdleWhenKeyWateringFinished() { void handle_setsDeviceWorkStatusIdleWhenKeyWateringFinished() {
@@ -39,14 +41,15 @@ class KeyFinishHandlerTest {
schedulingDeviceMapper, schedulingDeviceMapper,
deviceIdentityResolver deviceIdentityResolver
); );
AppDeviceVo device = new AppDeviceVo(); ReflectionTestUtils.setField(handler, "deviceBindingService", deviceBindingService);
device.setUserId(99L);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01"); when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device); when(deviceBindingService.isActive("D01")).thenReturn(true);
when(deviceBindingService.queryActiveUserId("D01")).thenReturn(99L);
when(deviceBindingService.isCurrentBinding("D01", "binding-1")).thenReturn(true);
withJsonContext(() -> { withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"durationMin\":10}"); handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"durationMin\":10,\"bindingId\":\"binding-1\"}");
return null; return null;
}); });

View File

@@ -7,6 +7,7 @@ import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration; import org.apache.ibatis.session.Configuration;
import org.dromara.app.domain.AppDevice; import org.dromara.app.domain.AppDevice;
import org.dromara.app.mapper.AppDeviceMapper; import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IAppWateringLogService; import org.dromara.app.service.IAppWateringLogService;
import org.dromara.common.redis.utils.RedisUtils; import org.dromara.common.redis.utils.RedisUtils;
import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterAll;
@@ -41,6 +42,8 @@ class MqttDeviceStatusServiceTest {
@Mock @Mock
private IAppWateringLogService wateringLogService; private IAppWateringLogService wateringLogService;
@Mock @Mock
private IAppDeviceBindingService deviceBindingService;
@Mock
private RedissonClient redissonClient; private RedissonClient redissonClient;
@Mock @Mock
private RLock lock; private RLock lock;
@@ -127,8 +130,10 @@ class MqttDeviceStatusServiceTest {
private MqttDeviceStatusService newService() { private MqttDeviceStatusService newService() {
MqttDeviceStatusService service = new MqttDeviceStatusService(appDeviceMapper, wateringLogService); MqttDeviceStatusService service = new MqttDeviceStatusService(appDeviceMapper, wateringLogService);
ReflectionTestUtils.setField(service, "deviceBindingService", deviceBindingService);
ReflectionTestUtils.setField(service, "deviceStatusCachePrefix", "mqtt:device:status:"); ReflectionTestUtils.setField(service, "deviceStatusCachePrefix", "mqtt:device:status:");
ReflectionTestUtils.setField(service, "deviceStatusCacheTtlSeconds", 600L); ReflectionTestUtils.setField(service, "deviceStatusCacheTtlSeconds", 600L);
when(deviceBindingService.isActive("D01")).thenReturn(true);
return service; return service;
} }
} }

View File

@@ -5,8 +5,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.AppSchedulingDevice; import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo; import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper; import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.service.IAppDeviceBindingService;
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.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
@@ -16,6 +16,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext; import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Calendar; import java.util.Calendar;
import java.util.List; import java.util.List;
@@ -34,6 +35,7 @@ class ScheduleFinishHandlerTest {
@Mock private IAppDeviceService appDeviceService; @Mock private IAppDeviceService appDeviceService;
@Mock private AppSchedulingDeviceMapper schedulingDeviceMapper; @Mock private AppSchedulingDeviceMapper schedulingDeviceMapper;
@Mock private DeviceIdentityResolver deviceIdentityResolver; @Mock private DeviceIdentityResolver deviceIdentityResolver;
@Mock private IAppDeviceBindingService deviceBindingService;
@Test @Test
void handle_setsDeviceWorkStatusIdleWhenScheduleWateringFinished() { void handle_setsDeviceWorkStatusIdleWhenScheduleWateringFinished() {
@@ -43,18 +45,16 @@ class ScheduleFinishHandlerTest {
schedulingDeviceMapper, schedulingDeviceMapper,
deviceIdentityResolver deviceIdentityResolver
); );
AppDeviceVo device = new AppDeviceVo(); activate(handler);
device.setUserId(99L);
AppSchedulingDevice binding = new AppSchedulingDevice(); AppSchedulingDevice binding = new AppSchedulingDevice();
binding.setDeviceNo("D01"); binding.setDeviceNo("D01");
binding.setScheduleId(10L); binding.setScheduleId(10L);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01"); when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
when(schedulingDeviceMapper.selectList(any())).thenReturn(List.of(binding)); when(schedulingDeviceMapper.selectList(any())).thenReturn(List.of(binding));
withJsonContext(() -> { withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"durationMin\":10}"); handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"durationMin\":10,\"bindingId\":\"binding-1\"}");
return null; return null;
}); });
@@ -77,18 +77,16 @@ class ScheduleFinishHandlerTest {
schedulingDeviceMapper, schedulingDeviceMapper,
deviceIdentityResolver deviceIdentityResolver
); );
AppDeviceVo device = new AppDeviceVo(); activate(handler);
device.setUserId(99L);
AppSchedulingDevice binding = new AppSchedulingDevice(); AppSchedulingDevice binding = new AppSchedulingDevice();
binding.setDeviceNo("D01"); binding.setDeviceNo("D01");
binding.setScheduleId(10L); binding.setScheduleId(10L);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01"); when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
when(schedulingDeviceMapper.selectList(any())).thenReturn(List.of(binding)); when(schedulingDeviceMapper.selectList(any())).thenReturn(List.of(binding));
withJsonContext(() -> { withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"08:30:27\",\"durationMin\":10}"); handler.handle("D01", "{\"startTime\":\"08:30:27\",\"durationMin\":10,\"bindingId\":\"binding-1\"}");
return null; return null;
}); });
@@ -112,4 +110,11 @@ class ScheduleFinishHandlerTest {
calendar.setTime(date); calendar.setTime(date);
return calendar.get(Calendar.SECOND); return calendar.get(Calendar.SECOND);
} }
private void activate(ScheduleFinishHandler handler) {
ReflectionTestUtils.setField(handler, "deviceBindingService", deviceBindingService);
when(deviceBindingService.isActive("D01")).thenReturn(true);
when(deviceBindingService.queryActiveUserId("D01")).thenReturn(99L);
when(deviceBindingService.isCurrentBinding("D01", "binding-1")).thenReturn(true);
}
} }

View File

@@ -5,9 +5,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.AppSchedulingDevice; import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo; 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.domain.vo.AppWateringLogVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper; import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.service.IAppDeviceBindingService;
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.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
@@ -17,6 +17,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext; import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.List; import java.util.List;
@@ -34,6 +35,7 @@ class StartWaterHandlerTest {
@Mock private IAppDeviceService appDeviceService; @Mock private IAppDeviceService appDeviceService;
@Mock private AppSchedulingDeviceMapper schedulingDeviceMapper; @Mock private AppSchedulingDeviceMapper schedulingDeviceMapper;
@Mock private DeviceIdentityResolver deviceIdentityResolver; @Mock private DeviceIdentityResolver deviceIdentityResolver;
@Mock private IAppDeviceBindingService deviceBindingService;
@Test @Test
void handle_insertsRunningScheduleWateringLog() throws Exception { void handle_insertsRunningScheduleWateringLog() throws Exception {
@@ -43,18 +45,16 @@ class StartWaterHandlerTest {
schedulingDeviceMapper, schedulingDeviceMapper,
deviceIdentityResolver deviceIdentityResolver
); );
AppDeviceVo device = new AppDeviceVo(); activate(handler);
device.setUserId(99L);
AppSchedulingDevice binding = new AppSchedulingDevice(); AppSchedulingDevice binding = new AppSchedulingDevice();
binding.setDeviceNo("D01"); binding.setDeviceNo("D01");
binding.setScheduleId(10L); binding.setScheduleId(10L);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01"); when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
when(schedulingDeviceMapper.selectList(any())).thenReturn(List.of(binding)); when(schedulingDeviceMapper.selectList(any())).thenReturn(List.of(binding));
withJsonContext(() -> { withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"triggerON\":\"schedule\"}"); handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"triggerON\":\"schedule\",\"bindingId\":\"binding-1\"}");
return null; return null;
}); });
@@ -86,8 +86,7 @@ class StartWaterHandlerTest {
schedulingDeviceMapper, schedulingDeviceMapper,
deviceIdentityResolver deviceIdentityResolver
); );
AppDeviceVo device = new AppDeviceVo(); activate(handler);
device.setUserId(99L);
AppWateringLogVo activeLog = new AppWateringLogVo(); AppWateringLogVo activeLog = new AppWateringLogVo();
activeLog.setDeviceNo("D01"); activeLog.setDeviceNo("D01");
activeLog.setUserId(99L); activeLog.setUserId(99L);
@@ -96,11 +95,10 @@ class StartWaterHandlerTest {
activeLog.setEndTime(null); activeLog.setEndTime(null);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01"); when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
lenient().when(appWateringLogService.queryList(any(AppWateringLogBo.class))).thenReturn(List.of(activeLog)); lenient().when(appWateringLogService.queryList(any(AppWateringLogBo.class))).thenReturn(List.of(activeLog));
withJsonContext(() -> { withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"triggerON\":\"manual\"}"); handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"triggerON\":\"manual\",\"bindingId\":\"binding-1\"}");
return null; return null;
}); });
@@ -119,8 +117,7 @@ class StartWaterHandlerTest {
schedulingDeviceMapper, schedulingDeviceMapper,
deviceIdentityResolver deviceIdentityResolver
); );
AppDeviceVo device = new AppDeviceVo(); activate(handler);
device.setUserId(99L);
AppWateringLogVo activeLog = new AppWateringLogVo(); AppWateringLogVo activeLog = new AppWateringLogVo();
activeLog.setDeviceNo("D01"); activeLog.setDeviceNo("D01");
activeLog.setUserId(99L); activeLog.setUserId(99L);
@@ -131,11 +128,10 @@ class StartWaterHandlerTest {
activeLog.setEndTime(null); activeLog.setEndTime(null);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01"); when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
lenient().when(appWateringLogService.queryList(any(AppWateringLogBo.class))).thenReturn(List.of(activeLog)); lenient().when(appWateringLogService.queryList(any(AppWateringLogBo.class))).thenReturn(List.of(activeLog));
withJsonContext(() -> { withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:27\",\"triggerON\":\"manual\"}"); handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:27\",\"triggerON\":\"manual\",\"bindingId\":\"binding-1\"}");
return null; return null;
}); });
@@ -154,15 +150,13 @@ class StartWaterHandlerTest {
schedulingDeviceMapper, schedulingDeviceMapper,
deviceIdentityResolver deviceIdentityResolver
); );
AppDeviceVo device = new AppDeviceVo(); activate(handler);
device.setUserId(99L);
String today = new SimpleDateFormat("yyyy-MM-dd").format(new java.util.Date()); String today = new SimpleDateFormat("yyyy-MM-dd").format(new java.util.Date());
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01"); when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
withJsonContext(() -> { withJsonContext(() -> {
handler.handle("D01", "{\"deviceNo\":\"D01\",\"startTime\":\"16:39\",\"durationMin\":10,\"triggerON\":\"manual\"}"); handler.handle("D01", "{\"deviceNo\":\"D01\",\"startTime\":\"16:39\",\"durationMin\":10,\"triggerON\":\"manual\",\"bindingId\":\"binding-1\"}");
return null; return null;
}); });
@@ -190,4 +184,11 @@ class StartWaterHandlerTest {
return action.get(); return action.get();
} }
} }
private void activate(StartWaterHandler handler) {
ReflectionTestUtils.setField(handler, "deviceBindingService", deviceBindingService);
when(deviceBindingService.isActive("D01")).thenReturn(true);
when(deviceBindingService.queryActiveUserId("D01")).thenReturn(99L);
when(deviceBindingService.isCurrentBinding("D01", "binding-1")).thenReturn(true);
}
} }

View File

@@ -0,0 +1,246 @@
package org.dromara.app.service.impl;
import com.baomidou.lock.annotation.Lock4j;
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.*;
import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.domain.mqtt.DeviceCommandAck;
import org.dromara.app.domain.mqtt.DeviceUnbindRequest;
import org.dromara.app.domain.mqtt.DeviceUnbindResult;
import org.dromara.app.mapper.*;
import org.dromara.app.service.IDeviceCommandAckHandler;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.app.service.IDeviceUnbindResultPublisher;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
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.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.Date;
import java.util.List;
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 AppDeviceBindingServiceImplTest {
@Mock private AppDeviceBindingMapper bindingMapper;
@Mock private AppDeviceUnbindCommandMapper unbindCommandMapper;
@Mock private AppDeviceMapper deviceMapper;
@Mock private AppSchedulingDeviceMapper schedulingDeviceMapper;
@Mock private AppWateringLogMapper wateringLogMapper;
@Mock private PlatformTransactionManager transactionManager;
@Mock private TransactionStatus transactionStatus;
@Mock private IDeviceCommandService deviceCommandService;
@Mock private IDeviceCommandAckHandler deviceCommandAckHandler;
@Mock private IDeviceUnbindResultPublisher resultPublisher;
private AppDeviceBindingServiceImpl service;
@BeforeAll
static void initializeMybatisMetadata() {
initializeTableInfo(AppDeviceBinding.class);
initializeTableInfo(AppDeviceUnbindCommand.class);
initializeTableInfo(AppDevice.class);
initializeTableInfo(AppSchedulingDevice.class);
initializeTableInfo(AppWateringLog.class);
}
private static void initializeTableInfo(Class<?> entityType) {
if (TableInfoHelper.getTableInfo(entityType) == null) {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), ""), entityType);
}
}
@BeforeEach
void setUp() {
service = new AppDeviceBindingServiceImpl(
bindingMapper,
unbindCommandMapper,
deviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
transactionManager
);
ReflectionTestUtils.setField(service, "self", service);
ReflectionTestUtils.setField(service, "deviceCommandService", deviceCommandService);
ReflectionTestUtils.setField(service, "deviceCommandAckHandler", deviceCommandAckHandler);
ReflectionTestUtils.setField(service, "unbindResultPublisher", resultPublisher);
ReflectionTestUtils.setField(service, "deviceStatusCachePrefix", "");
ReflectionTestUtils.setField(service, "bindingTimeoutMs", 60_000L);
ReflectionTestUtils.setField(service, "bindingRecoveryIntervalMs", 20_000L);
ReflectionTestUtils.setField(service, "unbindingAlertAttempts", 5);
lenient().when(transactionManager.getTransaction(any())).thenReturn(transactionStatus);
}
@Test
void lifecycleTransactionsAlwaysUseRequiresNew() {
TransactionTemplate template = (TransactionTemplate) ReflectionTestUtils.getField(service, "transactionTemplate");
assertThat(template).isNotNull();
assertThat(template.getPropagationBehavior()).isEqualTo(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
@Test
void recoverBindingCommandsSendsNeverIssuedMigrationBinding() {
AppDeviceBinding binding = binding(1L, DeviceBindingStatus.BINDING, null);
binding.setBindingStartedTime(new Date(0));
binding.setLastBindCommandTime(null);
when(bindingMapper.selectList(any())).thenReturn(List.of(binding));
service.recoverBindingCommands();
verify(deviceCommandService).sendBindDeviceCommand("D01", "binding-1");
verify(bindingMapper).update(eq(null), any());
}
@Test
void recoverOneUnbindingUsesDeviceCommandLock() throws NoSuchMethodException {
Lock4j lock = AppDeviceBindingServiceImpl.class
.getMethod("recoverOneUnbinding", String.class)
.getAnnotation(Lock4j.class);
assertThat(lock).isNotNull();
assertThat(lock.keys()).containsExactly("'device:command:' + #deviceNo");
}
@Test
void recoverOneUnbindingInitializesServerUnbindBeforeReturning() {
AppDeviceBinding unbinding = binding(1L, DeviceBindingStatus.UNBINDING, DeviceUnbindSource.APP);
AppDeviceBinding unbound = binding(1L, DeviceBindingStatus.UNBOUND, DeviceUnbindSource.APP);
when(bindingMapper.selectExclusiveByDeviceNo("D01")).thenReturn(unbinding);
when(bindingMapper.selectById(1L)).thenReturn(unbinding, unbound);
service.recoverOneUnbinding("D01");
verify(deviceCommandService).sendInitDeviceCommand("D01");
}
@Test
void unbindByDeviceCleansCurrentUserDataWithoutSendingInitCommand() {
DeviceUnbindRequest request = request("command-1", "binding-1");
AppDeviceBinding active = binding(1L, DeviceBindingStatus.ACTIVE, DeviceUnbindSource.DEVICE);
AppDeviceBinding unbinding = binding(1L, DeviceBindingStatus.UNBINDING, DeviceUnbindSource.DEVICE);
unbinding.setUnbindCommandId("command-1");
AppDeviceBinding unbound = binding(1L, DeviceBindingStatus.UNBOUND, DeviceUnbindSource.DEVICE);
unbound.setUnbindCommandId("command-1");
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
device.setDeviceName("Garden");
when(unbindCommandMapper.selectByDeviceAndCommandId("D01", "command-1")).thenReturn(null);
when(bindingMapper.selectByDeviceAndBindingId("D01", "binding-1")).thenReturn(active);
when(deviceMapper.selectById("D01")).thenReturn(device);
when(bindingMapper.update(eq(null), any(Wrapper.class))).thenReturn(1);
when(bindingMapper.selectById(1L)).thenReturn(unbinding, unbinding, unbound);
DeviceUnbindResult result = service.unbindByDevice("D01", request);
assertThat(result.status()).isEqualTo(DeviceUnbindResult.APPLIED);
assertThat(result.commandId()).isEqualTo("command-1");
verify(deviceCommandAckHandler).clearPendingCommands(List.of("D01"));
verify(schedulingDeviceMapper).delete(any(Wrapper.class));
verify(wateringLogMapper).delete(any(Wrapper.class));
verify(deviceCommandService, never()).sendInitDeviceCommand(anyString());
ArgumentCaptor<AppDeviceUnbindCommand> commandCaptor = ArgumentCaptor.forClass(AppDeviceUnbindCommand.class);
verify(unbindCommandMapper).insert(commandCaptor.capture());
assertThat(commandCaptor.getValue().getCommandStatus()).isEqualTo(DeviceUnbindCommandStatus.PROCESSING);
}
@Test
void unbindByDeviceReturnsAlreadyAppliedForDuplicateCommandId() {
AppDeviceUnbindCommand command = new AppDeviceUnbindCommand();
command.setDeviceNo("D01");
command.setCommandId("command-1");
command.setBindingId("binding-1");
command.setCommandStatus(DeviceUnbindCommandStatus.APPLIED);
when(unbindCommandMapper.selectByDeviceAndCommandId("D01", "command-1")).thenReturn(command);
DeviceUnbindResult result = service.unbindByDevice("D01", request("command-1", "binding-1"));
assertThat(result.status()).isEqualTo(DeviceUnbindResult.ALREADY_APPLIED);
verifyNoInteractions(bindingMapper, deviceCommandAckHandler);
}
@Test
void unbindByDeviceRejectsStaleBindingId() {
when(unbindCommandMapper.selectByDeviceAndCommandId("D01", "command-1")).thenReturn(null);
when(bindingMapper.selectByDeviceAndBindingId("D01", "old-binding")).thenReturn(null);
DeviceUnbindResult result = service.unbindByDevice("D01", request("command-1", "old-binding"));
assertThat(result.status()).isEqualTo(DeviceUnbindResult.STALE_BINDING);
ArgumentCaptor<AppDeviceUnbindCommand> commandCaptor = ArgumentCaptor.forClass(AppDeviceUnbindCommand.class);
verify(unbindCommandMapper).insert(commandCaptor.capture());
assertThat(commandCaptor.getValue().getCommandStatus()).isEqualTo(DeviceUnbindCommandStatus.STALE);
verifyNoInteractions(deviceCommandAckHandler);
}
@Test
void lateBindAckCannotActivateStaleBinding() {
DeviceCommand command = bindCommand("old-binding");
DeviceCommandAck ack = new DeviceCommandAck();
ack.setStatus("success");
when(bindingMapper.update(eq(null), any(Wrapper.class))).thenReturn(0);
service.onCommandAcknowledged(command, ack);
verify(deviceMapper, never()).update(eq(null), any(Wrapper.class));
}
@Test
void bindAckActivatesMatchingBinding() {
DeviceCommand command = bindCommand("binding-1");
DeviceCommandAck ack = new DeviceCommandAck();
ack.setStatus("applied");
when(bindingMapper.update(eq(null), any(Wrapper.class))).thenReturn(1);
service.onCommandAcknowledged(command, ack);
verify(deviceMapper).update(eq(null), any(Wrapper.class));
}
private DeviceUnbindRequest request(String commandId, String bindingId) {
DeviceUnbindRequest request = new DeviceUnbindRequest();
request.setCommandId(commandId);
request.setBindingId(bindingId);
request.setReason("long_press");
return request;
}
private AppDeviceBinding binding(Long id, String status, String source) {
AppDeviceBinding binding = new AppDeviceBinding();
binding.setId(id);
binding.setDeviceNo("D01");
binding.setUserId(100L);
binding.setBindingId("binding-1");
binding.setBindingStatus(status);
binding.setUnbindSource(source);
binding.setCleanupAttempts(0);
return binding;
}
private DeviceCommand bindCommand(String bindingId) {
DeviceCommand command = new DeviceCommand();
command.setDeviceNo("D01");
command.setCommandType("bindDevice");
command.getPayload().put("bindingId", bindingId);
return command;
}
}

View File

@@ -2,13 +2,19 @@ package org.dromara.app.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.Wrapper;
import org.dromara.app.domain.AppDevice; import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.AppDeviceBinding;
import org.dromara.app.domain.DeviceBindingStatus;
import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.vo.AppDeviceVo; import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppDeviceMapper; import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.mapper.AppSchedulingDeviceMapper; import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mapper.AppWateringLogMapper; import org.dromara.app.mapper.AppWateringLogMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IAppWateringLogService; import org.dromara.app.service.IAppWateringLogService;
import org.dromara.app.service.IDeviceCommandAckHandler;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.common.core.enums.UserType; import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.common.satoken.utils.LoginHelper;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -16,6 +22,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
@@ -24,6 +31,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -39,6 +47,12 @@ class AppDeviceServiceImplTest {
private AppWateringLogMapper wateringLogMapper; private AppWateringLogMapper wateringLogMapper;
@Mock @Mock
private IAppWateringLogService wateringLogService; private IAppWateringLogService wateringLogService;
@Mock
private IDeviceCommandService deviceCommandService;
@Mock
private IDeviceCommandAckHandler deviceCommandAckHandler;
@Mock
private IAppDeviceBindingService deviceBindingService;
@Test @Test
void renderQrCodeImage_addsDeviceNoBelowQrCode() throws Exception { void renderQrCodeImage_addsDeviceNoBelowQrCode() throws Exception {
@@ -53,12 +67,10 @@ class AppDeviceServiceImplTest {
@Test @Test
void deleteWithValidByIds_allowsSuperAdminToDeleteDeviceOwnedByAnotherUser() { void deleteWithValidByIds_allowsSuperAdminToDeleteDeviceOwnedByAnotherUser() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl( AppDeviceServiceImpl service = newService();
appDeviceMapper, AppDevice device = new AppDevice();
schedulingDeviceMapper, device.setDeviceNo("D01");
wateringLogMapper, when(appDeviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(device));
wateringLogService
);
when(appDeviceMapper.delete(any(Wrapper.class))).thenReturn(1); when(appDeviceMapper.delete(any(Wrapper.class))).thenReturn(1);
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) { try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
@@ -74,12 +86,10 @@ class AppDeviceServiceImplTest {
@Test @Test
void deleteWithValidByIds_allowsAuthorizedSystemUserToDeleteDeviceOwnedByAnotherUser() { void deleteWithValidByIds_allowsAuthorizedSystemUserToDeleteDeviceOwnedByAnotherUser() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl( AppDeviceServiceImpl service = newService();
appDeviceMapper, AppDevice device = new AppDevice();
schedulingDeviceMapper, device.setDeviceNo("D01");
wateringLogMapper, when(appDeviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(device));
wateringLogService
);
when(appDeviceMapper.delete(any(Wrapper.class))).thenReturn(1); when(appDeviceMapper.delete(any(Wrapper.class))).thenReturn(1);
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) { try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
@@ -97,18 +107,14 @@ class AppDeviceServiceImplTest {
@Test @Test
void bindDeviceStatus_findsDeviceByDeviceNo() { void bindDeviceStatus_findsDeviceByDeviceNo() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl( AppDeviceServiceImpl service = newService();
appDeviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
wateringLogService
);
AppDeviceBo bo = new AppDeviceBo(); AppDeviceBo bo = new AppDeviceBo();
bo.setDeviceNo("D01"); bo.setDeviceNo("D01");
AppDevice device = new AppDevice(); AppDevice device = new AppDevice();
device.setDeviceNo("D01"); device.setDeviceNo("D01");
device.setUserId(100L); device.setUserId(100L);
when(appDeviceMapper.selectById("D01")).thenReturn(device); when(appDeviceMapper.selectById("D01")).thenReturn(device);
when(deviceBindingService.queryExclusiveBinding("D01")).thenReturn(activeBinding(100L));
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) { try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L); loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
@@ -124,12 +130,7 @@ class AppDeviceServiceImplTest {
@Test @Test
void bindDeviceStatus_findsDeviceByDeviceInitName() { void bindDeviceStatus_findsDeviceByDeviceInitName() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl( AppDeviceServiceImpl service = newService();
appDeviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
wateringLogService
);
AppDeviceBo bo = new AppDeviceBo(); AppDeviceBo bo = new AppDeviceBo();
bo.setDeviceInitName("INIT-01"); bo.setDeviceInitName("INIT-01");
AppDevice device = new AppDevice(); AppDevice device = new AppDevice();
@@ -137,6 +138,7 @@ class AppDeviceServiceImplTest {
device.setDeviceInitName("INIT-01"); device.setDeviceInitName("INIT-01");
device.setUserId(100L); device.setUserId(100L);
when(appDeviceMapper.selectByDeviceInitName("INIT-01")).thenReturn(device); when(appDeviceMapper.selectByDeviceInitName("INIT-01")).thenReturn(device);
when(deviceBindingService.queryExclusiveBinding("D01")).thenReturn(activeBinding(100L));
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) { try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L); loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
@@ -153,12 +155,7 @@ class AppDeviceServiceImplTest {
@Test @Test
void bindDeviceStatus_returnsUnboundWhenNetworkStatusFieldsAreNull() { void bindDeviceStatus_returnsUnboundWhenNetworkStatusFieldsAreNull() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl( AppDeviceServiceImpl service = newService();
appDeviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
wateringLogService
);
AppDeviceBo bo = new AppDeviceBo(); AppDeviceBo bo = new AppDeviceBo();
bo.setDeviceNo("D01"); bo.setDeviceNo("D01");
AppDevice device = new AppDevice(); AppDevice device = new AppDevice();
@@ -175,20 +172,148 @@ class AppDeviceServiceImplTest {
} }
} }
@Test
void bindDeviceStatus_doesNotExposeDeviceForFormerUnboundUser() {
AppDeviceServiceImpl service = newService();
AppDeviceBo bo = new AppDeviceBo();
bo.setDeviceNo("D01");
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
when(appDeviceMapper.selectById("D01")).thenReturn(device);
AppDeviceBinding unbound = new AppDeviceBinding();
unbound.setBindingStatus(DeviceBindingStatus.UNBOUND);
when(deviceBindingService.queryUserRelationship(100L, "D01")).thenReturn(unbound);
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("bindDevice")).isNull();
assertThat(result.get("bindingStatus")).isEqualTo(DeviceBindingStatus.UNBOUND);
}
}
@Test
void bindRegisteredDevice_returnsJoinedDeviceDetailsAfterBindingStarts() {
AppDeviceServiceImpl service = newService();
AppDeviceBo bo = new AppDeviceBo();
bo.setDeviceNo("D01");
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
AppDeviceVo bindingResult = new AppDeviceVo();
bindingResult.setDeviceNo("D01");
AppDeviceVo joinedResult = new AppDeviceVo();
joinedResult.setDeviceNo("D01");
joinedResult.setUserId(100L);
joinedResult.setBindingStatus(DeviceBindingStatus.BINDING);
joinedResult.setNickName("测试用户");
joinedResult.setDeviceEm("WATER-01");
when(appDeviceMapper.selectById("D01")).thenReturn(device);
when(deviceBindingService.startBinding(device, bo, 100L)).thenReturn(bindingResult);
when(appDeviceMapper.selectDeviceVosByDeviceNos(List.of("D01"))).thenReturn(List.of(joinedResult));
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
AppDeviceVo result = service.bindRegisteredDevice(bo);
assertThat(result).isSameAs(joinedResult);
assertThat(result.getNickName()).isEqualTo("测试用户");
assertThat(result.getBindingStatus()).isEqualTo(DeviceBindingStatus.BINDING);
verify(deviceBindingService).startBinding(device, bo, 100L);
verify(appDeviceMapper).selectDeviceVosByDeviceNos(List.of("D01"));
}
}
@Test
void updateByBo_rejectsFormerUserAfterUnbind() {
AppDeviceServiceImpl service = newService();
AppDeviceBo bo = new AppDeviceBo();
bo.setDeviceNo("D01");
AppDeviceBinding unbound = new AppDeviceBinding();
unbound.setBindingStatus(DeviceBindingStatus.UNBOUND);
when(deviceBindingService.queryUserRelationship(100L, "D01")).thenReturn(unbound);
assertThatThrownBy(() -> service.updateByBo(bo, 100L))
.isInstanceOf(ServiceException.class)
.hasMessageContaining("无权操作");
verify(appDeviceMapper, never()).updateById(any(AppDevice.class));
}
@Test @Test
void queryByDeviceNos_delegatesToSingleBatchQuery() { void queryByDeviceNos_delegatesToSingleBatchQuery() {
AppDeviceServiceImpl service = newService();
List<String> deviceNos = List.of("D01", "D02");
List<AppDeviceVo> expected = List.of(new AppDeviceVo(), new AppDeviceVo());
when(appDeviceMapper.selectDeviceVosByDeviceNos(deviceNos)).thenReturn(expected);
assertThat(service.queryByDeviceNos(deviceNos)).isSameAs(expected);
verify(appDeviceMapper).selectDeviceVosByDeviceNos(deviceNos);
}
@Test
void unbindDevicesByAdminAllowsCrossUserBatchAndCleansRelations() {
AppDeviceServiceImpl service = newService();
when(deviceBindingService.unbindByAdmin(List.of("D02", "D01"))).thenReturn(true);
Boolean result = service.unbindDevicesByAdmin(List.of("D02", "D01"));
assertThat(result).isTrue();
verify(deviceBindingService).unbindByAdmin(List.of("D02", "D01"));
verifyNoInteractions(schedulingDeviceMapper, wateringLogMapper);
}
@Test
void unbindDevicesByAdminRejectsMixedBoundAndUnboundBatchAtomically() {
AppDeviceServiceImpl service = newService();
when(deviceBindingService.unbindByAdmin(List.of("D01", "D02")))
.thenThrow(new ServiceException("设备不存在或不处于已绑定状态D02"));
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.unbindDevicesByAdmin(List.of("D01", "D02")))
.isInstanceOf(ServiceException.class)
.hasMessage("设备不存在或不处于已绑定状态D02");
verify(appDeviceMapper, never()).update(eq(null), any(Wrapper.class));
}
@Test
void deleteWithValidByIdsRejectsBoundDevice() {
AppDevice bound = new AppDevice();
bound.setDeviceNo("D01");
bound.setUserId(10L);
when(appDeviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(bound));
AppDeviceServiceImpl service = newService();
when(deviceBindingService.hasExclusiveBinding("D01")).thenReturn(true);
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::isSuperAdmin).thenReturn(true);
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.deleteWithValidByIds(List.of("D01"), true))
.isInstanceOf(ServiceException.class)
.hasMessage("绑定设备不能直接删除,请先解绑");
}
verify(appDeviceMapper, never()).delete(any(Wrapper.class));
}
private AppDeviceServiceImpl newService() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl( AppDeviceServiceImpl service = new AppDeviceServiceImpl(
appDeviceMapper, appDeviceMapper,
schedulingDeviceMapper, schedulingDeviceMapper,
wateringLogMapper, wateringLogMapper,
wateringLogService wateringLogService
); );
List<String> deviceNos = List.of("D01", "D02"); ReflectionTestUtils.setField(service, "deviceBindingService", deviceBindingService);
List<AppDeviceVo> expected = List.of(new AppDeviceVo(), new AppDeviceVo()); return service;
when(appDeviceMapper.selectVoByIds(deviceNos)).thenReturn(expected); }
assertThat(service.queryByDeviceNos(deviceNos)).isSameAs(expected); private AppDeviceBinding activeBinding(Long userId) {
verify(appDeviceMapper).selectVoByIds(deviceNos); AppDeviceBinding binding = new AppDeviceBinding();
binding.setDeviceNo("D01");
binding.setUserId(userId);
binding.setBindingStatus(DeviceBindingStatus.ACTIVE);
return binding;
} }
private int countNonWhitePixels(BufferedImage image, int startY, int endY) { private int countNonWhitePixels(BufferedImage image, int startY, int endY) {

View File

@@ -0,0 +1,99 @@
package org.dromara.app.service.impl;
import org.dromara.app.config.FirmwareStorageProperties;
import org.dromara.app.domain.AppFirmware;
import org.dromara.app.domain.bo.AppFirmwareBo;
import org.dromara.app.domain.vo.AppFirmwareVo;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.mapper.AppFirmwareMapper;
import org.dromara.app.service.IDeviceCommandService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.core.io.Resource;
import org.springframework.mock.web.MockMultipartFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@Tag("dev")
class AppFirmwareServiceImplTest {
@TempDir
Path storageDirectory;
@Test
void uploadFirmware_storesBinLocallyAndReturnsDownloadUrl() throws Exception {
AppFirmwareMapper firmwareMapper = mock(AppFirmwareMapper.class);
AtomicReference<AppFirmware> savedFirmware = new AtomicReference<>();
when(firmwareMapper.insert(any(AppFirmware.class))).thenAnswer(invocation -> {
AppFirmware firmware = invocation.getArgument(0);
firmware.setId(99L);
savedFirmware.set(firmware);
return 1;
});
when(firmwareMapper.selectVoById(99L)).thenAnswer(invocation -> {
AppFirmware firmware = savedFirmware.get();
AppFirmwareVo vo = new AppFirmwareVo();
vo.setId(firmware.getId());
vo.setFirmwareVersion(firmware.getFirmwareVersion());
vo.setFileName(firmware.getFileName());
vo.setFileUrl(firmware.getFileUrl());
vo.setFileSize(firmware.getFileSize());
vo.setMd5(firmware.getMd5());
return vo;
});
FirmwareStorageProperties properties = new FirmwareStorageProperties();
properties.setDirectory(storageDirectory);
properties.setDownloadBaseUrl("https://firmware.example.com/");
AppFirmwareServiceImpl service = new AppFirmwareServiceImpl(
firmwareMapper,
mock(AppDeviceMapper.class),
mock(IDeviceCommandService.class),
properties
);
byte[] content = new byte[] {1, 2, 3, 4};
MockMultipartFile file = new MockMultipartFile(
"file", "controller-v1.bin", "application/octet-stream", content);
AppFirmwareBo bo = new AppFirmwareBo();
bo.setFirmwareVersion("1.0.0");
AppFirmwareVo result = service.uploadFirmware(file, bo);
assertThat(result.getFileUrl())
.matches("https://firmware\\.example\\.com/app/firmware/download/[0-9a-f]{32}\\.bin");
String storedName = result.getFileUrl().substring(result.getFileUrl().lastIndexOf('/') + 1);
assertThat(Files.readAllBytes(storageDirectory.resolve(storedName))).isEqualTo(content);
Resource download = service.loadFirmware(storedName);
try (var inputStream = download.getInputStream()) {
assertThat(inputStream.readAllBytes()).isEqualTo(content);
}
assertThat(savedFirmware.get().getFileName()).isEqualTo("controller-v1.bin");
assertThat(savedFirmware.get().getFileSize()).isEqualTo(4L);
assertThat(savedFirmware.get().getMd5()).isEqualTo("08d6c05a21512a79a1dfeb9d2a8f262f");
}
@Test
void loadFirmware_rejectsPathTraversal() {
FirmwareStorageProperties properties = new FirmwareStorageProperties();
properties.setDirectory(storageDirectory);
AppFirmwareServiceImpl service = new AppFirmwareServiceImpl(
mock(AppFirmwareMapper.class),
mock(AppDeviceMapper.class),
mock(IDeviceCommandService.class),
properties
);
assertThatThrownBy(() -> service.loadFirmware("../firmware.bin"))
.isInstanceOf(org.dromara.common.core.exception.ServiceException.class)
.hasMessage("固件文件名无效");
}
}

View File

@@ -2,7 +2,10 @@ package org.dromara.app.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.Wrapper;
import org.dromara.app.domain.AppSchedulingDevice; import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper; import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -13,6 +16,7 @@ import java.util.List;
import java.util.Set; import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -22,10 +26,12 @@ class AppSchedulingDeviceServiceImplTest {
@Mock @Mock
private AppSchedulingDeviceMapper mapper; private AppSchedulingDeviceMapper mapper;
@Mock
private IAppDeviceBindingService deviceBindingService;
@Test @Test
void findBoundDeviceNos_usesOneConstrainedQuery() { void findBoundDeviceNos_usesOneConstrainedQuery() {
AppSchedulingDeviceServiceImpl service = new AppSchedulingDeviceServiceImpl(mapper); AppSchedulingDeviceServiceImpl service = new AppSchedulingDeviceServiceImpl(mapper, deviceBindingService);
AppSchedulingDevice first = new AppSchedulingDevice(); AppSchedulingDevice first = new AppSchedulingDevice();
first.setDeviceNo("D01"); first.setDeviceNo("D01");
AppSchedulingDevice duplicate = new AppSchedulingDevice(); AppSchedulingDevice duplicate = new AppSchedulingDevice();
@@ -37,4 +43,29 @@ class AppSchedulingDeviceServiceImplTest {
assertThat(result).containsExactly("D01"); assertThat(result).containsExactly("D01");
verify(mapper, times(1)).selectList(any(Wrapper.class)); verify(mapper, times(1)).selectList(any(Wrapper.class));
} }
@Test
void insertByBo_rejectsDeviceWithoutActiveBinding() {
AppSchedulingDeviceServiceImpl service = new AppSchedulingDeviceServiceImpl(mapper, deviceBindingService);
AppSchedulingDeviceBo bo = new AppSchedulingDeviceBo();
bo.setDeviceNo("D01");
bo.setScheduleId(10L);
when(deviceBindingService.isActive("D01")).thenReturn(false);
assertThatThrownBy(() -> service.insertByBo(bo))
.isInstanceOf(ServiceException.class)
.hasMessageContaining("已解绑");
verifyNoInteractions(mapper);
}
@Test
void deleteByScheduleAndDevice_rejectsFormerUserAfterUnbind() {
AppSchedulingDeviceServiceImpl service = new AppSchedulingDeviceServiceImpl(mapper, deviceBindingService);
when(deviceBindingService.queryUserRelationship(100L, "D01")).thenReturn(null);
assertThatThrownBy(() -> service.deleteWithValidByScheduleIdAndDeviceNo(10L, "D01", 100L))
.isInstanceOf(ServiceException.class)
.hasMessageContaining("无权操作");
verifyNoInteractions(mapper);
}
} }

View File

@@ -1,5 +1,6 @@
package org.dromara.app.service.impl; package org.dromara.app.service.impl;
import org.dromara.app.domain.DeviceBindingStatus;
import org.dromara.app.domain.bo.AppWateringLogBo; import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.mqtt.DeviceCommand; import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.domain.vo.AppDeviceVo; import org.dromara.app.domain.vo.AppDeviceVo;
@@ -21,6 +22,7 @@ import java.util.HashMap;
import java.util.Map; import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -38,6 +40,7 @@ class DeviceCommandServiceImplTest {
AppDeviceVo device = new AppDeviceVo(); AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01"); device.setDeviceNo("D01");
device.setUserId(99L); device.setUserId(99L);
device.setBindingStatus(DeviceBindingStatus.ACTIVE);
device.setMacAddress("AABBCC"); device.setMacAddress("AABBCC");
Map<String, Object> payload = new HashMap<>(); Map<String, Object> payload = new HashMap<>();
@@ -68,6 +71,7 @@ class DeviceCommandServiceImplTest {
AppDeviceVo device = new AppDeviceVo(); AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01"); device.setDeviceNo("D01");
device.setUserId(99L); device.setUserId(99L);
device.setBindingStatus(DeviceBindingStatus.ACTIVE);
device.setMacAddress("AABBCC"); device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device); when(deviceService.queryById("D01")).thenReturn(device);
@@ -97,6 +101,7 @@ class DeviceCommandServiceImplTest {
AppDeviceVo device = new AppDeviceVo(); AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01"); device.setDeviceNo("D01");
device.setUserId(99L); device.setUserId(99L);
device.setBindingStatus(DeviceBindingStatus.BINDING);
device.setMacAddress("AABBCC"); device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device); when(deviceService.queryById("D01")).thenReturn(device);
@@ -119,6 +124,7 @@ class DeviceCommandServiceImplTest {
AppDeviceVo device = new AppDeviceVo(); AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01"); device.setDeviceNo("D01");
device.setUserId(99L); device.setUserId(99L);
device.setBindingStatus(DeviceBindingStatus.BINDING);
device.setMacAddress("AABBCC"); device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device); when(deviceService.queryById("D01")).thenReturn(device);
@@ -143,6 +149,7 @@ class DeviceCommandServiceImplTest {
AppDeviceVo device = new AppDeviceVo(); AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01"); device.setDeviceNo("D01");
device.setUserId(99L); device.setUserId(99L);
device.setBindingStatus(DeviceBindingStatus.ACTIVE);
device.setMacAddress("AABBCC"); device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device); when(deviceService.queryById("D01")).thenReturn(device);
@@ -172,6 +179,7 @@ class DeviceCommandServiceImplTest {
AppDeviceVo device = new AppDeviceVo(); AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01"); device.setDeviceNo("D01");
device.setUserId(99L); device.setUserId(99L);
device.setBindingStatus(DeviceBindingStatus.ACTIVE);
device.setMacAddress("AABBCC"); device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device); when(deviceService.queryById("D01")).thenReturn(device);
@@ -189,4 +197,45 @@ class DeviceCommandServiceImplTest {
verify(wateringLogService).finishRunningLogsByDevice(eq("D01"), any(), eq("手动停止浇水")); verify(wateringLogService).finishRunningLogsByDevice(eq("D01"), any(), eq("手动停止浇水"));
verify(wateringLogService, never()).finishManualLog(any(), any(), any(), any()); verify(wateringLogService, never()).finishManualLog(any(), any(), any(), any());
} }
@Test
void sendSwitchCommand_rejectsUserDifferentFromCurrentBinding() {
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(100L);
device.setBindingStatus(DeviceBindingStatus.ACTIVE);
device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device);
DeviceCommandServiceImpl service = new DeviceCommandServiceImpl(deviceService, wateringLogService);
ReflectionTestUtils.setField(service, "commandPublisher", commandPublisher);
assertThatThrownBy(() -> service.sendSwitchCommand("D01", "1", "08:05", 10, 99L))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("无权操作");
verifyNoInteractions(commandPublisher, wateringLogService);
}
@Test
void sendFactoryResetCommand_usesCmdTopicAndFactoryResetCommandType() {
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device);
when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-reset");
DeviceCommandServiceImpl service = new DeviceCommandServiceImpl(deviceService, wateringLogService);
ReflectionTestUtils.setField(service, "commandPublisher", commandPublisher);
assertThat(service.sendFactoryResetCommand("D01")).isEqualTo("cmd-reset");
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.getTopic()).isNull();
assertThat(command.getCommandType()).isEqualTo("factoryReset");
assertThat(command.getPayload()).containsEntry("deviceNo", "D01");
}
} }