设备bug修改 新增设备解绑功能

This commit is contained in:
yuhaiming
2026-08-25 16:18:34 +08:00
parent b789c1c07e
commit 509e760f3d
87 changed files with 4458 additions and 405 deletions

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

View File

@@ -2,18 +2,23 @@ package org.dromara.app.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
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.DashboardStatsVo;
import org.dromara.app.listener.AppDeviceImportListener;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.file.FileUtils;
import org.dromara.common.core.validate.AddGroup;
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.idempotent.annotation.RepeatSubmit;
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.TableDataInfo;
import org.dromara.common.web.core.BaseController;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -42,6 +50,7 @@ import java.util.List;
public class AppDeviceController extends BaseController {
private final IAppDeviceService appDeviceService;
private final IDeviceCommandService deviceCommandService;
/**
* 查询设备信息
@@ -74,6 +83,27 @@ public class AppDeviceController extends BaseController {
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());
}
/**
* 后台解绑设备:清理用户数据并保留设备主档,支持跨用户批量操作。
*/
@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 String deviceName;
private String deviceInitName;
private String deviceImg;
@@ -35,6 +36,8 @@ public class AppDevice extends BaseEntity {
private String powerLevel;
private String powerStatus;
private Date powerLevelUpdatatime;
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 String bindingStatus;
/**
* 设备名称
*/
@@ -52,6 +55,10 @@ public class AppDeviceBo extends BaseEntity {
* 电量
*/
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 deviceNo;
private String bindingId;
private String status;
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")
private Long userId;
@ExcelProperty(value = "绑定状态")
private String bindingStatus;
@ExcelProperty(value = "解绑时间")
private Date unboundTime;
@ExcelProperty(value = "设备名称")
private String deviceName;
private String deviceInitName;
@@ -37,11 +43,15 @@ public class AppDeviceVo implements Serializable {
@ExcelProperty(value = "状态")
private String status;
@ExcelProperty(value = "工作状态")
private String workStatus;
@ExcelProperty(value = "电量")
private String powerLevel;
@ExcelProperty(value = "电量状态")
private String powerStatus;
@ExcelProperty(value = "电量更新时间")
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;
import com.baomidou.lock.annotation.Lock4j;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IAppDeviceService;
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 java.util.Date;
@@ -24,6 +28,9 @@ public class DeviceDataHandler implements MqttTopicHandler {
private final IAppDeviceService appDeviceService;
private final DeviceIdentityResolver deviceIdentityResolver;
private final MqttDeviceStatusService deviceStatusService;
@Lazy
@Autowired
private IAppDeviceBindingService deviceBindingService;
@Override
public Pattern topicPattern() {
@@ -31,6 +38,7 @@ public class DeviceDataHandler implements MqttTopicHandler {
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
public void handle(String deviceIdentity, String payload) {
String deviceNo = null;
try {
@@ -41,6 +49,10 @@ public class DeviceDataHandler implements MqttTopicHandler {
return;
}
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) {
log.warn("[MQTT] 设备电量上报缺少电量字段 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
return;
@@ -49,6 +61,7 @@ public class DeviceDataHandler implements MqttTopicHandler {
AppDeviceBo appDeviceBo = new AppDeviceBo();
appDeviceBo.setDeviceNo(deviceNo);
appDeviceBo.setPowerLevel(dto.get("powerLevel").toString());
appDeviceBo.setPowerStatus(dto.get("charging") == null ? null : dto.get("charging").toString());
appDeviceBo.setPowerLevelUpdatatime(new Date());
appDeviceService.updateByBo(appDeviceBo);
deviceStatusService.markOnline(deviceNo);
@@ -59,4 +72,5 @@ public class DeviceDataHandler implements MqttTopicHandler {
HandlerLogTime.now(), deviceIdentity, deviceNo, payload, e);
}
}
}

View File

@@ -34,6 +34,7 @@ DeviceRegisterHandler implements MqttTopicHandler {
private final ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider;
private final DeviceIdentityResolver deviceIdentityResolver;
private final MqttDeviceStatusService deviceStatusService;
private final DeviceRegisterReplyGuard registerReplyGuard;
@Override
public Pattern topicPattern() {
@@ -61,8 +62,8 @@ DeviceRegisterHandler implements MqttTopicHandler {
}
AppDeviceBo device = buildRegisterDevice(deviceNo, normalizedDeviceMac, dto);
appDeviceService.registerByMqtt(device);
deviceStatusService.markOnline(deviceNo);
sendDeviceNoToDevice(deviceNo, normalizedDeviceMac);
deviceStatusService.markRegisteredOnline(deviceNo);
sendDeviceNoOnce(deviceNo, normalizedDeviceMac);
log.info("[MQTT] 设备注册并上线 时间={} MAC={} 设备编号={} 消息体={}",
HandlerLogTime.now(), normalizedDeviceMac, deviceNo, payload);
} catch (Exception e) {
@@ -104,17 +105,28 @@ DeviceRegisterHandler implements MqttTopicHandler {
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();
if (commandPublisher == null) {
log.warn("[MQTT] 设备编号下发失败,未找到命令发布器 时间={} MAC={} 设备编号={}",
HandlerLogTime.now(), deviceMac, deviceNo);
return;
return false;
}
if (deviceMac == null) {
log.warn("[MQTT] 设备编号下发失败,缺少设备 MAC 时间={} 设备编号={}",
HandlerLogTime.now(), deviceNo);
return;
return false;
}
try {
@@ -130,9 +142,11 @@ DeviceRegisterHandler implements MqttTopicHandler {
String commandId = commandPublisher.send(command);
log.info("[MQTT] 设备编号已下发 时间={} MAC={} 设备编号={} 命令编号={}",
HandlerLogTime.now(), deviceMac, deviceNo, commandId);
return true;
} catch (Exception e) {
log.error("[MQTT] 设备编号下发失败 时间={} MAC={} 设备编号={}",
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;
import com.baomidou.lock.annotation.Lock4j;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceBindingService;
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 java.util.Map;
@@ -19,6 +23,9 @@ public class ErromesHandler implements MqttTopicHandler {
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/error$");
private final DeviceIdentityResolver deviceIdentityResolver;
@Lazy
@Autowired
private IAppDeviceBindingService deviceBindingService;
@Override
public Pattern topicPattern() {
@@ -26,6 +33,7 @@ public class ErromesHandler implements MqttTopicHandler {
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
public void handle(String deviceIdentity, String payload) {
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
try {
@@ -35,10 +43,15 @@ public class ErromesHandler implements MqttTopicHandler {
return;
}
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);
} catch (Exception e) {
log.error("[MQTT] 设备异常处理失败 时间={} 设备标识={} 设备编号={}",
HandlerLogTime.now(), deviceIdentity, deviceNo, e);
}
}
}

View File

@@ -1,18 +1,21 @@
package org.dromara.app.handler;
import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import java.text.ParseException;
@@ -36,6 +39,9 @@ public class KeyFinishHandler implements MqttTopicHandler {
private final IAppDeviceService appDeviceService;
private final AppSchedulingDeviceMapper schedulingDeviceMapper;
private final DeviceIdentityResolver deviceIdentityResolver;
@Lazy
@Autowired
private IAppDeviceBindingService deviceBindingService;
@Override
public Pattern topicPattern() {
@@ -43,6 +49,7 @@ public class KeyFinishHandler implements MqttTopicHandler {
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
public void handle(String deviceIdentity, String payload) {
String deviceNo = null;
try {
@@ -53,6 +60,10 @@ public class KeyFinishHandler implements MqttTopicHandler {
return;
}
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);
appWateringLogService.confirmScheduleLog(logBo);
updateDeviceWorkStatusIdle(deviceNo);
@@ -106,10 +117,10 @@ public class KeyFinishHandler implements MqttTopicHandler {
}
private Long queryUserId(String deviceNo) {
AppDeviceVo device = appDeviceService.queryById(deviceNo);
return device == null ? null : device.getUserId();
return deviceBindingService.queryActiveUserId(deviceNo);
}
private Long queryScheduleId(String deviceNo) {
List<AppSchedulingDevice> schedulingDevices = schedulingDeviceMapper.selectList(
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;
import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import java.time.Duration;
@@ -28,6 +32,9 @@ public class MqttDeviceStatusService {
private final AppDeviceMapper appDeviceMapper;
private final IAppWateringLogService wateringLogService;
@Lazy
@Autowired
private IAppDeviceBindingService deviceBindingService;
@Value("${mqtt.command-ack.device-status-cache-prefix}")
private String deviceStatusCachePrefix;
@@ -35,10 +42,23 @@ public class MqttDeviceStatusService {
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:600}")
private long deviceStatusCacheTtlSeconds;
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
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)) {
return;
}
markOnlineInternal(deviceNo);
}
private void markOnlineInternal(String deviceNo) {
withDeviceStatusLock(deviceNo, () -> {
Map<String, Object> statusCache = new HashMap<>();
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) {
if (StringUtils.isBlank(deviceNo)) {
return;
@@ -65,6 +86,7 @@ public class MqttDeviceStatusService {
markOfflineWithLock(deviceNo, reason, false);
}
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public boolean markOfflineIfCacheMissing(String deviceNo, String reason) {
if (StringUtils.isBlank(deviceNo)) {
return false;
@@ -73,6 +95,9 @@ public class MqttDeviceStatusService {
}
private boolean markOfflineWithLock(String deviceNo, String reason, boolean skipWhenCacheExists) {
if (!isActive(deviceNo)) {
return false;
}
return withDeviceStatusLock(deviceNo, () -> {
if (skipWhenCacheExists && RedisUtils.hasKey(deviceStatusCachePrefix + deviceNo)) {
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) {
RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo);
boolean locked = false;

View File

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

View File

@@ -1,19 +1,22 @@
package org.dromara.app.handler;
import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.domain.vo.AppWateringLogVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mqtt.MqttTopicHandler;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import java.text.ParseException;
@@ -35,6 +38,9 @@ public class StartWaterHandler implements MqttTopicHandler {
private final IAppDeviceService appDeviceService;
private final AppSchedulingDeviceMapper schedulingDeviceMapper;
private final DeviceIdentityResolver deviceIdentityResolver;
@Lazy
@Autowired
private IAppDeviceBindingService deviceBindingService;
@Override
public Pattern topicPattern() {
@@ -42,6 +48,7 @@ public class StartWaterHandler implements MqttTopicHandler {
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
public void handle(String deviceIdentity, String payload) {
String deviceNo = null;
try {
@@ -52,6 +59,10 @@ public class StartWaterHandler implements MqttTopicHandler {
return;
}
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);
if (hasDuplicateRunningLog(logBo)) {
updateDeviceWorkStatusWorking(deviceNo);
@@ -177,10 +188,10 @@ public class StartWaterHandler implements MqttTopicHandler {
}
private Long queryUserId(String deviceNo) {
AppDeviceVo device = appDeviceService.queryById(deviceNo);
return device == null ? null : device.getUserId();
return deviceBindingService.queryActiveUserId(deviceNo);
}
private Long queryScheduleId(String deviceNo) {
List<AppSchedulingDevice> schedulingDevices = schedulingDeviceMapper.selectList(
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.common.mybatis.core.mapper.BaseMapperPlus;
import java.util.Collection;
import java.util.List;
/**
@@ -25,7 +26,7 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
<script>
select
d.device_no,
d.user_id,
b.user_id,
d.device_name,
d.device_init_name,
d.device_img,
@@ -33,6 +34,7 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
d.status,
d.work_status,
d.power_level,
d.power_status,
d.power_level_updatatime,
d.wifi_name,
d.wifi_password,
@@ -41,15 +43,23 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
d.fw_ver,
d.mac_address,
d.expiration_time,
u.nick_name
u.nick_name,
b.binding_status,
b.unbound_time
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>
<if test="bo.deviceNo != null and bo.deviceNo != ''">
and d.device_no = #{bo.deviceNo}
</if>
<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 test="bo.deviceName != null and bo.deviceName != ''">
and d.device_name like concat('%', #{bo.deviceName}, '%')
@@ -103,7 +113,7 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
<script>
select
d.device_no,
d.user_id,
b.user_id,
d.device_name,
d.device_init_name,
d.device_img,
@@ -111,6 +121,7 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
d.status,
d.work_status,
d.power_level,
d.power_status,
d.power_level_updatatime,
d.wifi_name,
d.wifi_password,
@@ -119,15 +130,23 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
d.fw_ver,
d.mac_address,
d.expiration_time,
u.nick_name
u.nick_name,
b.binding_status,
b.unbound_time
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>
<if test="bo.deviceNo != null and bo.deviceNo != ''">
and d.device_no = #{bo.deviceNo}
</if>
<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 test="bo.deviceName != null and 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);
@Update("""
<script>
update app_device
<set>
user_id = #{userId},
<if test="deviceName != null and deviceName != ''">
device_name = #{deviceName},
</if>
<if test="status != null and status != ''">
status = #{status},
</if>
<if test="workStatus != null and workStatus != ''">
work_status = #{workStatus},
</if>
<if test="wifiName != null and wifiName != ''">
wifi_name = #{wifiName},
</if>
<if test="wifiPassword != null and wifiPassword != ''">
wifi_password = #{wifiPassword},
</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);
@Select("""
<script>
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, u.nick_name, b.binding_status, b.unbound_time
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')
left join sys_user u on u.user_id = b.user_id
where d.device_no in
<foreach collection="deviceNos" item="deviceNo" open="(" separator="," close=")">
#{deviceNo}
</foreach>
</script>
""")
List<AppDeviceVo> selectDeviceVosByDeviceNos(@Param("deviceNos") Collection<String> deviceNos);
@Update("""
update app_device
set work_status = #{workStatus}
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,
@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 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 user_id is null then 1 else 0 end) as unboundDevices
from app_device
sum(case when b.device_no is null then 1 else 0 end) as unboundDevices
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();

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> {
@Select("SELECT count(id) FROM app_watering_log\n" +
"WHERE user_id=#{userId} AND YEAR(create_time) = YEAR(CURDATE()) AND MONTH(create_time) = MONTH(CURDATE());")
@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()) " +
"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);
@Select("SELECT COUNT(id) \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);
@@ -29,11 +31,25 @@ public interface AppWateringLogMapper extends BaseMapperPlus<AppWateringLog, App
@Select("SELECT COUNT(id) \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);
@Select("SELECT SUM(duration_min)\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);
@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);
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, Long userId);
/**
* MQTT设备注册或刷新设备基础信息
*
@@ -96,6 +105,10 @@ public interface IAppDeviceService {
*/
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);
/**
@@ -134,6 +147,14 @@ public interface IAppDeviceService {
*/
Boolean unbindDevices(Collection<String> deviceNos, Long userId);
/**
* 后台解绑设备:允许按权限跨用户操作,但仍要求设备当前处于绑定状态。
*
* @param deviceNos 设备编号集合
* @return 是否解绑成功
*/
Boolean unbindDevicesByAdmin(Collection<String> deviceNos);
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, Long userId);
Boolean updateByBo(AppSchedulingDeviceBo bo);
Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
@@ -36,5 +39,8 @@ public interface IAppSchedulingDeviceService {
Boolean deleteWithValidByScheduleIdAndDeviceNo(@NotEmpty(message = "主键不能为空") Long scheduleId, String deviceNo);
/** 用户侧删除排程设备关联,并在设备锁内复核当前绑定用户。 */
Boolean deleteWithValidByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo, Long userId);
AppSchedulingDeviceVo queryByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo);
}

View File

@@ -1,6 +1,15 @@
package org.dromara.app.service;
import java.util.Collection;
public interface IDeviceCommandAckHandler {
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);
/**
* 用户侧下发设备开关/浇水命令,要求请求用户仍持有当前 ACTIVE 绑定。
*/
String sendSwitchCommand(String deviceNo, String workStatus, String startTime,
Integer durationMin, Long userId);
/**
* 下发自定义命令。
*
@@ -50,8 +56,14 @@ public interface IDeviceCommandService {
* @return commandId
*/
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 bindingId);
/**
* 下发排程解绑命令,通知设备清除指定排程。
*
@@ -61,6 +73,9 @@ public interface IDeviceCommandService {
*/
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, Long userId);
/**
* 下发设备初始化指令。用于解绑设备,允许设备未绑定用户时下发。
*
@@ -77,4 +95,34 @@ public interface IDeviceCommandService {
* @return commandId
*/
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.extra.qrcode.QrCodeUtil;
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.metadata.OrderItem;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.AppWateringLog;
import org.dromara.app.domain.*;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.domain.vo.DashboardStatsVo;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mapper.AppWateringLogMapper;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.app.service.*;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.exception.ServiceException;
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.entity.UploadResult;
import org.dromara.common.oss.factory.OssFactory;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import javax.imageio.ImageIO;
import java.awt.*;
@@ -69,11 +67,25 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
@Lazy
@Autowired
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;
@Override
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
@@ -81,7 +93,27 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
if (deviceNos == null || deviceNos.isEmpty()) {
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
@@ -106,32 +138,16 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
return item;
}
private LambdaQueryWrapper<AppDevice> buildQueryWrapper(AppDeviceBo bo) {
Map<String, Object> params = bo.getParams();
LambdaQueryWrapper<AppDevice> lqw = Wrappers.lambdaQuery();
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;
private boolean isActiveForUser(String deviceNo, Long userId) {
AppDeviceBinding binding = deviceBindingService.queryUserRelationship(userId, deviceNo);
return binding != null && DeviceBindingStatus.ACTIVE.equals(binding.getBindingStatus());
}
@Override
public Boolean insertByBo(AppDeviceBo bo) {
// fillBindTokenHash(bo);
AppDevice add = MapstructUtils.convert(bo, AppDevice.class);
add.setUserId(null);
validEntityBeforeInsert(add);
normalizeMacAddress(add);
ensureMacAddressAvailable(add);
@@ -140,8 +156,23 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
@Override
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);
AppDevice update = MapstructUtils.convert(bo, AppDevice.class);
update.setUserId(null);
validEntityBeforeUpdate(update);
normalizeMacAddress(update);
ensureMacAddressAvailable(update);
@@ -156,6 +187,7 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
AppDevice exists = baseMapper.selectById(bo.getDeviceNo());
AppDevice device = MapstructUtils.convert(bo, AppDevice.class);
device.setUserId(null);
device.setPowerLevelUpdatatime(new Date());
// applyRegisterBindToken(device, bo, exists);
if (StringUtils.isBlank(device.getStatus())) {
@@ -236,29 +268,26 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
if (exists == null) {
throw new ServiceException("设备未上线注册,请先完成配网");
}
if (exists.getUserId() != null && !exists.getUserId().equals(userId)) {
throw new ServiceException("设备已被其他用户绑定");
deviceBindingService.startBinding(exists, bo, userId);
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();
String wifiName = StringUtils.isBlank(bo.getWifiName()) ? exists.getWifiName() : bo.getWifiName();
String wifiPassword = StringUtils.isBlank(bo.getWifiPassword()) ? exists.getWifiPassword() : bo.getWifiPassword();
String deviceName = StringUtils.isBlank(bo.getDeviceName()) ? exists.getDeviceName() : bo.getDeviceName();
int rows = baseMapper.bindIfAvailable(exists.getDeviceNo(), userId, deviceName, status,"0",wifiName,wifiPassword);
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("设备绑定失败,请重试");
@Override
public AppDeviceVo retryBinding(String deviceNo, Long userId) {
AppDevice device = baseMapper.selectById(deviceNo);
if (device == null) {
throw new ServiceException("设备不存在");
}
//下发设备已绑定状态
deviceCommandService.sendBindDeviceCommand(exists.getDeviceNo());
return baseMapper.selectVoById(exists.getDeviceNo());
return deviceBindingService.retryBinding(device, userId);
}
@Override
public Boolean removeUserDeviceEntry(String deviceNo, Long userId) {
return deviceBindingService.removeListEntry(userId, deviceNo);
}
@Override
@@ -284,7 +313,8 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
AppDeviceVo device = baseMapper.selectVoById(deviceNo);
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);
}
@@ -317,22 +347,43 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
params.put("bindDeviceStatusName", "设备未入库注册,请先进行入库");
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);
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("bindDeviceStatusName", "设备未配网,请先去配置网络");
return params;
}
if (exists.getUserId() != null && !exists.getUserId().equals(userId)) {
if (binding != null && !Objects.equals(binding.getUserId(), userId)) {
params.put("bindDeviceStatus", 303);
params.put("bindDeviceStatusName", "设备已被其他用户绑定");
return params;
}
if (exists.getUserId() == null ) {
if (binding == null) {
params.put("bindDeviceStatus", 304);
params.put("bindDeviceStatusName", "设备未绑定,请先绑定用户");
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;
@@ -471,19 +522,9 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
}
@Override
@Transactional
@Transactional(rollbackFor = Exception.class)
public Boolean deleteWithValidByIds(Collection<String> deviceNos, Boolean isValid) {
if (deviceNos == null || deviceNos.isEmpty()) {
throw new ServiceException("设备编号不能为空");
}
List<String> distinctDeviceNos = deviceNos.stream()
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
if (distinctDeviceNos.isEmpty()) {
throw new ServiceException("设备编号不能为空");
}
List<String> distinctDeviceNos = normalizeDeviceNos(deviceNos);
boolean platformAdmin = isPlatformAdmin();
Long userId = platformAdmin ? null : LoginHelper.getUserId();
@@ -491,29 +532,27 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
if (userId == null) {
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()
.in(AppDevice::getDeviceNo, distinctDeviceNos)
.eq(!platformAdmin, AppDevice::getUserId, userId)
) > 0;
if (flag) {
schedulingDeviceMapper.delete(
new QueryWrapper<AppSchedulingDevice>().in("device_no", distinctDeviceNos)
);
wateringLogMapper.delete(new QueryWrapper<AppWateringLog>().in("device_no", distinctDeviceNos));
);
if (deletedRows != distinctDeviceNos.size()) {
throw new ServiceException("设备删除失败,请重试");
}
return flag;
deleteDeviceRelations(distinctDeviceNos);
deviceBindingService.deleteRelationships(distinctDeviceNos);
registerDeviceRuntimeCleanupAfterCommit(distinctDeviceNos, false);
return true;
}
private boolean isPlatformAdmin() {
@@ -530,54 +569,88 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean unbindDevices(Collection<String> deviceNos, Long userId) {
if (deviceNos == null || deviceNos.isEmpty()) {
throw new ServiceException("设备编号不能为空");
}
if (userId == null) {
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()
.filter(StringUtils::isNotBlank)
.map(String::trim)
.distinct()
.sorted()
.toList();
if (distinctDeviceNos.isEmpty()) {
throw new ServiceException("设备编号不能为空");
}
return distinctDeviceNos;
}
List<AppDevice> devices = baseMapper.selectList(
private List<AppDevice> queryDevices(List<String> deviceNos) {
return baseMapper.selectList(
Wrappers.<AppDevice>lambdaQuery()
.in(AppDevice::getDeviceNo, distinctDeviceNos)
.eq(AppDevice::getUserId, userId)
.in(AppDevice::getDeviceNo, deviceNos)
.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(
new QueryWrapper<AppSchedulingDevice>().in("device_no", distinctDeviceNos)
Wrappers.<AppSchedulingDevice>lambdaQuery().in(AppSchedulingDevice::getDeviceNo, deviceNos)
);
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;
import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
@@ -10,7 +11,9 @@ import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
import org.dromara.app.domain.vo.AppSchedulingDeviceVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.service.IAppDeviceBindingService;
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.StringUtils;
import org.dromara.common.mybatis.core.page.PageQuery;
@@ -30,6 +33,7 @@ import java.util.stream.Collectors;
public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceService {
private final AppSchedulingDeviceMapper baseMapper;
private final IAppDeviceBindingService deviceBindingService;
@Override
public AppSchedulingDeviceVo queryById(Long schedulingId) {
@@ -58,7 +62,17 @@ public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceServi
}
@Override
@Lock4j(keys = {"'device:command:' + #bo.deviceNo"}, acquireTimeout = 5000, expire = 30000)
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) {
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
public Boolean updateByBo(AppSchedulingDeviceBo bo) {
AppSchedulingDevice update = MapstructUtils.convert(bo, AppSchedulingDevice.class);
@@ -104,6 +126,15 @@ public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceServi
.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
public List<AppSchedulingDeviceVo> findByDeviceNo(String deviceNo) {
return baseMapper.selectVoList(new QueryWrapper<AppSchedulingDevice>().eq("device_no", deviceNo));

View File

@@ -1,7 +1,6 @@
package org.dromara.app.service.impl;
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.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -75,6 +74,10 @@ public class AppWateringLogServiceImpl implements IAppWateringLogService {
LambdaQueryWrapper<AppWateringLog> lqw = Wrappers.lambdaQuery();
lqw.orderByAsc(AppWateringLog::getId);
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.getCommandId()), AppWateringLog::getCommandId, bo.getCommandId());
lqw.eq(bo.getScheduleId() != null, AppWateringLog::getScheduleId, bo.getScheduleId());
@@ -333,7 +336,7 @@ public class AppWateringLogServiceImpl implements IAppWateringLogService {
@Override
public Long queryCount(Long userId) {
return baseMapper.selectCount(new QueryWrapper<AppWateringLog>().eq("user_id",userId));
return baseMapper.selectActiveCount(userId);
}
@Override

View File

@@ -1,15 +1,15 @@
package org.dromara.app.service.impl;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.lock.annotation.Lock4j;
import lombok.RequiredArgsConstructor;
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.mqtt.DeviceCommand;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.app.service.IDeviceCommandPublisher;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.app.service.*;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.satoken.utils.LoginHelper;
@@ -38,12 +38,17 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
@Autowired
private IDeviceCommandPublisher commandPublisher;
@Lazy
@Autowired(required = false)
private IAppDeviceBindingService deviceBindingService;
private final IAppDeviceService deviceService;
private final IAppWateringLogService wateringLogService;
// ========================= 通用下发 =========================
@Override
@Lock4j(keys = {"'device:command:' + #command.deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendCommand(DeviceCommand command) {
validate(command);
if (StringUtils.isBlank(command.getDeviceMac())) {
@@ -58,8 +63,16 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
// ========================= 类型化下发 =========================
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
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);
if ("1".equals(workStatus)) {
assertDurationPositive(durationMin);
@@ -76,12 +89,12 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
if (durationMin != null) {
command.getPayload().put("durationMin", durationMin);
}
attachCurrentBindingId(command);
String commandId = commandPublisher.send(command);
log.info("[命令] 开关命令已下发 设备编号={} 工作状态={} 持续分钟数={} 命令编号={}",
deviceNo, workStatus, durationMin, commandId);
Long userId = LoginHelper.getUserId();
if ("1".equals(workStatus)) {
AppWateringLogBo logBo = buildManualStartLog(deviceNo, userId, commandId, commandStartTime, durationMin);
wateringLogService.insertByBo(logBo);
@@ -97,6 +110,7 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendCustomCommand(String deviceNo, String commandType, Map<String, Object> extra) {
assertDeviceExists(deviceNo);
@@ -107,12 +121,20 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
if (extra != null) {
command.getPayload().putAll(extra);
}
attachCurrentBindingId(command);
return sendCommand(command);
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
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())) {
throw new ServiceException("设备未登记 MAC无法下发排程" + deviceNo);
}
@@ -125,11 +147,19 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
if (payload != null) {
command.getPayload().putAll(payload);
}
attachCurrentBindingId(command);
return sendCommand(command);
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
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);
if (ObjectUtil.isNull(device)) {
throw new ServiceException("设备不存在:" + deviceNo);
@@ -146,13 +176,25 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
objectObjectHashMap.put("bindStatus", true);
objectObjectHashMap.put("deviceNo", deviceNo);
objectObjectHashMap.put("cmd", -1);
if (StringUtils.isNotBlank(bindingId)) {
objectObjectHashMap.put("bindingId", bindingId);
}
command.getPayload().putAll(objectObjectHashMap);
return sendCommand(command);
String commandId = commandPublisher.send(command);
log.info("[命令] 绑定命令已下发 设备编号={} 绑定标识={} 命令编号={}", deviceNo, bindingId, commandId);
return commandId;
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
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())) {
throw new ServiceException("设备未登记 MAC无法下发排程解绑命令" + deviceNo);
}
@@ -166,12 +208,20 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
command.getPayload().put("deviceNo", deviceNo);
command.getPayload().put("scheduleId", scheduleId);
command.getPayload().put("unbind", true);
attachCurrentBindingId(command);
return sendCommand(command);
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
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())) {
throw new ServiceException("设备未登记 MAC无法下发排程取消命令" + deviceNo);
}
@@ -184,10 +234,12 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
command.getPayload().put("deviceNo", deviceNo);
command.getPayload().put("scheduleId", scheduleId);
command.getPayload().put("canceled", true);
attachCurrentBindingId(command);
return sendCommand(command);
}
@Override
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
public String sendInitDeviceCommand(String deviceNo) {
AppDeviceVo device = deviceService.queryById(deviceNo);
if (ObjectUtil.isNull(device)) {
@@ -211,6 +263,85 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
log.info("[命令] 设备初始化命令已下发 设备编号={} 命令编号={}", deviceNo, 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) {
@@ -224,13 +355,7 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
}
private void assertDeviceExists(String deviceNo) {
AppDeviceVo device = deviceService.queryById(deviceNo);
if (ObjectUtil.isNull(device)) {
throw new ServiceException("设备不存在:" + deviceNo);
}
if (device.getUserId() == null) {
throw new ServiceException("设备未绑定用户,无法下发命令:" + deviceNo);
}
requireCommandDevice(deviceNo, null);
}
private String requireDeviceMac(String deviceNo) {
@@ -242,16 +367,34 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
}
private AppDeviceVo requireCommandDevice(String deviceNo) {
return requireCommandDevice(deviceNo, null);
}
private AppDeviceVo requireCommandDevice(String deviceNo, Long userId) {
AppDeviceVo device = deviceService.queryById(deviceNo);
if (ObjectUtil.isNull(device)) {
throw new ServiceException("设备不存在:" + deviceNo);
}
if (device.getUserId() == null) {
if (!DeviceBindingStatus.ACTIVE.equals(device.getBindingStatus())) {
throw new ServiceException("设备未绑定用户,无法下发命令:" + deviceNo);
}
if (userId != null && !Objects.equals(userId, device.getUserId())) {
throw new ServiceException("设备不存在或无权操作:" + deviceNo);
}
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) {
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.AppSchedulingDevice;
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.AppScheduleMapper;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
@@ -41,7 +41,7 @@ public class OfflineScheduleWateringLogTask {
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
private final AppDeviceMapper deviceMapper;
private final AppDeviceBindingMapper deviceBindingMapper;
private final AppSchedulingDeviceMapper schedulingDeviceMapper;
private final AppScheduleMapper scheduleMapper;
private final AppScheduleDetailMapper scheduleDetailMapper;
@@ -59,12 +59,7 @@ public class OfflineScheduleWateringLogTask {
return;
}
List<AppDevice> offlineDevices = deviceMapper.selectList(
Wrappers.<AppDevice>lambdaQuery()
.eq(AppDevice::getStatus, "0")
.isNotNull(AppDevice::getUserId)
.select(AppDevice::getDeviceNo, AppDevice::getUserId)
);
List<AppDevice> offlineDevices = deviceBindingMapper.selectOfflineActiveDevices();
if (offlineDevices.isEmpty()) {
return;
}

View File

@@ -164,7 +164,7 @@ class AppControllerConcurrencyTest {
detail.setStatus("1");
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);
Map<String, Object> payload = invocation.getArgument(1);
assertThat(payloadsByDevice.putIfAbsent(deviceNo, payload)).isNull();
@@ -197,7 +197,7 @@ class AppControllerConcurrencyTest {
assertThat(detail.get("triggerType")).isEqualTo(detailMarker(scheduleId));
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());
}

View File

@@ -3,7 +3,10 @@ package org.dromara.app.controller;
import cn.hutool.extra.spring.SpringUtil;
import cn.hutool.json.JSONObject;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.bo.*;
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.service.*;
import org.dromara.common.core.domain.R;
@@ -273,7 +276,7 @@ public class AppControllerTest {
public void getDeviceInfo_propagatesUnexpectedException() {
AppController controller = newController();
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")))
.isSameAs(failure);
@@ -300,7 +303,7 @@ public class AppControllerTest {
AppController controller = newController();
AppDeviceVo first = ownedDevice("D01", 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")))
.thenReturn(Set.of("D01"));
@@ -320,12 +323,11 @@ public class AppControllerTest {
secondBinding.setDeviceNo("D02");
AppDeviceVo firstDevice = ownedDevice("D01", 99L);
AppDeviceVo secondDevice = ownedDevice("D02", 99L);
List<AppDeviceVo> queriedDevices = List.of(secondDevice, firstDevice);
when(appScheduleService.queryById(10L)).thenReturn(schedule);
when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of());
when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class)))
.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));
@@ -358,16 +360,16 @@ public class AppControllerTest {
detail.setStatus("1");
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));
when(appSchedulingDeviceService.insertByBo(any(AppSchedulingDeviceBo.class))).thenReturn(true);
when(appSchedulingDeviceService.insertByBo(any(AppSchedulingDeviceBo.class), eq(userId))).thenReturn(true);
R<Void> result = callWithLogin(userId,
() -> controller.addScheduleDevice("{\"scheduleId\":10,\"deviceNos\":[\"D01\"]}"));
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();
assertThat(payload.get("deviceNo")).isEqualTo("D01");
assertThat(payload).containsKey("details").doesNotContainKeys("cmd", "schedule");
@@ -397,15 +399,15 @@ public class AppControllerTest {
AppSchedulingDeviceVo existingBinding = new AppSchedulingDeviceVo();
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);
R<Void> result = callWithLogin(userId,
() -> controller.addScheduleDevice("{\"scheduleId\":10,\"deviceNos\":[\"D01\"]}"));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
verify(appSchedulingDeviceService, never()).insertByBo(any(AppSchedulingDeviceBo.class));
verify(deviceCommandService, never()).sendScheduleBindCommand(eq("D01"), any());
verify(appSchedulingDeviceService, never()).insertByBo(any(AppSchedulingDeviceBo.class), anyLong());
verify(deviceCommandService, never()).sendScheduleBindCommand(eq("D01"), any(), anyLong());
}
@Test
@@ -425,7 +427,7 @@ public class AppControllerTest {
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
InOrder ordered = inOrder(appScheduleService, deviceCommandService);
ordered.verify(appScheduleService).deleteWithValidByIds(List.of(10L), true);
ordered.verify(deviceCommandService).sendScheduleUnbindCommand("D01", 10L);
ordered.verify(deviceCommandService).sendScheduleUnbindCommand("D01", 10L, userId);
}
@Test
@@ -443,7 +445,7 @@ public class AppControllerTest {
R<Void> result = callWithLogin(userId, () -> controller.removeSchedule(new Long[]{10L}));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(500);
verify(deviceCommandService, never()).sendScheduleUnbindCommand("D01", 10L);
verify(deviceCommandService, never()).sendScheduleUnbindCommand("D01", 10L, userId);
}
@Test
@@ -464,8 +466,8 @@ public class AppControllerTest {
R<Void> result = callWithLogin(userId, () -> controller.editScheduleStatus(bo));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
verify(deviceCommandService).sendScheduleCanceledCommand("D01", 10L);
verify(deviceCommandService, never()).sendScheduleBindCommand(eq("D01"), any());
verify(deviceCommandService).sendScheduleCanceledCommand("D01", 10L, userId);
verify(deviceCommandService, never()).sendScheduleBindCommand(eq("D01"), any(), anyLong());
}
@Test
@@ -492,8 +494,8 @@ public class AppControllerTest {
R<Void> result = callWithLogin(userId, () -> controller.editScheduleStatus(bo));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), any());
verify(deviceCommandService, never()).sendScheduleCanceledCommand("D01", 10L);
verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), any(), eq(userId));
verify(deviceCommandService, never()).sendScheduleCanceledCommand("D01", 10L, userId);
}
@Test
@@ -516,7 +518,7 @@ public class AppControllerTest {
callWithLogin(99L, () -> controller.editScheduleStatus(bo));
verify(appScheduleDetailService, times(1)).queryByScheduleIdByStatus(10L);
verify(deviceCommandService, times(2)).sendScheduleBindCommand(anyString(), any());
verify(deviceCommandService, times(2)).sendScheduleBindCommand(anyString(), any(), eq(99L));
}
@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.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));
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 com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IAppDeviceService;
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;
@@ -12,12 +14,12 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
@@ -26,6 +28,9 @@ class DeviceDataHandlerTest {
@Mock
private IAppDeviceService appDeviceService;
@Mock
private IAppDeviceBindingService deviceBindingService;
@Mock
private DeviceIdentityResolver deviceIdentityResolver;
@@ -35,12 +40,19 @@ class DeviceDataHandlerTest {
@InjectMocks
private DeviceDataHandler handler;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(handler, "deviceBindingService", deviceBindingService);
}
@Test
void handleMarksDeviceOnlineAndUpdatesPowerLevel() {
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(deviceBindingService.isActive("D01")).thenReturn(true);
when(deviceBindingService.isCurrentBinding("D01", "binding-1")).thenReturn(true);
withJsonContext(() -> {
handler.handle("D01", "{\"powerLevel\":\"86\"}");
handler.handle("D01", "{\"powerLevel\":\"86\",\"bindingId\":\"binding-1\"}");
return null;
});
@@ -53,6 +65,31 @@ class DeviceDataHandlerTest {
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) {
try (GenericApplicationContext applicationContext = new GenericApplicationContext()) {
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.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
@@ -35,6 +34,7 @@ class DeviceRegisterHandlerTest {
@Mock private IDeviceCommandPublisher commandPublisher;
@Mock private DeviceIdentityResolver deviceIdentityResolver;
@Mock private MqttDeviceStatusService deviceStatusService;
@Mock private DeviceRegisterReplyGuard registerReplyGuard;
@Test
void firstRegistrationReplyStillUsesMacTopic() throws Exception {
@@ -43,7 +43,8 @@ class DeviceRegisterHandlerTest {
appDeviceMapper,
commandPublisherProvider,
deviceIdentityResolver,
deviceStatusService
deviceStatusService,
registerReplyGuard
);
when(commandPublisherProvider.getIfAvailable()).thenReturn(commandPublisher);
when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1");
@@ -69,13 +70,13 @@ class DeviceRegisterHandlerTest {
appDeviceMapper,
commandPublisherProvider,
deviceIdentityResolver,
deviceStatusService
deviceStatusService,
registerReplyGuard
);
AppDevice existing = new AppDevice();
existing.setDeviceNo("2075423638947475457");
existing.setMacAddress("aa:bb:cc");
when(deviceIdentityResolver.resolve("2075423638947475457")).thenReturn(existing);
when(commandPublisherProvider.getIfAvailable()).thenReturn(null);
withJsonContext(() -> {
handler.handle("2075423638947475457", "{}");
@@ -86,7 +87,62 @@ class DeviceRegisterHandlerTest {
verify(appDeviceService).registerByMqtt(captor.capture());
assertThat(captor.getValue().getDeviceNo()).isEqualTo("2075423638947475457");
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) {

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 org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.junit.jupiter.api.Tag;
@@ -15,6 +15,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.function.Supplier;
@@ -30,6 +31,7 @@ class KeyFinishHandlerTest {
@Mock private IAppDeviceService appDeviceService;
@Mock private AppSchedulingDeviceMapper schedulingDeviceMapper;
@Mock private DeviceIdentityResolver deviceIdentityResolver;
@Mock private IAppDeviceBindingService deviceBindingService;
@Test
void handle_setsDeviceWorkStatusIdleWhenKeyWateringFinished() {
@@ -39,14 +41,15 @@ class KeyFinishHandlerTest {
schedulingDeviceMapper,
deviceIdentityResolver
);
AppDeviceVo device = new AppDeviceVo();
device.setUserId(99L);
ReflectionTestUtils.setField(handler, "deviceBindingService", deviceBindingService);
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(() -> {
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;
});

View File

@@ -7,6 +7,7 @@ import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.dromara.app.domain.AppDevice;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.common.redis.utils.RedisUtils;
import org.junit.jupiter.api.AfterAll;
@@ -41,6 +42,8 @@ class MqttDeviceStatusServiceTest {
@Mock
private IAppWateringLogService wateringLogService;
@Mock
private IAppDeviceBindingService deviceBindingService;
@Mock
private RedissonClient redissonClient;
@Mock
private RLock lock;
@@ -127,8 +130,10 @@ class MqttDeviceStatusServiceTest {
private MqttDeviceStatusService newService() {
MqttDeviceStatusService service = new MqttDeviceStatusService(appDeviceMapper, wateringLogService);
ReflectionTestUtils.setField(service, "deviceBindingService", deviceBindingService);
ReflectionTestUtils.setField(service, "deviceStatusCachePrefix", "mqtt:device:status:");
ReflectionTestUtils.setField(service, "deviceStatusCacheTtlSeconds", 600L);
when(deviceBindingService.isActive("D01")).thenReturn(true);
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.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.junit.jupiter.api.Tag;
@@ -16,6 +16,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Calendar;
import java.util.List;
@@ -34,6 +35,7 @@ class ScheduleFinishHandlerTest {
@Mock private IAppDeviceService appDeviceService;
@Mock private AppSchedulingDeviceMapper schedulingDeviceMapper;
@Mock private DeviceIdentityResolver deviceIdentityResolver;
@Mock private IAppDeviceBindingService deviceBindingService;
@Test
void handle_setsDeviceWorkStatusIdleWhenScheduleWateringFinished() {
@@ -43,18 +45,16 @@ class ScheduleFinishHandlerTest {
schedulingDeviceMapper,
deviceIdentityResolver
);
AppDeviceVo device = new AppDeviceVo();
device.setUserId(99L);
activate(handler);
AppSchedulingDevice binding = new AppSchedulingDevice();
binding.setDeviceNo("D01");
binding.setScheduleId(10L);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
when(schedulingDeviceMapper.selectList(any())).thenReturn(List.of(binding));
withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"durationMin\":10}");
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"durationMin\":10,\"bindingId\":\"binding-1\"}");
return null;
});
@@ -77,18 +77,16 @@ class ScheduleFinishHandlerTest {
schedulingDeviceMapper,
deviceIdentityResolver
);
AppDeviceVo device = new AppDeviceVo();
device.setUserId(99L);
activate(handler);
AppSchedulingDevice binding = new AppSchedulingDevice();
binding.setDeviceNo("D01");
binding.setScheduleId(10L);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
when(schedulingDeviceMapper.selectList(any())).thenReturn(List.of(binding));
withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"08:30:27\",\"durationMin\":10}");
handler.handle("D01", "{\"startTime\":\"08:30:27\",\"durationMin\":10,\"bindingId\":\"binding-1\"}");
return null;
});
@@ -112,4 +110,11 @@ class ScheduleFinishHandlerTest {
calendar.setTime(date);
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.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.domain.vo.AppWateringLogVo;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.junit.jupiter.api.Tag;
@@ -17,6 +17,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import java.text.SimpleDateFormat;
import java.util.List;
@@ -34,6 +35,7 @@ class StartWaterHandlerTest {
@Mock private IAppDeviceService appDeviceService;
@Mock private AppSchedulingDeviceMapper schedulingDeviceMapper;
@Mock private DeviceIdentityResolver deviceIdentityResolver;
@Mock private IAppDeviceBindingService deviceBindingService;
@Test
void handle_insertsRunningScheduleWateringLog() throws Exception {
@@ -43,18 +45,16 @@ class StartWaterHandlerTest {
schedulingDeviceMapper,
deviceIdentityResolver
);
AppDeviceVo device = new AppDeviceVo();
device.setUserId(99L);
activate(handler);
AppSchedulingDevice binding = new AppSchedulingDevice();
binding.setDeviceNo("D01");
binding.setScheduleId(10L);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
when(schedulingDeviceMapper.selectList(any())).thenReturn(List.of(binding));
withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"triggerON\":\"schedule\"}");
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"triggerON\":\"schedule\",\"bindingId\":\"binding-1\"}");
return null;
});
@@ -86,8 +86,7 @@ class StartWaterHandlerTest {
schedulingDeviceMapper,
deviceIdentityResolver
);
AppDeviceVo device = new AppDeviceVo();
device.setUserId(99L);
activate(handler);
AppWateringLogVo activeLog = new AppWateringLogVo();
activeLog.setDeviceNo("D01");
activeLog.setUserId(99L);
@@ -96,11 +95,10 @@ class StartWaterHandlerTest {
activeLog.setEndTime(null);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
lenient().when(appWateringLogService.queryList(any(AppWateringLogBo.class))).thenReturn(List.of(activeLog));
withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"triggerON\":\"manual\"}");
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:00\",\"triggerON\":\"manual\",\"bindingId\":\"binding-1\"}");
return null;
});
@@ -119,8 +117,7 @@ class StartWaterHandlerTest {
schedulingDeviceMapper,
deviceIdentityResolver
);
AppDeviceVo device = new AppDeviceVo();
device.setUserId(99L);
activate(handler);
AppWateringLogVo activeLog = new AppWateringLogVo();
activeLog.setDeviceNo("D01");
activeLog.setUserId(99L);
@@ -131,11 +128,10 @@ class StartWaterHandlerTest {
activeLog.setEndTime(null);
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
lenient().when(appWateringLogService.queryList(any(AppWateringLogBo.class))).thenReturn(List.of(activeLog));
withJsonContext(() -> {
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:27\",\"triggerON\":\"manual\"}");
handler.handle("D01", "{\"startTime\":\"2026-07-06 08:30:27\",\"triggerON\":\"manual\",\"bindingId\":\"binding-1\"}");
return null;
});
@@ -154,15 +150,13 @@ class StartWaterHandlerTest {
schedulingDeviceMapper,
deviceIdentityResolver
);
AppDeviceVo device = new AppDeviceVo();
device.setUserId(99L);
activate(handler);
String today = new SimpleDateFormat("yyyy-MM-dd").format(new java.util.Date());
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
when(appDeviceService.queryById("D01")).thenReturn(device);
withJsonContext(() -> {
handler.handle("D01", "{\"deviceNo\":\"D01\",\"startTime\":\"16:39\",\"durationMin\":10,\"triggerON\":\"manual\"}");
handler.handle("D01", "{\"deviceNo\":\"D01\",\"startTime\":\"16:39\",\"durationMin\":10,\"triggerON\":\"manual\",\"bindingId\":\"binding-1\"}");
return null;
});
@@ -190,4 +184,11 @@ class StartWaterHandlerTest {
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 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.vo.AppDeviceVo;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mapper.AppWateringLogMapper;
import org.dromara.app.service.IAppDeviceBindingService;
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.exception.ServiceException;
import org.dromara.common.satoken.utils.LoginHelper;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@@ -16,6 +22,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
@@ -24,6 +31,7 @@ import java.util.List;
import java.util.Map;
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.*;
@@ -39,6 +47,12 @@ class AppDeviceServiceImplTest {
private AppWateringLogMapper wateringLogMapper;
@Mock
private IAppWateringLogService wateringLogService;
@Mock
private IDeviceCommandService deviceCommandService;
@Mock
private IDeviceCommandAckHandler deviceCommandAckHandler;
@Mock
private IAppDeviceBindingService deviceBindingService;
@Test
void renderQrCodeImage_addsDeviceNoBelowQrCode() throws Exception {
@@ -53,12 +67,10 @@ class AppDeviceServiceImplTest {
@Test
void deleteWithValidByIds_allowsSuperAdminToDeleteDeviceOwnedByAnotherUser() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl(
appDeviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
wateringLogService
);
AppDeviceServiceImpl service = newService();
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
when(appDeviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(device));
when(appDeviceMapper.delete(any(Wrapper.class))).thenReturn(1);
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
@@ -74,12 +86,10 @@ class AppDeviceServiceImplTest {
@Test
void deleteWithValidByIds_allowsAuthorizedSystemUserToDeleteDeviceOwnedByAnotherUser() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl(
appDeviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
wateringLogService
);
AppDeviceServiceImpl service = newService();
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
when(appDeviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(device));
when(appDeviceMapper.delete(any(Wrapper.class))).thenReturn(1);
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
@@ -97,18 +107,14 @@ class AppDeviceServiceImplTest {
@Test
void bindDeviceStatus_findsDeviceByDeviceNo() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl(
appDeviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
wateringLogService
);
AppDeviceServiceImpl service = newService();
AppDeviceBo bo = new AppDeviceBo();
bo.setDeviceNo("D01");
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
device.setUserId(100L);
when(appDeviceMapper.selectById("D01")).thenReturn(device);
when(deviceBindingService.queryExclusiveBinding("D01")).thenReturn(activeBinding(100L));
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
@@ -124,12 +130,7 @@ class AppDeviceServiceImplTest {
@Test
void bindDeviceStatus_findsDeviceByDeviceInitName() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl(
appDeviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
wateringLogService
);
AppDeviceServiceImpl service = newService();
AppDeviceBo bo = new AppDeviceBo();
bo.setDeviceInitName("INIT-01");
AppDevice device = new AppDevice();
@@ -137,6 +138,7 @@ class AppDeviceServiceImplTest {
device.setDeviceInitName("INIT-01");
device.setUserId(100L);
when(appDeviceMapper.selectByDeviceInitName("INIT-01")).thenReturn(device);
when(deviceBindingService.queryExclusiveBinding("D01")).thenReturn(activeBinding(100L));
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
@@ -153,12 +155,7 @@ class AppDeviceServiceImplTest {
@Test
void bindDeviceStatus_returnsUnboundWhenNetworkStatusFieldsAreNull() {
AppDeviceServiceImpl service = new AppDeviceServiceImpl(
appDeviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
wateringLogService
);
AppDeviceServiceImpl service = newService();
AppDeviceBo bo = new AppDeviceBo();
bo.setDeviceNo("D01");
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
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(
appDeviceMapper,
schedulingDeviceMapper,
wateringLogMapper,
wateringLogService
);
List<String> deviceNos = List.of("D01", "D02");
List<AppDeviceVo> expected = List.of(new AppDeviceVo(), new AppDeviceVo());
when(appDeviceMapper.selectVoByIds(deviceNos)).thenReturn(expected);
ReflectionTestUtils.setField(service, "deviceBindingService", deviceBindingService);
return service;
}
assertThat(service.queryByDeviceNos(deviceNos)).isSameAs(expected);
verify(appDeviceMapper).selectVoByIds(deviceNos);
private AppDeviceBinding activeBinding(Long userId) {
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) {

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 org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
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.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -13,6 +16,7 @@ import java.util.List;
import java.util.Set;
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.*;
@@ -22,10 +26,12 @@ class AppSchedulingDeviceServiceImplTest {
@Mock
private AppSchedulingDeviceMapper mapper;
@Mock
private IAppDeviceBindingService deviceBindingService;
@Test
void findBoundDeviceNos_usesOneConstrainedQuery() {
AppSchedulingDeviceServiceImpl service = new AppSchedulingDeviceServiceImpl(mapper);
AppSchedulingDeviceServiceImpl service = new AppSchedulingDeviceServiceImpl(mapper, deviceBindingService);
AppSchedulingDevice first = new AppSchedulingDevice();
first.setDeviceNo("D01");
AppSchedulingDevice duplicate = new AppSchedulingDevice();
@@ -37,4 +43,29 @@ class AppSchedulingDeviceServiceImplTest {
assertThat(result).containsExactly("D01");
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;
import org.dromara.app.domain.DeviceBindingStatus;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.domain.vo.AppDeviceVo;
@@ -21,6 +22,7 @@ import java.util.HashMap;
import java.util.Map;
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.eq;
import static org.mockito.Mockito.*;
@@ -38,6 +40,7 @@ class DeviceCommandServiceImplTest {
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(99L);
device.setBindingStatus(DeviceBindingStatus.ACTIVE);
device.setMacAddress("AABBCC");
Map<String, Object> payload = new HashMap<>();
@@ -68,6 +71,7 @@ class DeviceCommandServiceImplTest {
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(99L);
device.setBindingStatus(DeviceBindingStatus.ACTIVE);
device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device);
@@ -97,6 +101,7 @@ class DeviceCommandServiceImplTest {
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(99L);
device.setBindingStatus(DeviceBindingStatus.BINDING);
device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device);
@@ -119,6 +124,7 @@ class DeviceCommandServiceImplTest {
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(99L);
device.setBindingStatus(DeviceBindingStatus.BINDING);
device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device);
@@ -143,6 +149,7 @@ class DeviceCommandServiceImplTest {
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(99L);
device.setBindingStatus(DeviceBindingStatus.ACTIVE);
device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device);
@@ -172,6 +179,7 @@ class DeviceCommandServiceImplTest {
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(99L);
device.setBindingStatus(DeviceBindingStatus.ACTIVE);
device.setMacAddress("AABBCC");
when(deviceService.queryById("D01")).thenReturn(device);
@@ -189,4 +197,45 @@ class DeviceCommandServiceImplTest {
verify(wateringLogService).finishRunningLogsByDevice(eq("D01"), any(), eq("手动停止浇水"));
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");
}
}