{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"我会快速查看当前 git diff 的文件清单和统计,确认这次提交的主线,避免只根据截断 diff 猜测。"}]}}{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"item_1","name":"bash","input":{"command":"\"C:\\\\Users\\\\admin\\\\AppData\\\\Local\\\\Microsoft\\\\WindowsApps\\\\pwsh.exe\" -Command 'git diff --stat'","description":"Run \"C:\\\\Users\\\\admin\\\\AppData\\\\Local\\\\Microsoft\\\\WindowsApps\\\\pwsh.exe\""}}]}}
This commit is contained in:
@@ -102,6 +102,23 @@
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>water-common-websocket</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>water-common-oss</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-extra</artifactId>
|
||||
</dependency>
|
||||
<!-- hutool-extra QrCodeUtil 底层需要 zxing(hutool 声明为 optional,不会传递) -->
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>javase</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.dromara</groupId>
|
||||
<artifactId>water-system</artifactId>
|
||||
|
||||
@@ -154,7 +154,7 @@ public class AppController extends BaseController {
|
||||
for (String deviceNo : deviceNos) {
|
||||
assertDeviceOwned(deviceNo);
|
||||
}
|
||||
return toAjax(appDeviceService.deleteWithValidByIds(List.of(deviceNos), true));
|
||||
return toAjax(appDeviceService.unbindDevices(List.of(deviceNos), LoginHelper.getUserId()));
|
||||
} catch (Exception e) {
|
||||
return fail(e);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
package org.dromara.app.controller;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.constraints.*;
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.dromara.common.idempotent.annotation.RepeatSubmit;
|
||||
import org.dromara.common.log.annotation.Log;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
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.AppDeviceVo;
|
||||
import org.dromara.app.domain.vo.DashboardStatsVo;
|
||||
import org.dromara.app.service.IAppDeviceService;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.validate.AddGroup;
|
||||
import org.dromara.common.core.validate.EditGroup;
|
||||
import org.dromara.common.log.enums.BusinessType;
|
||||
import org.dromara.common.excel.utils.ExcelUtil;
|
||||
import org.dromara.app.domain.vo.AppDeviceVo;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.service.IAppDeviceService;
|
||||
import org.dromara.common.idempotent.annotation.RepeatSubmit;
|
||||
import org.dromara.common.log.annotation.Log;
|
||||
import org.dromara.common.log.enums.BusinessType;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 设备信息
|
||||
@@ -47,6 +49,15 @@ public class AppDeviceController extends BaseController {
|
||||
return appDeviceService.queryPageList(bo, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表盘统计 — 首页 8 个统计卡片聚合数据。
|
||||
*/
|
||||
@SaCheckPermission("app:device:query")
|
||||
@GetMapping("/dashboard")
|
||||
public R<DashboardStatsVo> dashboard() {
|
||||
return R.ok(appDeviceService.getDashboardStats());
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出设备信息
|
||||
列表
|
||||
@@ -63,7 +74,7 @@ public class AppDeviceController extends BaseController {
|
||||
* 获取设备信息
|
||||
详细信息
|
||||
*
|
||||
* @param id 主键
|
||||
* @param
|
||||
*/
|
||||
@SaCheckPermission("app:device:query")
|
||||
@GetMapping("/{deviceNo}")
|
||||
@@ -81,6 +92,8 @@ public class AppDeviceController extends BaseController {
|
||||
@RepeatSubmit()
|
||||
@PostMapping()
|
||||
public R<Void> add(@Validated(AddGroup.class) @RequestBody AppDeviceBo bo) {
|
||||
bo.setStatus("0");
|
||||
bo.setWorkStatus("2");
|
||||
return toAjax(appDeviceService.insertByBo(bo));
|
||||
}
|
||||
|
||||
@@ -96,11 +109,24 @@ public class AppDeviceController extends BaseController {
|
||||
return toAjax(appDeviceService.updateByBo(bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成设备二维码
|
||||
*
|
||||
* @param ids 设备编号集合
|
||||
*/
|
||||
@SaCheckPermission("app:device:edit")
|
||||
@Log(title = "设备二维码", businessType = BusinessType.UPDATE)
|
||||
@PostMapping("/generateQrCode/{ids}")
|
||||
public R<Void> generateQrCode(@NotEmpty(message = "设备编号不能为空")
|
||||
@PathVariable String[] ids) {
|
||||
return toAjax(appDeviceService.generateQrCode(List.of(ids)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除设备信息
|
||||
|
||||
*
|
||||
* @param ids 主键串
|
||||
* @param
|
||||
*/
|
||||
@SaCheckPermission("app:device:remove")
|
||||
@Log(title = "设备信息", businessType = BusinessType.DELETE)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.dromara.app.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
@@ -18,7 +17,7 @@ public class AppDevice extends BaseEntity {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "device_no", type = IdType.INPUT)
|
||||
@TableId(value = "device_no")
|
||||
private String deviceNo;
|
||||
|
||||
private String bindTokenHash;
|
||||
@@ -29,6 +28,8 @@ public class AppDevice extends BaseEntity {
|
||||
|
||||
private String deviceImg;
|
||||
|
||||
private String qrcode;
|
||||
|
||||
private String status;
|
||||
|
||||
private String workStatus;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package org.dromara.app.domain;
|
||||
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import java.util.Date;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 浇水记录对象 app_watering_log
|
||||
@@ -74,6 +74,11 @@ public class AppWateringLog extends BaseEntity {
|
||||
*/
|
||||
private String triggerType;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.dromara.app.domain.bo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.app.domain.AppDevice;
|
||||
@@ -8,8 +9,6 @@ import org.dromara.common.core.validate.AddGroup;
|
||||
import org.dromara.common.core.validate.EditGroup;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@@ -17,14 +16,13 @@ import java.util.Date;
|
||||
@AutoMapper(target = AppDevice.class, reverseConvertGenerate = false)
|
||||
public class AppDeviceBo extends BaseEntity {
|
||||
|
||||
@NotBlank(message = "设备编号不能为空", groups = { AddGroup.class, EditGroup.class })
|
||||
@NotBlank(message = "设备编号不能为空", groups = { EditGroup.class })
|
||||
private String deviceNo;
|
||||
|
||||
private String bindToken;
|
||||
|
||||
private String bindTokenHash;
|
||||
|
||||
@NotNull(message = "用户id不能为空", groups = { AddGroup.class, EditGroup.class })
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
@@ -37,6 +35,11 @@ public class AppDeviceBo extends BaseEntity {
|
||||
*/
|
||||
private String deviceImg;
|
||||
|
||||
/**
|
||||
* 二维码
|
||||
*/
|
||||
private String qrcode;
|
||||
|
||||
/**
|
||||
* 状态 1-在线 0-离线 2-到期 3-故障
|
||||
*/
|
||||
@@ -85,6 +88,7 @@ public class AppDeviceBo extends BaseEntity {
|
||||
/**
|
||||
* mac地址
|
||||
*/
|
||||
@NotBlank(message = "MAC地址不能为空", groups = { AddGroup.class })
|
||||
private String macAddress;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
package org.dromara.app.domain.bo;
|
||||
|
||||
import org.dromara.app.domain.AppWateringLog;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
import org.dromara.common.core.validate.AddGroup;
|
||||
import org.dromara.common.core.validate.EditGroup;
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.dromara.app.domain.AppWateringLog;
|
||||
import org.dromara.common.core.validate.EditGroup;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
import java.util.Date;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
|
||||
/**
|
||||
* 浇水记录业务对象 app_watering_log
|
||||
@@ -73,6 +72,11 @@ public class AppWateringLogBo extends BaseEntity {
|
||||
*/
|
||||
private String triggerType;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,9 @@ public class AppDeviceVo implements Serializable {
|
||||
@ExcelProperty(value = "设备图片")
|
||||
private String deviceImg;
|
||||
|
||||
@ExcelProperty(value = "二维码")
|
||||
private String qrcode;
|
||||
|
||||
@ExcelProperty(value = "状态")
|
||||
private String status;
|
||||
|
||||
@@ -59,6 +62,9 @@ public class AppDeviceVo implements Serializable {
|
||||
@ExcelProperty(value = "mac地址")
|
||||
private String macAddress;
|
||||
|
||||
@ExcelProperty(value = "用户名称")
|
||||
private String nickName;
|
||||
|
||||
@ExcelProperty(value = "到期时间")
|
||||
private Date expirationTime;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
package org.dromara.app.domain.vo;
|
||||
|
||||
import java.util.Date;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import org.dromara.app.domain.AppWateringLog;
|
||||
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import org.dromara.common.excel.annotation.ExcelDictFormat;
|
||||
import org.dromara.common.excel.convert.ExcelDictConvert;
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.dromara.app.domain.AppWateringLog;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
@@ -91,6 +87,12 @@ public class AppWateringLogVo implements Serializable {
|
||||
private String triggerType;
|
||||
private String deviceName;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
@ExcelProperty(value = "备注")
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.dromara.app.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 仪表盘统计指标 VO。
|
||||
* <p>
|
||||
* 对应前端首页 8 个统计卡片的数据。
|
||||
*
|
||||
* @author water team
|
||||
*/
|
||||
@Data
|
||||
public class DashboardStatsVo implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 设备总数 */
|
||||
private Long totalDevices;
|
||||
|
||||
/** 在线设备数(status='1') */
|
||||
private Long onlineDevices;
|
||||
|
||||
/** 待机设备数(status='2') */
|
||||
private Long standbyDevices;
|
||||
|
||||
/** 离线设备数(status='0') */
|
||||
private Long offlineDevices;
|
||||
|
||||
/** 故障设备数(status='3') */
|
||||
private Long faultDevices;
|
||||
|
||||
/** 工作中设备数(workStatus='1') */
|
||||
private Long workingDevices;
|
||||
|
||||
/** 空闲设备数(workStatus='0') */
|
||||
private Long idleDevices;
|
||||
|
||||
/** 未绑定用户设备数(user_id IS NULL) */
|
||||
private Long unboundDevices;
|
||||
}
|
||||
@@ -18,9 +18,11 @@ public class DeviceCommandAckHandler implements MqttTopicHandler {
|
||||
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/ack$");
|
||||
|
||||
private final IDeviceCommandAckHandler delegate;
|
||||
private final DeviceIdentityResolver deviceIdentityResolver;
|
||||
|
||||
public DeviceCommandAckHandler(IDeviceCommandAckHandler delegate) {
|
||||
public DeviceCommandAckHandler(IDeviceCommandAckHandler delegate, DeviceIdentityResolver deviceIdentityResolver) {
|
||||
this.delegate = delegate;
|
||||
this.deviceIdentityResolver = deviceIdentityResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -29,7 +31,12 @@ public class DeviceCommandAckHandler implements MqttTopicHandler {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(String deviceNo, String payload) {
|
||||
public void handle(String deviceIdentity, String payload) {
|
||||
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
|
||||
if (deviceNo == null) {
|
||||
log.warn("[MQTT] ACK 未找到设备 设备标识={} 消息体={}", deviceIdentity, payload);
|
||||
return;
|
||||
}
|
||||
delegate.handleAck(deviceNo, payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.dromara.app.mqtt.MqttTopicHandler;
|
||||
import org.dromara.app.service.IAppDeviceService;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.redisson.api.RLock;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -18,6 +19,7 @@ import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
@@ -31,12 +33,14 @@ public class DeviceDataHandler implements MqttTopicHandler {
|
||||
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/power$");
|
||||
private final IAppDeviceService appDeviceService;
|
||||
private final AppDeviceMapper appDeviceMapper;
|
||||
private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:";
|
||||
|
||||
@Value("${mqtt.command-ack.device-status-cache-prefix}")
|
||||
private String deviceStatusCachePrefix;
|
||||
|
||||
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds}")
|
||||
private int deviceStatusCacheTtlSeconds;
|
||||
private final DeviceIdentityResolver deviceIdentityResolver;
|
||||
|
||||
@Override
|
||||
public Pattern topicPattern() {
|
||||
@@ -44,8 +48,15 @@ public class DeviceDataHandler implements MqttTopicHandler {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(String deviceNo, String payload) {
|
||||
public void handle(String deviceIdentity, String payload) {
|
||||
String deviceNo = null;
|
||||
try {
|
||||
deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
|
||||
if (deviceNo == null) {
|
||||
log.warn("[MQTT] 设备电量上报未找到设备 时间={} 设备标识={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceIdentity, payload);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
|
||||
if (dto == null || dto.get("powerLevel") == null) {
|
||||
log.warn("[MQTT] 设备电量上报缺少电量字段 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
|
||||
@@ -63,7 +74,8 @@ public class DeviceDataHandler implements MqttTopicHandler {
|
||||
refreshDeviceOnline(deviceNo);
|
||||
log.info("[MQTT] 设备电量更新 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
|
||||
} catch (Exception e) {
|
||||
log.error("[MQTT] 设备电量更新失败 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload, e);
|
||||
log.error("[MQTT] 设备电量更新失败 时间={} 设备标识={} 设备编号={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceIdentity, deviceNo, payload, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,19 +84,29 @@ public class DeviceDataHandler implements MqttTopicHandler {
|
||||
* 写入 Redis 后 TTL 到期自动过期 = 离线
|
||||
*/
|
||||
private void refreshDeviceOnline(String deviceNo) {
|
||||
Map<String, Object> statusCache = new HashMap<>();
|
||||
statusCache.put("deviceNo", deviceNo);
|
||||
statusCache.put("status", "1");
|
||||
statusCache.put("lastReportTime", Instant.now().toString());
|
||||
RedisUtils.setCacheObject(
|
||||
deviceStatusCachePrefix + deviceNo,
|
||||
statusCache,
|
||||
Duration.ofSeconds(deviceStatusCacheTtlSeconds)
|
||||
);
|
||||
appDeviceMapper.update(null,
|
||||
new LambdaUpdateWrapper<AppDevice>()
|
||||
.set(AppDevice::getStatus, "1")
|
||||
.eq(AppDevice::getDeviceNo, deviceNo)
|
||||
);
|
||||
RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo);
|
||||
boolean locked = false;
|
||||
try {
|
||||
lock.lock(10, TimeUnit.SECONDS);
|
||||
locked = true;
|
||||
Map<String, Object> statusCache = new HashMap<>();
|
||||
statusCache.put("deviceNo", deviceNo);
|
||||
statusCache.put("status", "1");
|
||||
statusCache.put("lastReportTime", Instant.now().toString());
|
||||
RedisUtils.setCacheObject(
|
||||
deviceStatusCachePrefix + deviceNo,
|
||||
statusCache,
|
||||
Duration.ofSeconds(deviceStatusCacheTtlSeconds)
|
||||
);
|
||||
appDeviceMapper.update(null,
|
||||
new LambdaUpdateWrapper<AppDevice>()
|
||||
.set(AppDevice::getStatus, "1")
|
||||
.eq(AppDevice::getDeviceNo, deviceNo)
|
||||
);
|
||||
} finally {
|
||||
if (locked && lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.dromara.app.handler;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.app.domain.AppDevice;
|
||||
import org.dromara.app.mapper.AppDeviceMapper;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 解析 MQTT Topic 中的设备标识。
|
||||
* <p>
|
||||
* Topic 第一段允许传设备编号或 MAC 地址。业务处理统一转换为数据库中的设备编号。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceIdentityResolver {
|
||||
|
||||
private final AppDeviceMapper appDeviceMapper;
|
||||
|
||||
public AppDevice resolve(String identity) {
|
||||
if (StringUtils.isBlank(identity)) {
|
||||
return null;
|
||||
}
|
||||
String text = identity.trim();
|
||||
AppDevice device = appDeviceMapper.selectById(text);
|
||||
if (device != null) {
|
||||
return device;
|
||||
}
|
||||
return appDeviceMapper.selectByMac(normalizeMacAddress(text));
|
||||
}
|
||||
|
||||
public String resolveDeviceNo(String identity) {
|
||||
AppDevice device = resolve(identity);
|
||||
return device == null ? null : device.getDeviceNo();
|
||||
}
|
||||
|
||||
public String normalizeMacAddress(String macAddress) {
|
||||
if (StringUtils.isBlank(macAddress)) {
|
||||
return null;
|
||||
}
|
||||
return macAddress.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -5,38 +5,50 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.app.domain.AppDevice;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.domain.mqtt.DeviceCommand;
|
||||
import org.dromara.app.mapper.AppDeviceMapper;
|
||||
import org.dromara.app.mqtt.MqttTopicHandler;
|
||||
import org.dromara.app.service.IAppDeviceService;
|
||||
import org.dromara.app.service.IDeviceCommandPublisher;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.redisson.api.RLock;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Device registration handler, matches /{deviceNo}/publish/register.
|
||||
* 设备注册处理器,匹配 /{deviceMac}/publish/register。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceRegisterHandler implements MqttTopicHandler {
|
||||
public class
|
||||
DeviceRegisterHandler implements MqttTopicHandler {
|
||||
|
||||
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/register$");
|
||||
|
||||
private final IAppDeviceService appDeviceService;
|
||||
private final AppDeviceMapper appDeviceMapper;
|
||||
private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:";
|
||||
private final ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider;
|
||||
|
||||
@Value("${mqtt.command-ack.device-status-cache-prefix}")
|
||||
private String deviceStatusCachePrefix;
|
||||
|
||||
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds}")
|
||||
private int deviceStatusCacheTtlSeconds;
|
||||
private final DeviceIdentityResolver deviceIdentityResolver;
|
||||
@Value("${mqtt.topics.publish-prefix:}")
|
||||
private String mqttPublishPrefix;
|
||||
|
||||
@Override
|
||||
public Pattern topicPattern() {
|
||||
@@ -44,22 +56,40 @@ public class DeviceRegisterHandler implements MqttTopicHandler {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(String deviceNo, String payload) {
|
||||
public void handle(String deviceIdentity, String payload) {
|
||||
try {
|
||||
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
|
||||
AppDeviceBo device = buildRegisterDevice(deviceNo, dto);
|
||||
AppDevice exists = deviceIdentityResolver.resolve(deviceIdentity);
|
||||
String normalizedDeviceMac = resolveDeviceMac(deviceIdentity, dto);
|
||||
if (exists == null && normalizedDeviceMac != null) {
|
||||
exists = appDeviceMapper.selectByMac(normalizedDeviceMac);
|
||||
}
|
||||
if (exists == null) {
|
||||
log.warn("[MQTT] 设备注册失败,设备标识未入库 时间={} 设备标识={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceIdentity, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
String deviceNo = exists.getDeviceNo();
|
||||
if (normalizedDeviceMac == null) {
|
||||
normalizedDeviceMac = deviceIdentityResolver.normalizeMacAddress(exists.getMacAddress());
|
||||
}
|
||||
AppDeviceBo device = buildRegisterDevice(deviceNo, normalizedDeviceMac, dto);
|
||||
appDeviceService.registerByMqtt(device);
|
||||
// 注册即在线
|
||||
refreshDeviceOnline(deviceNo);
|
||||
log.info("[MQTT] 设备注册 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
|
||||
String commandTarget = normalizedDeviceMac == null ? deviceIdentity : normalizedDeviceMac;
|
||||
sendDeviceNoToDevice(commandTarget, deviceNo);
|
||||
log.info("[MQTT] 设备注册 时间={} MAC={} 设备编号={} 消息体={}",
|
||||
HandlerLogTime.now(), normalizedDeviceMac, deviceNo, payload);
|
||||
} catch (Exception e) {
|
||||
log.error("[MQTT] 设备注册失败 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload, e);
|
||||
log.error("[MQTT] 设备注册失败 时间={} 设备标识={} 消息体={}", HandlerLogTime.now(), deviceIdentity, payload, e);
|
||||
}
|
||||
}
|
||||
|
||||
private AppDeviceBo buildRegisterDevice(String topicDeviceNo, Map<String, Object> dto) {
|
||||
private AppDeviceBo buildRegisterDevice(String deviceNo, String deviceMac, Map<String, Object> dto) {
|
||||
AppDeviceBo device = new AppDeviceBo();
|
||||
device.setDeviceNo(topicDeviceNo);
|
||||
device.setDeviceNo(deviceNo);
|
||||
device.setMacAddress(deviceMac);
|
||||
device.setStatus("1"); // 注册即在线
|
||||
if (dto == null) {
|
||||
return device;
|
||||
@@ -68,11 +98,59 @@ public class DeviceRegisterHandler implements MqttTopicHandler {
|
||||
device.setPowerLevel(valueAsString(dto.get("powerLevel")));
|
||||
device.setDeviceEm(valueAsString(dto.get("deviceEm")));
|
||||
device.setDeviceSn(valueAsString(dto.get("deviceSn")));
|
||||
device.setMacAddress(firstNotBlank(dto.get("deviceMac"), dto.get("macAddress")));
|
||||
device.setFwVer(firstNotBlank(dto.get("version"), dto.get("fwVer")));
|
||||
return device;
|
||||
}
|
||||
|
||||
private String resolveDeviceMac(String topicDeviceMac, Map<String, Object> dto) {
|
||||
String payloadMac = dto == null ? null : firstNotBlank(dto.get("deviceMac"), dto.get("macAddress"));
|
||||
return normalizeMacAddress(firstNotBlank(payloadMac, topicDeviceMac));
|
||||
}
|
||||
|
||||
private String normalizeMacAddress(String macAddress) {
|
||||
if (macAddress == null) {
|
||||
return null;
|
||||
}
|
||||
String text = macAddress.trim();
|
||||
return text.isBlank() ? null : text.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private void sendDeviceNoToDevice(String deviceMac, String deviceNo) {
|
||||
IDeviceCommandPublisher commandPublisher = commandPublisherProvider.getIfAvailable();
|
||||
if (commandPublisher == null) {
|
||||
log.warn("[MQTT] 设备编号下发失败,未找到命令发布器 时间={} MAC={} 设备编号={}",
|
||||
HandlerLogTime.now(), deviceMac, deviceNo);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
DeviceCommand command = new DeviceCommand();
|
||||
command.setDeviceNo(deviceNo);
|
||||
command.setCommandType("registerDeviceNo");
|
||||
command.setTopic(buildCommandTopic(deviceMac));
|
||||
command.getPayload().put("deviceNo", deviceNo);
|
||||
command.getPayload().put("deviceMac", deviceMac);
|
||||
|
||||
String commandId = commandPublisher.send(command);
|
||||
log.info("[MQTT] 设备编号已下发 时间={} MAC={} 设备编号={} 命令编号={}",
|
||||
HandlerLogTime.now(), deviceMac, deviceNo, commandId);
|
||||
} catch (Exception e) {
|
||||
log.error("[MQTT] 设备编号下发失败 时间={} MAC={} 设备编号={}",
|
||||
HandlerLogTime.now(), deviceMac, deviceNo, e);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildCommandTopic(String deviceMac) {
|
||||
if (mqttPublishPrefix == null || mqttPublishPrefix.isBlank()) {
|
||||
return "/" + deviceMac + "/subscriber/cmd";
|
||||
}
|
||||
String normalizedPrefix = mqttPublishPrefix.startsWith("/") ? mqttPublishPrefix : "/" + mqttPublishPrefix;
|
||||
if (normalizedPrefix.endsWith("/")) {
|
||||
normalizedPrefix = normalizedPrefix.substring(0, normalizedPrefix.length() - 1);
|
||||
}
|
||||
return normalizedPrefix + "/" + deviceMac + "/subscriber/cmd";
|
||||
}
|
||||
|
||||
private String firstNotBlank(Object first, Object second) {
|
||||
String firstText = valueAsString(first);
|
||||
if (firstText != null && !firstText.isBlank()) {
|
||||
@@ -90,19 +168,29 @@ public class DeviceRegisterHandler implements MqttTopicHandler {
|
||||
* 刷新设备在线状态到 Redis
|
||||
*/
|
||||
private void refreshDeviceOnline(String deviceNo) {
|
||||
Map<String, Object> statusCache = new HashMap<>();
|
||||
statusCache.put("deviceNo", deviceNo);
|
||||
statusCache.put("status", "1");
|
||||
statusCache.put("lastReportTime", Instant.now().toString());
|
||||
RedisUtils.setCacheObject(
|
||||
deviceStatusCachePrefix + deviceNo,
|
||||
statusCache,
|
||||
Duration.ofSeconds(deviceStatusCacheTtlSeconds)
|
||||
);
|
||||
appDeviceMapper.update(null,
|
||||
new LambdaUpdateWrapper<AppDevice>()
|
||||
.set(AppDevice::getStatus, "1")
|
||||
.eq(AppDevice::getDeviceNo, deviceNo)
|
||||
);
|
||||
RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo);
|
||||
boolean locked = false;
|
||||
try {
|
||||
lock.lock(10, TimeUnit.SECONDS);
|
||||
locked = true;
|
||||
Map<String, Object> statusCache = new HashMap<>();
|
||||
statusCache.put("deviceNo", deviceNo);
|
||||
statusCache.put("status", "1");
|
||||
statusCache.put("lastReportTime", Instant.now().toString());
|
||||
RedisUtils.setCacheObject(
|
||||
deviceStatusCachePrefix + deviceNo,
|
||||
statusCache,
|
||||
Duration.ofSeconds(deviceStatusCacheTtlSeconds)
|
||||
);
|
||||
appDeviceMapper.update(null,
|
||||
new LambdaUpdateWrapper<AppDevice>()
|
||||
.set(AppDevice::getStatus, "1")
|
||||
.eq(AppDevice::getDeviceNo, deviceNo)
|
||||
);
|
||||
} finally {
|
||||
if (locked && lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import java.util.regex.Pattern;
|
||||
public class ErromesHandler implements MqttTopicHandler {
|
||||
|
||||
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/error$");
|
||||
private final DeviceIdentityResolver deviceIdentityResolver;
|
||||
|
||||
@Override
|
||||
public Pattern topicPattern() {
|
||||
@@ -25,12 +26,19 @@ public class ErromesHandler implements MqttTopicHandler {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(String deviceNo, String payload) {
|
||||
public void handle(String deviceIdentity, String payload) {
|
||||
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
|
||||
try {
|
||||
if (deviceNo == null) {
|
||||
log.warn("[MQTT] 设备异常告警未找到设备 时间={} 设备标识={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceIdentity, payload);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
|
||||
log.info("[MQTT] 设备异常告警 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, dto);
|
||||
} catch (Exception e) {
|
||||
log.error("[MQTT] 设备异常处理失败 时间={} 设备编号={}", HandlerLogTime.now(), deviceNo, e);
|
||||
log.error("[MQTT] 设备异常处理失败 时间={} 设备标识={} 设备编号={}",
|
||||
HandlerLogTime.now(), deviceIdentity, deviceNo, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.dromara.app.handler;
|
||||
|
||||
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.AppWateringLogBo;
|
||||
import org.dromara.app.domain.vo.AppDeviceVo;
|
||||
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
|
||||
import org.dromara.app.mqtt.MqttTopicHandler;
|
||||
import org.dromara.app.service.IAppDeviceService;
|
||||
import org.dromara.app.service.IAppWateringLogService;
|
||||
@@ -14,6 +17,7 @@ import org.springframework.stereotype.Component;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -29,6 +33,8 @@ public class KeyFinishHandler implements MqttTopicHandler {
|
||||
|
||||
private final IAppWateringLogService appWateringLogService;
|
||||
private final IAppDeviceService appDeviceService;
|
||||
private final AppSchedulingDeviceMapper schedulingDeviceMapper;
|
||||
private final DeviceIdentityResolver deviceIdentityResolver;
|
||||
|
||||
@Override
|
||||
public Pattern topicPattern() {
|
||||
@@ -36,14 +42,26 @@ public class KeyFinishHandler implements MqttTopicHandler {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(String deviceNo, String payload) {
|
||||
public void handle(String deviceIdentity, String payload) {
|
||||
String deviceNo = null;
|
||||
try {
|
||||
deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
|
||||
if (deviceNo == null) {
|
||||
log.warn("[MQTT] 按键浇水完成上报未找到设备 时间={} 设备标识={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceIdentity, payload);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
|
||||
AppWateringLogBo logBo = buildWateringLog(deviceNo, dto);
|
||||
appWateringLogService.insertByBo(logBo);
|
||||
if ("0".equals(logBo.getTriggerType())) {
|
||||
appWateringLogService.confirmScheduleLog(logBo);
|
||||
} else {
|
||||
appWateringLogService.insertByBo(logBo);
|
||||
}
|
||||
log.info("[MQTT] 按键浇水完成上报已保存 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
|
||||
} catch (Exception e) {
|
||||
log.error("[MQTT] 按键浇水完成上报处理失败 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload, e);
|
||||
log.error("[MQTT] 按键浇水完成上报处理失败 时间={} 设备标识={} 设备编号={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceIdentity, deviceNo, payload, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,9 +91,11 @@ public class KeyFinishHandler implements MqttTopicHandler {
|
||||
logBo.setTriggerType("1");
|
||||
if ("schedule".equals(valueAsString(dto.get("triggerON")))){
|
||||
logBo.setTriggerType("0");
|
||||
logBo.setScheduleId(queryScheduleId(topicDeviceNo));
|
||||
}
|
||||
logBo.setCreateTime(receivedAt);
|
||||
logBo.setEndTime(endTime);
|
||||
logBo.setRemark("0".equals(logBo.getTriggerType()) ? "设备上报排程浇水完成" : "设备按键完成浇水");
|
||||
return logBo;
|
||||
}
|
||||
|
||||
@@ -84,6 +104,21 @@ public class KeyFinishHandler implements MqttTopicHandler {
|
||||
return device == null ? null : device.getUserId();
|
||||
}
|
||||
|
||||
private Long queryScheduleId(String deviceNo) {
|
||||
List<AppSchedulingDevice> schedulingDevices = schedulingDeviceMapper.selectList(
|
||||
Wrappers.<AppSchedulingDevice>lambdaQuery()
|
||||
.eq(AppSchedulingDevice::getDeviceNo, deviceNo)
|
||||
);
|
||||
if (schedulingDevices == null || schedulingDevices.isEmpty()) {
|
||||
log.warn("[MQTT] 按键浇水完成上报未找到设备绑定的排程 时间={} 设备编号={}", HandlerLogTime.now(), deviceNo);
|
||||
return 0L;
|
||||
}
|
||||
if (schedulingDevices.size() > 1) {
|
||||
log.warn("[MQTT] 按键浇水完成上报匹配到多个排程 时间={} 设备编号={} 数量={}", HandlerLogTime.now(), deviceNo, schedulingDevices.size());
|
||||
}
|
||||
return schedulingDevices.get(0).getScheduleId();
|
||||
}
|
||||
|
||||
private Date parseStartTime(String startTime) {
|
||||
if (StringUtils.isBlank(startTime)) {
|
||||
return new Date();
|
||||
|
||||
@@ -12,6 +12,7 @@ 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.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.text.ParseException;
|
||||
@@ -34,6 +35,7 @@ public class ScheduleFinishHandler implements MqttTopicHandler {
|
||||
private final IAppWateringLogService appWateringLogService;
|
||||
private final IAppDeviceService appDeviceService;
|
||||
private final AppSchedulingDeviceMapper schedulingDeviceMapper;
|
||||
private final DeviceIdentityResolver deviceIdentityResolver;
|
||||
|
||||
@Override
|
||||
public Pattern topicPattern() {
|
||||
@@ -41,14 +43,23 @@ public class ScheduleFinishHandler implements MqttTopicHandler {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(String deviceNo, String payload) {
|
||||
public void handle(String deviceIdentity, String payload) {
|
||||
String deviceNo = null;
|
||||
try {
|
||||
deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
|
||||
if (deviceNo == null) {
|
||||
log.warn("[MQTT] 浇水排程上报未找到设备 时间={} 设备标识={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceIdentity, payload);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
|
||||
AppWateringLogBo logBo = buildWateringLog(deviceNo, dto);
|
||||
appWateringLogService.insertByBo(logBo);
|
||||
logBo.setUserId(LoginHelper.getUserId());
|
||||
appWateringLogService.confirmScheduleLog(logBo);
|
||||
log.info("[MQTT] 浇水排程上报已保存 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
|
||||
} catch (Exception e) {
|
||||
log.error("[MQTT] 浇水排程上报处理失败 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload, e);
|
||||
log.error("[MQTT] 浇水排程上报处理失败 时间={} 设备标识={} 设备编号={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceIdentity, deviceNo, payload, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +93,7 @@ public class ScheduleFinishHandler implements MqttTopicHandler {
|
||||
}
|
||||
logBo.setCreateTime(receivedAt);
|
||||
logBo.setEndTime(endTime);
|
||||
logBo.setRemark("排程浇水完成");
|
||||
return logBo;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
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.apache.ibatis.annotations.Update;
|
||||
import org.dromara.app.domain.AppDevice;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
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.List;
|
||||
|
||||
/**
|
||||
* 设备信息
|
||||
Mapper接口
|
||||
@@ -16,6 +21,154 @@ Mapper接口
|
||||
*/
|
||||
public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo> {
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
select
|
||||
d.device_no,
|
||||
d.user_id,
|
||||
d.device_name,
|
||||
d.device_img,
|
||||
d.qrcode,
|
||||
d.status,
|
||||
d.work_status,
|
||||
d.power_level,
|
||||
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
|
||||
from app_device d
|
||||
left join sys_user u on u.user_id = d.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}
|
||||
</if>
|
||||
<if test="bo.deviceName != null and bo.deviceName != ''">
|
||||
and d.device_name like concat('%', #{bo.deviceName}, '%')
|
||||
</if>
|
||||
<if test="bo.deviceImg != null and bo.deviceImg != ''">
|
||||
and d.device_img = #{bo.deviceImg}
|
||||
</if>
|
||||
<if test="bo.qrcode != null and bo.qrcode != ''">
|
||||
and d.qrcode = #{bo.qrcode}
|
||||
</if>
|
||||
<if test="bo.status != null and bo.status != ''">
|
||||
and d.status = #{bo.status}
|
||||
</if>
|
||||
<if test="bo.powerLevel != null and bo.powerLevel != ''">
|
||||
and d.power_level = #{bo.powerLevel}
|
||||
</if>
|
||||
<if test="bo.powerLevelUpdatatime != null">
|
||||
and d.power_level_updatatime = #{bo.powerLevelUpdatatime}
|
||||
</if>
|
||||
<if test="bo.wifiName != null and bo.wifiName != ''">
|
||||
and d.wifi_name like concat('%', #{bo.wifiName}, '%')
|
||||
</if>
|
||||
<if test="bo.wifiPassword != null and bo.wifiPassword != ''">
|
||||
and d.wifi_password = #{bo.wifiPassword}
|
||||
</if>
|
||||
<if test="bo.deviceEm != null and bo.deviceEm != ''">
|
||||
and d.device_em = #{bo.deviceEm}
|
||||
</if>
|
||||
<if test="bo.deviceSn != null and bo.deviceSn != ''">
|
||||
and d.device_sn = #{bo.deviceSn}
|
||||
</if>
|
||||
<if test="bo.fwVer != null and bo.fwVer != ''">
|
||||
and d.fw_ver = #{bo.fwVer}
|
||||
</if>
|
||||
<if test="bo.macAddress != null and bo.macAddress != ''">
|
||||
and d.mac_address = #{bo.macAddress}
|
||||
</if>
|
||||
<if test="bo.expirationTime != null">
|
||||
and d.expiration_time = #{bo.expirationTime}
|
||||
</if>
|
||||
</where>
|
||||
order by d.device_no asc
|
||||
</script>
|
||||
""")
|
||||
Page<AppDeviceVo> selectDeviceVoPage(Page<AppDeviceVo> page, @Param("bo") AppDeviceBo bo);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
select
|
||||
d.device_no,
|
||||
d.user_id,
|
||||
d.device_name,
|
||||
d.device_img,
|
||||
d.qrcode,
|
||||
d.status,
|
||||
d.work_status,
|
||||
d.power_level,
|
||||
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
|
||||
from app_device d
|
||||
left join sys_user u on u.user_id = d.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}
|
||||
</if>
|
||||
<if test="bo.deviceName != null and bo.deviceName != ''">
|
||||
and d.device_name like concat('%', #{bo.deviceName}, '%')
|
||||
</if>
|
||||
<if test="bo.deviceImg != null and bo.deviceImg != ''">
|
||||
and d.device_img = #{bo.deviceImg}
|
||||
</if>
|
||||
<if test="bo.qrcode != null and bo.qrcode != ''">
|
||||
and d.qrcode = #{bo.qrcode}
|
||||
</if>
|
||||
<if test="bo.status != null and bo.status != ''">
|
||||
and d.status = #{bo.status}
|
||||
</if>
|
||||
<if test="bo.powerLevel != null and bo.powerLevel != ''">
|
||||
and d.power_level = #{bo.powerLevel}
|
||||
</if>
|
||||
<if test="bo.powerLevelUpdatatime != null">
|
||||
and d.power_level_updatatime = #{bo.powerLevelUpdatatime}
|
||||
</if>
|
||||
<if test="bo.wifiName != null and bo.wifiName != ''">
|
||||
and d.wifi_name like concat('%', #{bo.wifiName}, '%')
|
||||
</if>
|
||||
<if test="bo.wifiPassword != null and bo.wifiPassword != ''">
|
||||
and d.wifi_password = #{bo.wifiPassword}
|
||||
</if>
|
||||
<if test="bo.deviceEm != null and bo.deviceEm != ''">
|
||||
and d.device_em = #{bo.deviceEm}
|
||||
</if>
|
||||
<if test="bo.deviceSn != null and bo.deviceSn != ''">
|
||||
and d.device_sn = #{bo.deviceSn}
|
||||
</if>
|
||||
<if test="bo.fwVer != null and bo.fwVer != ''">
|
||||
and d.fw_ver = #{bo.fwVer}
|
||||
</if>
|
||||
<if test="bo.macAddress != null and bo.macAddress != ''">
|
||||
and d.mac_address = #{bo.macAddress}
|
||||
</if>
|
||||
<if test="bo.expirationTime != null">
|
||||
and d.expiration_time = #{bo.expirationTime}
|
||||
</if>
|
||||
</where>
|
||||
order by d.device_no asc
|
||||
</script>
|
||||
""")
|
||||
List<AppDeviceVo> selectDeviceVoList(@Param("bo") AppDeviceBo bo);
|
||||
|
||||
@Update("""
|
||||
<script>
|
||||
update app_device
|
||||
@@ -63,8 +216,25 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
|
||||
@Select("""
|
||||
select *
|
||||
from app_device
|
||||
where mac_address = #{macAddress}
|
||||
where lower(mac_address) = lower(#{macAddress})
|
||||
""")
|
||||
AppDevice selectByMac(@Param("macAddress") String macAddress);
|
||||
|
||||
/**
|
||||
* 仪表盘统计 — 单条 SQL 条件聚合,一次查询返回全部 8 个指标。
|
||||
*/
|
||||
@Select("""
|
||||
select
|
||||
count(*) as totalDevices,
|
||||
sum(case when status = '1' then 1 else 0 end) as onlineDevices,
|
||||
sum(case when status = '2' then 1 else 0 end) as standbyDevices,
|
||||
sum(case when status = '0' then 1 else 0 end) as offlineDevices,
|
||||
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
|
||||
""")
|
||||
DashboardStatsVo selectDashboardStats();
|
||||
|
||||
}
|
||||
|
||||
@@ -38,8 +38,8 @@ public class MqttMessageDispatcher {
|
||||
for (MqttTopicHandler handler : handlers) {
|
||||
Matcher matcher = handler.topicPattern().matcher(topic);
|
||||
if (matcher.matches()) {
|
||||
String deviceNo = matcher.group(1);
|
||||
handler.handle(deviceNo, payload);
|
||||
String deviceIdentity = matcher.group(1);
|
||||
handler.handle(deviceIdentity, payload);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,15 @@ public interface MqttTopicHandler {
|
||||
|
||||
/**
|
||||
* 返回该处理器匹配的 Topic 正则。
|
||||
* 必须包含一个捕获组用于提取 deviceNo。
|
||||
* 必须包含一个捕获组用于提取设备标识(设备编号或 MAC 地址)。
|
||||
*/
|
||||
Pattern topicPattern();
|
||||
|
||||
/**
|
||||
* 处理匹配到的消息。
|
||||
*
|
||||
* @param deviceNo 从 Topic 正则中提取的设备编号
|
||||
* @param payload 消息体(JSON)
|
||||
* @param deviceIdentity 从 Topic 正则中提取的设备标识(设备编号或 MAC 地址)
|
||||
* @param payload 消息体(JSON)
|
||||
*/
|
||||
void handle(String deviceNo, String payload);
|
||||
void handle(String deviceIdentity, String payload);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.dromara.app.service;
|
||||
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.domain.vo.AppDeviceVo;
|
||||
import org.dromara.app.domain.vo.DashboardStatsVo;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
|
||||
@@ -87,6 +88,14 @@ public interface IAppDeviceService {
|
||||
|
||||
Boolean switchDevice(String deviceNo, String workStatus, String startTime, Integer durationMin);
|
||||
|
||||
/**
|
||||
* 批量生成设备二维码并保存到设备二维码字段
|
||||
*
|
||||
* @param deviceNos 设备编号集合
|
||||
* @return 是否生成成功
|
||||
*/
|
||||
Boolean generateQrCode(Collection<String> deviceNos);
|
||||
|
||||
/**
|
||||
* 校验并批量删除设备信息
|
||||
信息
|
||||
@@ -97,5 +106,21 @@ public interface IAppDeviceService {
|
||||
*/
|
||||
Boolean deleteWithValidByIds(Collection<String> deviceNos, Boolean isValid);
|
||||
|
||||
/**
|
||||
* 解绑设备:清空设备用户、删除设备关联数据,并下发设备初始化指令。
|
||||
*
|
||||
* @param deviceNos 设备编号集合
|
||||
* @param userId 当前用户ID
|
||||
* @return 是否解绑成功
|
||||
*/
|
||||
Boolean unbindDevices(Collection<String> deviceNos, Long userId);
|
||||
|
||||
Map<String, Object> bindDeviceStatus(AppDeviceBo appDevice);
|
||||
|
||||
/**
|
||||
* 仪表盘统计 — 返回首页 8 个统计卡片数据。
|
||||
*
|
||||
* @return 仪表盘统计 VO(设备总数/在线/待机/离线/故障/工作中/空闲/未绑定用户)
|
||||
*/
|
||||
DashboardStatsVo getDashboardStats();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package org.dromara.app.service;
|
||||
|
||||
import org.dromara.app.domain.vo.AppWateringLogVo;
|
||||
import org.dromara.app.domain.bo.AppWateringLogBo;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
import org.dromara.app.domain.vo.AppWateringLogVo;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
@@ -68,6 +68,43 @@ public interface IAppWateringLogService {
|
||||
*/
|
||||
Boolean finishManualLog(String deviceNo, Long userId, Date endTime);
|
||||
|
||||
/**
|
||||
* 结束当前手动浇水记录。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @param userId 用户ID
|
||||
* @param endTime 结束时间
|
||||
* @param remark 备注
|
||||
* @return 是否更新成功
|
||||
*/
|
||||
Boolean finishManualLog(String deviceNo, Long userId, Date endTime, String remark);
|
||||
|
||||
/**
|
||||
* 结束指定设备所有进行中的浇水记录。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @param endTime 结束时间
|
||||
* @param remark 备注
|
||||
* @return 更新数量
|
||||
*/
|
||||
int finishRunningLogsByDevice(String deviceNo, Date endTime, String remark);
|
||||
|
||||
/**
|
||||
* 保存设备离线期间按排程推算出的浇水记录,已存在则跳过。
|
||||
*
|
||||
* @param bo 浇水记录
|
||||
* @return 是否新增成功
|
||||
*/
|
||||
Boolean saveEstimatedScheduleLog(AppWateringLogBo bo);
|
||||
|
||||
/**
|
||||
* 保存设备上报的排程浇水记录。若已有离线预估记录,则更新为设备确认记录。
|
||||
*
|
||||
* @param bo 浇水记录
|
||||
* @return 是否保存成功
|
||||
*/
|
||||
Boolean confirmScheduleLog(AppWateringLogBo bo);
|
||||
|
||||
/**
|
||||
* 校验并批量删除浇水记录信息
|
||||
*
|
||||
|
||||
@@ -42,4 +42,12 @@ public interface IDeviceCommandService {
|
||||
*/
|
||||
String sendCustomCommand(String deviceNo, String commandType, Map<String, Object> extra);
|
||||
String sendBindDeviceCommand(String deviceNo);
|
||||
|
||||
/**
|
||||
* 下发设备初始化指令。用于解绑设备,允许设备未绑定用户时下发。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @return commandId
|
||||
*/
|
||||
String sendInitDeviceCommand(String deviceNo);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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;
|
||||
@@ -13,6 +16,7 @@ import org.dromara.app.domain.AppSchedulingDevice;
|
||||
import org.dromara.app.domain.AppWateringLog;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.domain.vo.AppDeviceVo;
|
||||
import org.dromara.app.domain.vo.DashboardStatsVo;
|
||||
import org.dromara.app.mapper.AppDeviceMapper;
|
||||
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
|
||||
import org.dromara.app.mapper.AppWateringLogMapper;
|
||||
@@ -22,8 +26,11 @@ 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.json.utils.JsonUtils;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
import org.dromara.common.oss.entity.UploadResult;
|
||||
import org.dromara.common.oss.factory.OssFactory;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
@@ -54,15 +61,24 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
|
||||
|
||||
@Override
|
||||
public TableDataInfo<AppDeviceVo> queryPageList(AppDeviceBo bo, PageQuery pageQuery) {
|
||||
LambdaQueryWrapper<AppDevice> lqw = buildQueryWrapper(bo);
|
||||
Page<AppDeviceVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
|
||||
Page<AppDeviceVo> page = pageQuery.build();
|
||||
page.orders().replaceAll(this::qualifyDeviceOrderItem);
|
||||
Page<AppDeviceVo> result = baseMapper.selectDeviceVoPage(page, bo);
|
||||
return TableDataInfo.build(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AppDeviceVo> queryList(AppDeviceBo bo) {
|
||||
LambdaQueryWrapper<AppDevice> lqw = buildQueryWrapper(bo);
|
||||
return baseMapper.selectVoList(lqw);
|
||||
return baseMapper.selectDeviceVoList(bo);
|
||||
}
|
||||
|
||||
private OrderItem qualifyDeviceOrderItem(OrderItem item) {
|
||||
String column = item.getColumn();
|
||||
if (StringUtils.isBlank(column) || column.contains(".")) {
|
||||
return item;
|
||||
}
|
||||
item.setColumn("d." + column);
|
||||
return item;
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<AppDevice> buildQueryWrapper(AppDeviceBo bo) {
|
||||
@@ -73,6 +89,7 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
|
||||
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());
|
||||
@@ -90,7 +107,11 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
|
||||
public Boolean insertByBo(AppDeviceBo bo) {
|
||||
// fillBindTokenHash(bo);
|
||||
AppDevice add = MapstructUtils.convert(bo, AppDevice.class);
|
||||
validEntityBeforeSave(add);
|
||||
validEntityBeforeInsert(add);
|
||||
normalizeMacAddress(add);
|
||||
if (baseMapper.selectByMac(add.getMacAddress()) != null) {
|
||||
throw new ServiceException("MAC地址已存在,不能重复新增设备");
|
||||
}
|
||||
return baseMapper.insert(add) > 0;
|
||||
}
|
||||
|
||||
@@ -98,7 +119,7 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
|
||||
public Boolean updateByBo(AppDeviceBo bo) {
|
||||
// fillBindTokenHash(bo);
|
||||
AppDevice update = MapstructUtils.convert(bo, AppDevice.class);
|
||||
validEntityBeforeSave(update);
|
||||
validEntityBeforeUpdate(update);
|
||||
return baseMapper.updateById(update) > 0;
|
||||
}
|
||||
|
||||
@@ -232,8 +253,56 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean generateQrCode(Collection<String> deviceNos) {
|
||||
if (deviceNos == null || deviceNos.isEmpty()) {
|
||||
throw new ServiceException("设备编号不能为空");
|
||||
}
|
||||
List<AppDevice> devices = baseMapper.selectByIds(deviceNos);
|
||||
if (devices.size() != new HashSet<>(deviceNos).size()) {
|
||||
throw new ServiceException("存在无效的设备编号");
|
||||
}
|
||||
|
||||
private void validEntityBeforeSave(AppDevice entity) {
|
||||
for (AppDevice device : devices) {
|
||||
byte[] imageBytes = QrCodeUtil.generatePng(buildQrCodeContent(device), 300, 300);
|
||||
UploadResult uploadResult = OssFactory.instance().uploadSuffix(imageBytes, ".png", "image/png");
|
||||
int rows = baseMapper.update(null, new LambdaUpdateWrapper<AppDevice>()
|
||||
.set(AppDevice::getQrcode, uploadResult.getUrl())
|
||||
.eq(AppDevice::getDeviceNo, device.getDeviceNo()));
|
||||
if (rows <= 0) {
|
||||
throw new ServiceException("设备二维码保存失败");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private String buildQrCodeContent(AppDevice device) {
|
||||
if (StringUtils.isBlank(device.getMacAddress())) {
|
||||
throw new ServiceException("设备MAC地址不能为空,无法生成二维码");
|
||||
}
|
||||
Map<String, String> content = new LinkedHashMap<>();
|
||||
content.put("deviceNo", device.getDeviceNo());
|
||||
content.put("mac", device.getMacAddress());
|
||||
content.put("transport", "ble");
|
||||
content.put("name", device.getDeviceName());
|
||||
return JsonUtils.toJsonString(content);
|
||||
}
|
||||
|
||||
|
||||
private void validEntityBeforeInsert(AppDevice entity) {
|
||||
if (StringUtils.isBlank(entity.getMacAddress())) {
|
||||
throw new ServiceException("MAC地址不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
private void normalizeMacAddress(AppDevice entity) {
|
||||
if (StringUtils.isNotBlank(entity.getMacAddress())) {
|
||||
entity.setMacAddress(entity.getMacAddress().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
|
||||
private void validEntityBeforeUpdate(AppDevice entity) {
|
||||
if (StringUtils.isBlank(entity.getDeviceNo())) {
|
||||
throw new ServiceException("设备编号不能为空");
|
||||
}
|
||||
@@ -251,4 +320,66 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
|
||||
}
|
||||
return flag;
|
||||
}
|
||||
|
||||
@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("用户不能为空");
|
||||
}
|
||||
|
||||
List<String> distinctDeviceNos = deviceNos.stream()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (distinctDeviceNos.isEmpty()) {
|
||||
throw new ServiceException("设备编号不能为空");
|
||||
}
|
||||
|
||||
List<AppDevice> devices = baseMapper.selectList(
|
||||
Wrappers.<AppDevice>lambdaQuery()
|
||||
.in(AppDevice::getDeviceNo, distinctDeviceNos)
|
||||
.eq(AppDevice::getUserId, userId)
|
||||
);
|
||||
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)
|
||||
.set(AppDevice::getBindTokenHash, null)
|
||||
.in(AppDevice::getDeviceNo, distinctDeviceNos)
|
||||
.eq(AppDevice::getUserId, userId)
|
||||
);
|
||||
if (rows != distinctDeviceNos.size()) {
|
||||
throw new ServiceException("设备解绑失败,请重试");
|
||||
}
|
||||
|
||||
schedulingDeviceMapper.delete(
|
||||
new QueryWrapper<AppSchedulingDevice>().in("device_no", distinctDeviceNos)
|
||||
);
|
||||
wateringLogMapper.delete(
|
||||
new QueryWrapper<AppWateringLog>().in("device_no", distinctDeviceNos)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表盘统计 — 单条 SQL 聚合查询,避免 N+1 问题。
|
||||
*/
|
||||
@Override
|
||||
public DashboardStatsVo getDashboardStats() {
|
||||
return baseMapper.selectDashboardStats();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
package org.dromara.app.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import org.dromara.common.core.utils.MapstructUtils;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
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.system.domain.SysConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.dromara.app.domain.AppWateringLog;
|
||||
import org.dromara.app.domain.bo.AppWateringLogBo;
|
||||
import org.dromara.app.domain.vo.AppWateringLogVo;
|
||||
import org.dromara.app.domain.AppWateringLog;
|
||||
import org.dromara.app.mapper.AppWateringLogMapper;
|
||||
import org.dromara.app.service.IAppWateringLogService;
|
||||
import org.dromara.common.core.utils.MapstructUtils;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* 浇水记录Service业务层处理
|
||||
@@ -86,6 +86,7 @@ public class AppWateringLogServiceImpl implements IAppWateringLogService {
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getDurationMin()), AppWateringLog::getDurationMin, bo.getDurationMin());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getZones()), AppWateringLog::getZones, bo.getZones());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getTriggerType()), AppWateringLog::getTriggerType, bo.getTriggerType());
|
||||
lqw.like(StringUtils.isNotBlank(bo.getRemark()), AppWateringLog::getRemark, bo.getRemark());
|
||||
// lqw.eq(bo.getCreateTime() != null, AppWateringLog::getCreateTime, bo.getCreateTime());
|
||||
lqw.between(params.get("beginTime") != null && params.get("endTime") != null,
|
||||
AppWateringLog::getCreateTime, params.get("beginTime"), params.get("endTime"));
|
||||
@@ -113,6 +114,49 @@ public class AppWateringLogServiceImpl implements IAppWateringLogService {
|
||||
return flag;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean saveEstimatedScheduleLog(AppWateringLogBo bo) {
|
||||
if (bo == null || StringUtils.isBlank(bo.getDeviceNo()) || bo.getScheduleId() == null || bo.getStartTime() == null) {
|
||||
return false;
|
||||
}
|
||||
if (existsScheduleLog(bo.getDeviceNo(), bo.getScheduleId(), bo.getStartTime())) {
|
||||
return false;
|
||||
}
|
||||
bo.setTriggerType("0");
|
||||
if (StringUtils.isBlank(bo.getRemark())) {
|
||||
bo.setRemark("设备离线,按排程自动补记");
|
||||
}
|
||||
return insertByBo(bo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean confirmScheduleLog(AppWateringLogBo bo) {
|
||||
if (bo == null || StringUtils.isBlank(bo.getDeviceNo()) || bo.getScheduleId() == null || bo.getStartTime() == null) {
|
||||
return insertByBo(bo);
|
||||
}
|
||||
|
||||
AppWateringLog estimatedLog = baseMapper.selectOne(
|
||||
Wrappers.<AppWateringLog>lambdaQuery()
|
||||
.eq(AppWateringLog::getDeviceNo, bo.getDeviceNo())
|
||||
.eq(AppWateringLog::getScheduleId, bo.getScheduleId())
|
||||
.eq(AppWateringLog::getStartTime, bo.getStartTime())
|
||||
.eq(AppWateringLog::getTriggerType, "0")
|
||||
.like(AppWateringLog::getRemark, "自动补记")
|
||||
.last("limit 1")
|
||||
);
|
||||
if (estimatedLog == null) {
|
||||
if (existsScheduleLog(bo.getDeviceNo(), bo.getScheduleId(), bo.getStartTime())) {
|
||||
return true;
|
||||
}
|
||||
return insertByBo(bo);
|
||||
}
|
||||
|
||||
AppWateringLog update = MapstructUtils.convert(bo, AppWateringLog.class);
|
||||
update.setId(estimatedLog.getId());
|
||||
update.setRemark("设备离线期间执行,联网后补传确认");
|
||||
return baseMapper.updateById(update) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改浇水记录
|
||||
*
|
||||
@@ -126,8 +170,23 @@ public class AppWateringLogServiceImpl implements IAppWateringLogService {
|
||||
return baseMapper.updateById(update) > 0;
|
||||
}
|
||||
|
||||
private boolean existsScheduleLog(String deviceNo, Long scheduleId, Date startTime) {
|
||||
return baseMapper.exists(
|
||||
Wrappers.<AppWateringLog>lambdaQuery()
|
||||
.eq(AppWateringLog::getDeviceNo, deviceNo)
|
||||
.eq(AppWateringLog::getScheduleId, scheduleId)
|
||||
.eq(AppWateringLog::getStartTime, startTime)
|
||||
.eq(AppWateringLog::getTriggerType, "0")
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean finishManualLog(String deviceNo, Long userId, Date endTime) {
|
||||
return finishManualLog(deviceNo, userId, endTime, "手动停止浇水");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean finishManualLog(String deviceNo, Long userId, Date endTime, String remark) {
|
||||
if (StringUtils.isBlank(deviceNo) || userId == null || endTime == null) {
|
||||
return false;
|
||||
}
|
||||
@@ -147,13 +206,49 @@ public class AppWateringLogServiceImpl implements IAppWateringLogService {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeLog.setEndTime(endTime);
|
||||
LambdaUpdateWrapper<AppWateringLog> updateWrapper = Wrappers.lambdaUpdate();
|
||||
updateWrapper.eq(AppWateringLog::getId, activeLog.getId())
|
||||
.isNull(AppWateringLog::getEndTime)
|
||||
.set(AppWateringLog::getEndTime, endTime)
|
||||
.set(StringUtils.isNotBlank(remark), AppWateringLog::getRemark, remark);
|
||||
if (activeLog.getStartTime() != null) {
|
||||
long durationMillis = endTime.getTime() - activeLog.getStartTime().getTime();
|
||||
long durationMin = Math.max(durationMillis, 0L) / 60000L;
|
||||
activeLog.setDurationMin(String.valueOf(durationMin));
|
||||
updateWrapper.set(AppWateringLog::getDurationMin, String.valueOf(durationMin));
|
||||
}
|
||||
return baseMapper.updateById(activeLog) > 0;
|
||||
return baseMapper.update(null, updateWrapper) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int finishRunningLogsByDevice(String deviceNo, Date endTime, String remark) {
|
||||
if (StringUtils.isBlank(deviceNo) || endTime == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<AppWateringLog> activeLogs = baseMapper.selectList(
|
||||
Wrappers.<AppWateringLog>lambdaQuery()
|
||||
.eq(AppWateringLog::getDeviceNo, deviceNo)
|
||||
.isNull(AppWateringLog::getEndTime)
|
||||
);
|
||||
if (activeLogs == null || activeLogs.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int updated = 0;
|
||||
for (AppWateringLog activeLog : activeLogs) {
|
||||
LambdaUpdateWrapper<AppWateringLog> updateWrapper = Wrappers.lambdaUpdate();
|
||||
updateWrapper.eq(AppWateringLog::getId, activeLog.getId())
|
||||
.isNull(AppWateringLog::getEndTime)
|
||||
.set(AppWateringLog::getEndTime, endTime)
|
||||
.set(StringUtils.isNotBlank(remark), AppWateringLog::getRemark, remark);
|
||||
if (activeLog.getStartTime() != null) {
|
||||
long durationMillis = endTime.getTime() - activeLog.getStartTime().getTime();
|
||||
long durationMin = Math.max(durationMillis, 0L) / 60000L;
|
||||
updateWrapper.set(AppWateringLog::getDurationMin, String.valueOf(durationMin));
|
||||
}
|
||||
updated += baseMapper.update(null, updateWrapper);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -84,7 +84,7 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
|
||||
AppWateringLogBo logBo = buildManualStartLog(deviceNo, userId, commandId, startTime, durationMin);
|
||||
wateringLogService.insertByBo(logBo);
|
||||
} else {
|
||||
boolean updated = wateringLogService.finishManualLog(deviceNo, userId, new Date());
|
||||
boolean updated = wateringLogService.finishManualLog(deviceNo, userId, new Date(), "手动停止浇水");
|
||||
if (!updated) {
|
||||
log.warn("[命令] 未找到进行中的手动浇水记录 设备编号={} 用户ID={} 停止命令编号={}",
|
||||
deviceNo, userId, commandId);
|
||||
@@ -124,6 +124,27 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
|
||||
command.getPayload().putAll(objectObjectHashMap);
|
||||
return sendCommand(command);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String sendInitDeviceCommand(String deviceNo) {
|
||||
AppDeviceVo device = deviceService.queryById(deviceNo);
|
||||
if (ObjectUtil.isNull(device)) {
|
||||
throw new ServiceException("设备不存在:" + deviceNo);
|
||||
}
|
||||
|
||||
DeviceCommand command = new DeviceCommand();
|
||||
command.setDeviceNo(deviceNo);
|
||||
command.setCommandType("initDevice");
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("deviceNo", deviceNo);
|
||||
payload.put("initStatus", true);
|
||||
payload.put("cmd", "init");
|
||||
command.getPayload().putAll(payload);
|
||||
|
||||
String commandId = commandPublisher.send(command);
|
||||
log.info("[命令] 设备初始化命令已下发 设备编号={} 命令编号={}", deviceNo, commandId);
|
||||
return commandId;
|
||||
}
|
||||
// ========================= 校验 =========================
|
||||
|
||||
private void validate(DeviceCommand command) {
|
||||
@@ -172,6 +193,7 @@ public class DeviceCommandServiceImpl implements IDeviceCommandService {
|
||||
logBo.setScheduleId(0L);
|
||||
logBo.setDurationMin(String.valueOf(durationMin));
|
||||
logBo.setTriggerType("1"); // 手动
|
||||
logBo.setRemark("手动开始浇水");
|
||||
logBo.setCreateTime(new Date());
|
||||
|
||||
if (StringUtils.isNotEmpty(startTime)) {
|
||||
|
||||
@@ -6,13 +6,17 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.app.domain.AppDevice;
|
||||
import org.dromara.app.mapper.AppDeviceMapper;
|
||||
import org.dromara.app.service.IAppWateringLogService;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.redisson.api.RLock;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -32,19 +36,20 @@ import java.util.stream.Collectors;
|
||||
public class DeviceOfflineCheckTask {
|
||||
|
||||
private final AppDeviceMapper appDeviceMapper;
|
||||
private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:";
|
||||
|
||||
@Value("${mqtt.command-ack.device-status-cache-prefix}")
|
||||
private String deviceStatusCachePrefix;
|
||||
|
||||
@Value("${mqtt.offline-check.enabled:true}")
|
||||
private boolean enabled;
|
||||
private final IAppWateringLogService wateringLogService;
|
||||
|
||||
/**
|
||||
* 每 90 秒检查一次(Redis TTL 默认 300s,90s 间隔确保 1.5 个周期内同步)
|
||||
* 可通过 mqtt.offline-check.interval-ms 覆盖(单位 ms)
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "${mqtt.offline-check.interval-ms:90000}")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void checkOfflineDevices() {
|
||||
if (!enabled) {
|
||||
return;
|
||||
@@ -72,15 +77,55 @@ public class DeviceOfflineCheckTask {
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量更新为离线
|
||||
int updated = appDeviceMapper.update(null,
|
||||
new LambdaUpdateWrapper<AppDevice>()
|
||||
.set(AppDevice::getStatus, "0")
|
||||
.ne(AppDevice::getStatus, "0")
|
||||
.in(AppDevice::getDeviceNo, offlineDeviceNos)
|
||||
);
|
||||
List<String> updatedDeviceNos = new ArrayList<>();
|
||||
for (String deviceNo : offlineDeviceNos) {
|
||||
if (markOfflineIfStillExpired(deviceNo)) {
|
||||
updatedDeviceNos.add(deviceNo);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[设备离线] 检测到 {} 台设备离线,已更新数据库状态,设备编号:{}",
|
||||
updated, offlineDeviceNos);
|
||||
if (!updatedDeviceNos.isEmpty()) {
|
||||
log.info("[设备离线] 检测到 {} 台设备离线,已更新数据库状态,设备编号:{}",
|
||||
updatedDeviceNos.size(), updatedDeviceNos);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean markOfflineIfStillExpired(String deviceNo) {
|
||||
RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo);
|
||||
boolean locked = false;
|
||||
try {
|
||||
locked = lock.tryLock(100, 10000, TimeUnit.MILLISECONDS);
|
||||
if (!locked) {
|
||||
log.debug("[设备离线] 获取设备状态锁失败,本轮跳过 设备编号={}", deviceNo);
|
||||
return false;
|
||||
}
|
||||
// 获取锁后再次确认 Redis 在线 Key 仍不存在,避免设备刚注册上线时被旧扫描结果覆盖为离线
|
||||
if (RedisUtils.hasKey(deviceStatusCachePrefix + deviceNo)) {
|
||||
return false;
|
||||
}
|
||||
return appDeviceMapper.update(null,
|
||||
new LambdaUpdateWrapper<AppDevice>()
|
||||
.set(AppDevice::getStatus, "0")
|
||||
.set(AppDevice::getWorkStatus, "0")
|
||||
.ne(AppDevice::getStatus, "0")
|
||||
.eq(AppDevice::getDeviceNo, deviceNo)
|
||||
) > 0 && finishOfflineWateringLogs(deviceNo);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.warn("[设备离线] 获取设备状态锁被中断 设备编号={}", deviceNo);
|
||||
return false;
|
||||
} finally {
|
||||
if (locked && lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean finishOfflineWateringLogs(String deviceNo) {
|
||||
int count = wateringLogService.finishRunningLogsByDevice(deviceNo, new Date(), "设备掉线");
|
||||
if (count > 0) {
|
||||
log.info("[设备离线] 已结束设备进行中的浇水记录 设备编号={} 记录数={}", deviceNo, count);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package org.dromara.app.task;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.app.domain.AppDevice;
|
||||
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.AppScheduleDetailMapper;
|
||||
import org.dromara.app.mapper.AppScheduleMapper;
|
||||
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
|
||||
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.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 设备离线期间排程浇水记录补记任务。
|
||||
* <p>
|
||||
* 设备断网后可能仍按本地排程浇水,但服务端收不到完成上报。本任务按已开启排程生成预估历史记录,
|
||||
* 设备恢复联网后上报同一记录时会在 {@link org.dromara.app.service.IAppWateringLogService#confirmScheduleLog}
|
||||
* 中更新为设备确认记录。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class OfflineScheduleWateringLogTask {
|
||||
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
private final AppDeviceMapper deviceMapper;
|
||||
private final AppSchedulingDeviceMapper schedulingDeviceMapper;
|
||||
private final AppScheduleMapper scheduleMapper;
|
||||
private final AppScheduleDetailMapper scheduleDetailMapper;
|
||||
private final IAppWateringLogService wateringLogService;
|
||||
|
||||
@Value("${mqtt.offline-schedule-log.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${mqtt.offline-schedule-log.lookback-days:7}")
|
||||
private int lookbackDays;
|
||||
|
||||
@Scheduled(fixedDelayString = "${mqtt.offline-schedule-log.interval-ms:300000}")
|
||||
public void fillOfflineScheduleLogs() {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<AppDevice> offlineDevices = deviceMapper.selectList(
|
||||
Wrappers.<AppDevice>lambdaQuery()
|
||||
.eq(AppDevice::getStatus, "0")
|
||||
.isNotNull(AppDevice::getUserId)
|
||||
.select(AppDevice::getDeviceNo, AppDevice::getUserId)
|
||||
);
|
||||
if (offlineDevices.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int saved = 0;
|
||||
for (AppDevice device : offlineDevices) {
|
||||
saved += fillDeviceScheduleLogs(device);
|
||||
}
|
||||
if (saved > 0) {
|
||||
log.info("[离线排程补记] 已补记离线排程浇水记录 数量={}", saved);
|
||||
}
|
||||
}
|
||||
|
||||
private int fillDeviceScheduleLogs(AppDevice device) {
|
||||
List<AppSchedulingDevice> bindings = schedulingDeviceMapper.selectList(
|
||||
Wrappers.<AppSchedulingDevice>lambdaQuery()
|
||||
.eq(AppSchedulingDevice::getDeviceNo, device.getDeviceNo())
|
||||
);
|
||||
if (bindings.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int saved = 0;
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDate startDate = LocalDate.now().minusDays(Math.max(lookbackDays, 1));
|
||||
for (AppSchedulingDevice binding : bindings) {
|
||||
AppSchedule schedule = scheduleMapper.selectById(binding.getScheduleId());
|
||||
if (schedule == null || !"1".equals(schedule.getStatus())) {
|
||||
continue;
|
||||
}
|
||||
List<AppScheduleDetail> details = scheduleDetailMapper.selectList(
|
||||
Wrappers.<AppScheduleDetail>lambdaQuery()
|
||||
.eq(AppScheduleDetail::getScheduleId, schedule.getId())
|
||||
);
|
||||
for (AppScheduleDetail detail : details) {
|
||||
saved += fillDetailLogs(device, schedule, detail, startDate, now);
|
||||
}
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
private int fillDetailLogs(
|
||||
AppDevice device,
|
||||
AppSchedule schedule,
|
||||
AppScheduleDetail detail,
|
||||
LocalDate startDate,
|
||||
LocalDateTime now
|
||||
) {
|
||||
if (detail == null || "0".equals(detail.getStatus()) || detail.getWeekday() == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<TimeSlot> timeSlots = parseTimeSlots(detail.getTimeData());
|
||||
if (timeSlots.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int saved = 0;
|
||||
for (LocalDate date = startDate; !date.isAfter(LocalDate.now()); date = date.plusDays(1)) {
|
||||
if (toScheduleWeekday(date.getDayOfWeek()) != detail.getWeekday()) {
|
||||
continue;
|
||||
}
|
||||
for (TimeSlot timeSlot : timeSlots) {
|
||||
AppWateringLogBo logBo = buildLog(device, schedule, detail, date, timeSlot, now);
|
||||
if (logBo != null && Boolean.TRUE.equals(wateringLogService.saveEstimatedScheduleLog(logBo))) {
|
||||
saved++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
private AppWateringLogBo buildLog(
|
||||
AppDevice device,
|
||||
AppSchedule schedule,
|
||||
AppScheduleDetail detail,
|
||||
LocalDate date,
|
||||
TimeSlot timeSlot,
|
||||
LocalDateTime now
|
||||
) {
|
||||
if (StringUtils.isBlank(timeSlot.getStartTime()) || timeSlot.getDurationMin() == null || timeSlot.getDurationMin() <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
LocalTime startLocalTime;
|
||||
try {
|
||||
startLocalTime = LocalTime.parse(timeSlot.getStartTime(), TIME_FORMATTER);
|
||||
} catch (DateTimeParseException e) {
|
||||
log.warn("[离线排程补记] 排程开始时间格式错误 设备编号={} 排程ID={} 开始时间={}",
|
||||
device.getDeviceNo(), schedule.getId(), timeSlot.getStartTime());
|
||||
return null;
|
||||
}
|
||||
|
||||
LocalDateTime startTime = LocalDateTime.of(date, startLocalTime);
|
||||
LocalDateTime endTime = startTime.plusMinutes(timeSlot.getDurationMin());
|
||||
if (endTime.isAfter(now)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
AppWateringLogBo logBo = new AppWateringLogBo();
|
||||
logBo.setDeviceNo(device.getDeviceNo());
|
||||
logBo.setUserId(device.getUserId());
|
||||
logBo.setScheduleId(schedule.getId());
|
||||
logBo.setStartTime(toDate(startTime));
|
||||
logBo.setEndTime(toDate(endTime));
|
||||
logBo.setDurationMin(String.valueOf(timeSlot.getDurationMin()));
|
||||
logBo.setZones(detail.getZones() == null ? null : String.valueOf(detail.getZones()));
|
||||
logBo.setTriggerType("0");
|
||||
logBo.setCreateTime(new Date());
|
||||
logBo.setRemark("设备离线,按排程自动补记");
|
||||
return logBo;
|
||||
}
|
||||
|
||||
private List<TimeSlot> parseTimeSlots(String timeData) {
|
||||
if (StringUtils.isBlank(timeData)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
return JsonUtils.parseArray(timeData, TimeSlot.class);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("[离线排程补记] 排程时间数据解析失败 timeData={}", timeData);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
private int toScheduleWeekday(DayOfWeek dayOfWeek) {
|
||||
return dayOfWeek.getValue();
|
||||
}
|
||||
|
||||
private Date toDate(LocalDateTime dateTime) {
|
||||
return Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TimeSlot {
|
||||
private String startTime;
|
||||
private Integer durationMin;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.dromara.system.service.impl;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import com.baomidou.lock.annotation.Lock4j;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
@@ -152,10 +153,7 @@ public class SysOssConfigServiceImpl implements ISysOssConfigService {
|
||||
SysOssConfig info = baseMapper.selectOne(new LambdaQueryWrapper<SysOssConfig>()
|
||||
.select(SysOssConfig::getOssConfigId, SysOssConfig::getConfigKey)
|
||||
.eq(SysOssConfig::getConfigKey, sysOssConfig.getConfigKey()));
|
||||
if (ObjectUtil.isNotNull(info) && info.getOssConfigId() != ossConfigId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return !ObjectUtil.isNotNull(info) || info.getOssConfigId() == ossConfigId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,6 +161,7 @@ public class SysOssConfigServiceImpl implements ISysOssConfigService {
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@Lock4j(keys = {"'oss:config:status'"}, acquireTimeout = 5000, expire = 30000)
|
||||
public int updateOssConfigStatus(SysOssConfigBo bo) {
|
||||
SysOssConfig sysOssConfig = MapstructUtils.convert(bo, SysOssConfig.class);
|
||||
int row = baseMapper.update(null, new LambdaUpdateWrapper<SysOssConfig>()
|
||||
|
||||
@@ -263,9 +263,11 @@ public class WorkflowGlobalListener implements GlobalListener {
|
||||
if (flwTaskService.isTaskEnd(instanceId)) {
|
||||
String status = BusinessStatusEnum.FINISH.getStatus();
|
||||
// 更新流程状态为已完成
|
||||
flwInstanceService.updateStatus(instanceId, status);
|
||||
log.info("流程已结束,状态更新为: {}", status);
|
||||
return status;
|
||||
if (flwInstanceService.updateStatusIfNotFinal(instanceId, status)) {
|
||||
log.info("流程已结束,状态更新为: {}", status);
|
||||
return status;
|
||||
}
|
||||
log.debug("流程实例已被其他处理更新为最终状态,跳过重复完成事件,实例ID: {}", instanceId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,15 @@ public interface IFlwInstanceService {
|
||||
*/
|
||||
void updateStatus(Long instanceId, String status);
|
||||
|
||||
/**
|
||||
* 流程未处于最终状态时更新状态
|
||||
*
|
||||
* @param instanceId 实例id
|
||||
* @param status 状态
|
||||
* @return 是否更新成功
|
||||
*/
|
||||
boolean updateStatusIfNotFinal(Long instanceId, String status);
|
||||
|
||||
/**
|
||||
* 获取流程变量
|
||||
*
|
||||
|
||||
@@ -388,6 +388,15 @@ public class FlwInstanceServiceImpl implements IFlwInstanceService {
|
||||
flowInstanceMapper.update(wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean updateStatusIfNotFinal(Long instanceId, String status) {
|
||||
LambdaUpdateWrapper<FlowInstance> wrapper = new LambdaUpdateWrapper<>();
|
||||
wrapper.set(FlowInstance::getFlowStatus, status);
|
||||
wrapper.eq(FlowInstance::getId, instanceId);
|
||||
wrapper.notIn(FlowInstance::getFlowStatus, BusinessStatusEnum.finishStatus());
|
||||
return flowInstanceMapper.update(wrapper) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取流程变量
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user