设备bug修改 新增设备解绑功能
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除设备信息
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 电量更新时间
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.dromara.app.service;
|
||||
|
||||
import org.dromara.app.domain.mqtt.DeviceUnbindResult;
|
||||
|
||||
public interface IDeviceUnbindResultPublisher {
|
||||
void publish(DeviceUnbindResult result);
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("固件文件名无效");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user