{"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:
yuhaiming
2026-06-17 10:07:56 +08:00
parent c74363afa4
commit 5548512633
37 changed files with 1301 additions and 177 deletions

14
pom.xml
View File

@@ -39,6 +39,8 @@
<justauth.version>1.16.7</justauth.version>
<!-- 离线IP地址定位库 -->
<ip2region.version>3.3.4</ip2region.version>
<!-- ZXing 二维码 -->
<zxing.version>3.5.3</zxing.version>
<!-- OSS 配置 -->
<aws.sdk.version>2.28.22</aws.sdk.version>
<!-- SMS 配置 -->
@@ -151,6 +153,18 @@
<version>${fastexcel.version}</version>
</dependency>
<!-- ZXing 二维码库hutool-extra QrCodeUtil 底层依赖hutool 声明为 optional -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>${zxing.version}</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>${zxing.version}</version>
</dependency>
<!-- velocity代码生成使用模板 -->
<dependency>
<groupId>org.apache.velocity</groupId>

View File

@@ -96,6 +96,15 @@
<groupId>org.dromara</groupId>
<artifactId>water-app</artifactId>
</dependency>
<!-- 二维码生成运行依赖,确保启动包包含 ZXing 类 -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
</dependency>
<!-- 工作流模块 -->
<dependency>

View File

@@ -36,6 +36,7 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
private MqttClientManager mqttClientManager;
private final MqttProperties mqttProperties;
private final AppDeviceMapper appDeviceMapper;
private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:";
public void savePending(DeviceCommand command) {
RedisUtils.setCacheObject(pendingKey(command.getCommandId()), command, Duration.ofSeconds(mqttProperties.getCommandAck().getPendingTtlSeconds()));
@@ -46,7 +47,7 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
if (StringUtils.isBlank(commandId)) {
return;
}
deletePending(commandId);
withCommandLock(commandId, () -> deletePending(commandId));
}
@Override
@@ -74,8 +75,10 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
return;
}
ack.setDeviceNo(deviceNo);
deletePending(ack.getCommandId());
RedisUtils.setCacheObject(ackKey(ack.getCommandId()), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
withCommandLock(ack.getCommandId(), () -> {
deletePending(ack.getCommandId());
RedisUtils.setCacheObject(ackKey(ack.getCommandId()), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
});
refreshDeviceOnline(deviceNo);
log.info("[MQTT] 收到命令确认 设备编号={} 命令编号={} 状态={}", deviceNo, ack.getCommandId(), ack.getStatus());
}
@@ -96,8 +99,10 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
ack.setStatus("1");
ack.setMessage(payload);
deletePending(commandId);
RedisUtils.setCacheObject(ackKey(commandId), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
withCommandLock(commandId, () -> {
deletePending(commandId);
RedisUtils.setCacheObject(ackKey(commandId), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
});
log.info("[MQTT] 收到非 JSON 命令确认 设备编号={} 命令编号={} 消息体={}", deviceNo, commandId, payload);
}
@@ -121,24 +126,7 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
}
private void retryCommandIfLocked(String commandId, long now) {
RLock lock = RedisUtils.getClient().getLock(mqttProperties.getCommandAck().getRetryLockKeyPrefix() + commandId);
boolean locked = false;
try {
locked = lock.tryLock(0, mqttProperties.getCommandAck().getRetryLockTtlMs(), TimeUnit.MILLISECONDS);
if (!locked) {
return;
}
retryCommand(commandId, now);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("[MQTT] 命令重试锁等待被中断 命令编号={}", commandId);
} catch (RuntimeException e) {
log.error("[MQTT] 命令重试失败 命令编号={}", commandId, e);
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
withCommandLock(commandId, () -> retryCommand(commandId, now));
}
private void retryCommand(String commandId, long now) {
@@ -186,20 +174,30 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
}
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(
mqttProperties.getCommandAck().getDeviceStatusCachePrefix() + deviceNo,
statusCache,
Duration.ofSeconds(mqttProperties.getCommandAck().getDeviceStatusCacheTtlSeconds())
);
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(
mqttProperties.getCommandAck().getDeviceStatusCachePrefix() + deviceNo,
statusCache,
Duration.ofSeconds(mqttProperties.getCommandAck().getDeviceStatusCacheTtlSeconds())
);
appDeviceMapper.update(null,
new LambdaUpdateWrapper<AppDevice>()
.set(AppDevice::getStatus, "1")
.eq(AppDevice::getDeviceNo, deviceNo)
);
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private RSet<String> pendingIds() {
@@ -211,6 +209,27 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
pendingIds().remove(commandId);
}
private void withCommandLock(String commandId, Runnable action) {
RLock lock = RedisUtils.getClient().getLock(mqttProperties.getCommandAck().getRetryLockKeyPrefix() + commandId);
boolean locked = false;
try {
locked = lock.tryLock(0, mqttProperties.getCommandAck().getRetryLockTtlMs(), TimeUnit.MILLISECONDS);
if (!locked) {
return;
}
action.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("[MQTT] 命令锁等待被中断 命令编号={}", commandId);
} catch (RuntimeException e) {
log.error("[MQTT] 命令锁内处理失败 命令编号={}", commandId, e);
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private String pendingKey(String commandId) {
return mqttProperties.getCommandAck().getPendingKeyPrefix() + commandId;
}

View File

@@ -149,7 +149,7 @@ public class OssClient {
.contentType(contentType)
// 用于设置对象的访问控制列表ACL。不同云厂商对ACL的支持和实现方式有所不同
// 因此根据具体的云服务提供商你可能需要进行不同的配置自行开启阿里云有acl权限配置腾讯云没有acl权限配置
//.acl(getAccessPolicy().getObjectCannedACL())
.acl(getAccessPolicy().getObjectCannedACL())
.build()
);
if (log.isDebugEnabled()) {
@@ -203,7 +203,7 @@ public class OssClient {
.contentType(contentType)
// 用于设置对象的访问控制列表ACL。不同云厂商对ACL的支持和实现方式有所不同
// 因此根据具体的云服务提供商你可能需要进行不同的配置自行开启阿里云有acl权限配置腾讯云没有acl权限配置
//.acl(getAccessPolicy().getObjectCannedACL())
.acl(getAccessPolicy().getObjectCannedACL())
.build()
);
if (log.isDebugEnabled()) {

View File

@@ -22,7 +22,7 @@ public enum AccessPolicyType {
/**
* public
*/
PUBLIC("1", BucketCannedACL.PUBLIC_READ_WRITE, ObjectCannedACL.PUBLIC_READ_WRITE),
PUBLIC("1", BucketCannedACL.PUBLIC_READ, ObjectCannedACL.PUBLIC_READ),
/**
* custom

View File

@@ -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 底层需要 zxinghutool 声明为 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>

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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;

View File

@@ -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;
/**
* 创建时间
*/

View File

@@ -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;
/**

View File

@@ -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;
/**
* 创建时间
*/

View File

@@ -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;
}

View File

@@ -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;
/**
* 创建时间
*/

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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);
/**
* 校验并批量删除浇水记录信息
*

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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;
}
/**

View File

@@ -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)) {

View File

@@ -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 默认 300s90s 间隔确保 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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>()

View File

@@ -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;
}

View File

@@ -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);
/**
* 获取流程变量
*

View File

@@ -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;
}
/**
* 获取流程变量
*