+ * 启动时自动收集所有 {@link MqttTopicHandler} Bean,按 Topic 正则匹配分发。
+ * 新增 Topic 只需新增 Handler 实现类,零改动此处。
+ */
@Slf4j
@Component
-@RequiredArgsConstructor
public class MqttMessageDispatcher {
- private final DeviceDataHandler dataHandler;
- private final DeviceStatusHandler statusHandler;
- private final ErromesHandler erroHandler;
- // 路由规则:匹配 topic 分发到对应 Handler
- private static final String DATA_PATTERN = "/water/([^/]+)/data";
- private static final String STATUS_PATTERN = "/water/([^/]+)/status";
- private static final String ERROMES_PATTERN = "/water/([^/]+)/erromes";
+ private final List
+ * 所有上行消息处理器实现此接口,由 {@link MqttMessageDispatcher} 自动发现并路由。
+ * 新增 Topic 只需新增实现类并注册为 Spring Bean,无需修改 Dispatcher。
+ *
+ * @author water team
+ */
+public interface MqttTopicHandler {
+
+ /**
+ * 返回该处理器匹配的 Topic 正则。
+ * 必须包含一个捕获组用于提取 deviceNo。
+ */
+ Pattern topicPattern();
+
+ /**
+ * 处理匹配到的消息。
+ *
+ * @param deviceNo 从 Topic 正则中提取的设备编号
+ * @param payload 消息体(JSON)
+ */
+ void handle(String deviceNo, String payload);
+}
diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/IAppDeviceService.java b/water-modules/water-app/src/main/java/org/dromara/app/service/IAppDeviceService.java
index 971c974..799d8ab 100644
--- a/water-modules/water-app/src/main/java/org/dromara/app/service/IAppDeviceService.java
+++ b/water-modules/water-app/src/main/java/org/dromara/app/service/IAppDeviceService.java
@@ -27,7 +27,7 @@ public interface IAppDeviceService {
* @return 设备信息
*/
- AppDeviceVo queryById(Long id);
+ AppDeviceVo queryById(String deviceNo);
/**
* 分页查询设备信息
@@ -70,13 +70,31 @@ public interface IAppDeviceService {
*/
Boolean updateByBo(AppDeviceBo bo);
+ /**
+ * MQTT设备注册或刷新设备基础信息
+ *
+ * @param bo 设备信息
+ * @return 是否保存成功
+ */
+ Boolean registerByMqtt(AppDeviceBo bo);
+
+ /**
+ * 将已注册设备绑定到当前登录用户
+ *
+ * @param bo 设备信息
+ * @return 设备信息
+ */
+ AppDeviceVo bindRegisteredDevice(AppDeviceBo bo);
+
+ Boolean switchDevice(String deviceNo, String workStatus, String startTime, Integer durationMin);
+
/**
* 校验并批量删除设备信息
信息
*
- * @param ids 待删除的主键集合
+ * @param deviceNos 待删除的主键集合
* @param isValid 是否进行有效性校验
* @return 是否删除成功
*/
- Boolean deleteWithValidByIds(Collection
+ * 封装命令构造、设备校验、下发与日志记录,避免各业务方直接拼装 {@link DeviceCommand}。
+ *
+ * @author water team
+ */
+public interface IDeviceCommandService {
+
+ /**
+ * 通用命令下发。
+ *
+ * @param command 命令对象(commandId 为空时自动生成)
+ * @return commandId
+ */
+ String sendCommand(DeviceCommand command);
+
+ /**
+ * 下发设备开关/浇水命令。
+ *
+ * @param deviceNo 设备编号
+ * @param workStatus 目标工作状态
+ * @param startTime 开始时间(可为 null)
+ * @param durationMin 持续时间(分钟)
+ * @return commandId
+ */
+ String sendSwitchCommand(String deviceNo, String workStatus, String startTime, Integer durationMin);
+
+ /**
+ * 下发自定义命令。
+ *
+ * @param deviceNo 设备编号
+ * @param commandType 命令类型
+ * @param extra 额外参数
+ * @return commandId
+ */
+ String sendCustomCommand(String deviceNo, String commandType, Map
+ * 负责命令构造、设备校验、下发生效,以及手动操作的浇水日志记录。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class DeviceCommandServiceImpl implements IDeviceCommandService {
+
+ // 字段注入 + @Lazy 打破:DeviceMqttCommandPublisher → MqttClientManager → MqttMessageDispatcher
+ // → DeviceRegisterHandler → AppDeviceServiceImpl → DeviceCommandServiceImpl → IDeviceCommandPublisher 循环
+ // commandPublisher 仅在 sendCommand/sendSwitchCommand 运行时调用,构造阶段无需立即解析
+ @Lazy
+ @Autowired
+ private IDeviceCommandPublisher commandPublisher;
+
+ private final IAppDeviceService deviceService;
+ private final IAppWateringLogService wateringLogService;
+
+ // ========================= 通用下发 =========================
+
+ @Override
+ public String sendCommand(DeviceCommand command) {
+ validate(command);
+ String commandId = commandPublisher.send(command);
+ log.info("[CMD] command sent deviceNo={} commandType={} commandId={}",
+ command.getDeviceNo(), command.getCommandType(), commandId);
+ return commandId;
+ }
+
+ // ========================= 类型化下发 =========================
+
+ @Override
+ public String sendSwitchCommand(String deviceNo, String workStatus, String startTime, Integer durationMin) {
+ assertDeviceExists(deviceNo);
+ assertSwitchStatus(workStatus);
+ if ("1".equals(workStatus)) {
+ assertDurationPositive(durationMin);
+ }
+
+ DeviceCommand command = new DeviceCommand();
+ command.setDeviceNo(deviceNo);
+ command.setCommandType("switchDevice");
+ command.getPayload().put("deviceNo", deviceNo);
+ command.getPayload().put("cmd", workStatus);
+ command.getPayload().put("startTime", startTime);
+ if (durationMin != null) {
+ command.getPayload().put("durationMin", durationMin);
+ }
+
+ String commandId = commandPublisher.send(command);
+ log.info("[CMD] switch command sent deviceNo={} workStatus={} durationMin={} commandId={}",
+ deviceNo, workStatus, durationMin, commandId);
+
+ Long userId = LoginHelper.getUserId();
+ if ("1".equals(workStatus)) {
+ AppWateringLogBo logBo = buildManualStartLog(deviceNo, userId, commandId, startTime, durationMin);
+ wateringLogService.insertByBo(logBo);
+ } else {
+ boolean updated = wateringLogService.finishManualLog(deviceNo, userId, new Date());
+ if (!updated) {
+ log.warn("[CMD] no active manual watering log found deviceNo={} userId={} stopCommandId={}",
+ deviceNo, userId, commandId);
+ }
+ }
+
+ return commandId;
+ }
+
+ @Override
+ public String sendCustomCommand(String deviceNo, String commandType, Map