Parcourir la source

feature: 智能控制V2版本 第一版

Jayhaw il y a 1 mois
commit
cdff7e36be
100 fichiers modifiés avec 12128 ajouts et 0 suppressions
  1. 7 0
      .claude/settings.local.json
  2. 2 0
      .gitattributes
  3. 33 0
      .gitignore
  4. 3 0
      .mvn/wrapper/maven-wrapper.properties
  5. 1903 0
      docs/球阀轮灌技术方案.md
  6. 295 0
      mvnw
  7. 189 0
      mvnw.cmd
  8. 197 0
      pom.xml
  9. 16 0
      src/main/java/cn/sciento/farm/autoconfigure/EnableWfAutomationV2.java
  10. 18 0
      src/main/java/cn/sciento/farm/autoconfigure/WebConditionAutoConfiguration.java
  11. 17 0
      src/main/java/cn/sciento/farm/automationv2/WfEquipmentAutomationV2Application.java
  12. 129 0
      src/main/java/cn/sciento/farm/automationv2/api/controller/AlarmController.java
  13. 165 0
      src/main/java/cn/sciento/farm/automationv2/api/controller/ExecutionMonitorController.java
  14. 276 0
      src/main/java/cn/sciento/farm/automationv2/api/controller/IrrigationTaskController.java
  15. 156 0
      src/main/java/cn/sciento/farm/automationv2/api/dto/CreateTaskRequest.java
  16. 39 0
      src/main/java/cn/sciento/farm/automationv2/api/dto/PageRequest.java
  17. 45 0
      src/main/java/cn/sciento/farm/automationv2/api/dto/Result.java
  18. 75 0
      src/main/java/cn/sciento/farm/automationv2/app/context/ExecutionContext.java
  19. 37 0
      src/main/java/cn/sciento/farm/automationv2/app/handler/NodeHandler.java
  20. 74 0
      src/main/java/cn/sciento/farm/automationv2/app/handler/NodeHandlerFactory.java
  21. 66 0
      src/main/java/cn/sciento/farm/automationv2/app/handler/impl/CloseGroupNodeHandler.java
  22. 73 0
      src/main/java/cn/sciento/farm/automationv2/app/handler/impl/OpenGroupNodeHandler.java
  23. 57 0
      src/main/java/cn/sciento/farm/automationv2/app/handler/impl/SetPumpPressureNodeHandler.java
  24. 64 0
      src/main/java/cn/sciento/farm/automationv2/app/handler/impl/StartFertilizerNodeHandler.java
  25. 49 0
      src/main/java/cn/sciento/farm/automationv2/app/handler/impl/StartPumpNodeHandler.java
  26. 49 0
      src/main/java/cn/sciento/farm/automationv2/app/handler/impl/StopFertilizerNodeHandler.java
  27. 49 0
      src/main/java/cn/sciento/farm/automationv2/app/handler/impl/StopPumpNodeHandler.java
  28. 44 0
      src/main/java/cn/sciento/farm/automationv2/app/handler/impl/WaitNodeHandler.java
  29. 62 0
      src/main/java/cn/sciento/farm/automationv2/app/job/IrrigationScheduledJob.java
  30. 76 0
      src/main/java/cn/sciento/farm/automationv2/app/listener/SensorDataListener.java
  31. 171 0
      src/main/java/cn/sciento/farm/automationv2/app/service/AlarmNotificationService.java
  32. 199 0
      src/main/java/cn/sciento/farm/automationv2/app/service/AlarmRecordService.java
  33. 173 0
      src/main/java/cn/sciento/farm/automationv2/app/service/LinkageRuleEngine.java
  34. 247 0
      src/main/java/cn/sciento/farm/automationv2/app/service/QuartzManagementService.java
  35. 137 0
      src/main/java/cn/sciento/farm/automationv2/app/service/RetryManager.java
  36. 297 0
      src/main/java/cn/sciento/farm/automationv2/app/service/SafeShutdownService.java
  37. 426 0
      src/main/java/cn/sciento/farm/automationv2/app/service/TaskExecutionEngine.java
  38. 165 0
      src/main/java/cn/sciento/farm/automationv2/app/service/TaskTriggerService.java
  39. 146 0
      src/main/java/cn/sciento/farm/automationv2/app/service/WatchdogService.java
  40. 15 0
      src/main/java/cn/sciento/farm/automationv2/config/MyBatisConfig.java
  41. 60 0
      src/main/java/cn/sciento/farm/automationv2/config/QuartzConfig.java
  42. 168 0
      src/main/java/cn/sciento/farm/automationv2/domain/entity/AlarmRecord.java
  43. 86 0
      src/main/java/cn/sciento/farm/automationv2/domain/entity/IrrigationGroup.java
  44. 279 0
      src/main/java/cn/sciento/farm/automationv2/domain/entity/IrrigationTask.java
  45. 165 0
      src/main/java/cn/sciento/farm/automationv2/domain/entity/LinkageRule.java
  46. 205 0
      src/main/java/cn/sciento/farm/automationv2/domain/entity/TaskExecution.java
  47. 68 0
      src/main/java/cn/sciento/farm/automationv2/domain/entity/TaskGroupConfig.java
  48. 65 0
      src/main/java/cn/sciento/farm/automationv2/domain/enums/AckStatus.java
  49. 64 0
      src/main/java/cn/sciento/farm/automationv2/domain/enums/AlarmType.java
  50. 63 0
      src/main/java/cn/sciento/farm/automationv2/domain/enums/ExecutionStatus.java
  51. 28 0
      src/main/java/cn/sciento/farm/automationv2/domain/enums/FertilizerControlMode.java
  52. 66 0
      src/main/java/cn/sciento/farm/automationv2/domain/enums/NodeStatus.java
  53. 76 0
      src/main/java/cn/sciento/farm/automationv2/domain/enums/NodeType.java
  54. 33 0
      src/main/java/cn/sciento/farm/automationv2/domain/enums/PressureMode.java
  55. 41 0
      src/main/java/cn/sciento/farm/automationv2/domain/enums/PumpType.java
  56. 34 0
      src/main/java/cn/sciento/farm/automationv2/domain/enums/ScheduleType.java
  57. 39 0
      src/main/java/cn/sciento/farm/automationv2/domain/enums/TaskStatus.java
  58. 39 0
      src/main/java/cn/sciento/farm/automationv2/domain/enums/TriggerType.java
  59. 352 0
      src/main/java/cn/sciento/farm/automationv2/domain/service/ExecutionPlanGenerator.java
  60. 60 0
      src/main/java/cn/sciento/farm/automationv2/domain/valueobject/DeviceInfo.java
  61. 150 0
      src/main/java/cn/sciento/farm/automationv2/domain/valueobject/ExecutionNode.java
  62. 107 0
      src/main/java/cn/sciento/farm/automationv2/domain/valueobject/ExecutionPlan.java
  63. 76 0
      src/main/java/cn/sciento/farm/automationv2/domain/valueobject/ZoneConfigView.java
  64. 103 0
      src/main/java/cn/sciento/farm/automationv2/infra/feign/SmsServiceClient.java
  65. 73 0
      src/main/java/cn/sciento/farm/automationv2/infra/mq/consumer/AckTimeoutConsumer.java
  66. 64 0
      src/main/java/cn/sciento/farm/automationv2/infra/mq/consumer/CheckAckConsumer.java
  67. 74 0
      src/main/java/cn/sciento/farm/automationv2/infra/mq/consumer/DeviceAckConsumer.java
  68. 62 0
      src/main/java/cn/sciento/farm/automationv2/infra/mq/consumer/NextNodeConsumer.java
  69. 44 0
      src/main/java/cn/sciento/farm/automationv2/infra/mq/consumer/TaskStartConsumer.java
  70. 41 0
      src/main/java/cn/sciento/farm/automationv2/infra/mq/message/AckTimeoutMessage.java
  71. 46 0
      src/main/java/cn/sciento/farm/automationv2/infra/mq/message/CheckAckMessage.java
  72. 66 0
      src/main/java/cn/sciento/farm/automationv2/infra/mq/message/DeviceAckMessage.java
  73. 67 0
      src/main/java/cn/sciento/farm/automationv2/infra/mq/message/DeviceCommandMessage.java
  74. 41 0
      src/main/java/cn/sciento/farm/automationv2/infra/mq/message/NextNodeMessage.java
  75. 46 0
      src/main/java/cn/sciento/farm/automationv2/infra/mq/message/TaskStartMessage.java
  76. 234 0
      src/main/java/cn/sciento/farm/automationv2/infra/mq/producer/DeviceCommandProducer.java
  77. 200 0
      src/main/java/cn/sciento/farm/automationv2/infra/mq/producer/FlowControlProducer.java
  78. 185 0
      src/main/java/cn/sciento/farm/automationv2/infra/redis/AckManager.java
  79. 128 0
      src/main/java/cn/sciento/farm/automationv2/infra/redis/IdempotencyManager.java
  80. 78 0
      src/main/java/cn/sciento/farm/automationv2/infra/repository/AlarmRecordMapper.java
  81. 66 0
      src/main/java/cn/sciento/farm/automationv2/infra/repository/IrrigationGroupMapper.java
  82. 68 0
      src/main/java/cn/sciento/farm/automationv2/infra/repository/IrrigationTaskMapper.java
  83. 72 0
      src/main/java/cn/sciento/farm/automationv2/infra/repository/LinkageRuleMapper.java
  84. 105 0
      src/main/java/cn/sciento/farm/automationv2/infra/repository/TaskExecutionMapper.java
  85. 44 0
      src/main/java/cn/sciento/farm/automationv2/infra/repository/TaskGroupConfigMapper.java
  86. 173 0
      src/main/resources/application.yml
  87. 31 0
      src/main/resources/db/changelog/db.changelog-master.xml
  88. 363 0
      src/main/resources/db/changelog/v1.0/001-quartz-tables.xml
  89. 92 0
      src/main/resources/db/changelog/v1.0/002-irrigation-task.xml
  90. 90 0
      src/main/resources/db/changelog/v1.0/003-irrigation-group.xml
  91. 100 0
      src/main/resources/db/changelog/v1.0/004-task-execution.xml
  92. 99 0
      src/main/resources/db/changelog/v1.0/005-linkage-rule.xml
  93. 105 0
      src/main/resources/db/changelog/v1.0/006-alarm-record.xml
  94. 47 0
      src/main/resources/db/changelog/v1.1/007-update-irrigation-task.xml
  95. 41 0
      src/main/resources/db/changelog/v1.1/008-update-irrigation-group.xml
  96. 71 0
      src/main/resources/db/changelog/v1.1/009-create-task-group-config.xml
  97. 114 0
      src/main/resources/mapper/AlarmRecordMapper.xml
  98. 94 0
      src/main/resources/mapper/IrrigationGroupMapper.xml
  99. 130 0
      src/main/resources/mapper/IrrigationTaskMapper.xml
  100. 106 0
      src/main/resources/mapper/LinkageRuleMapper.xml

+ 7 - 0
.claude/settings.local.json

@@ -0,0 +1,7 @@
+{
+  "permissions": {
+    "allow": [
+      "Bash(mvn clean compile:*)"
+    ]
+  }
+}

+ 2 - 0
.gitattributes

@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf

+ 33 - 0
.gitignore

@@ -0,0 +1,33 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/

+ 3 - 0
.mvn/wrapper/maven-wrapper.properties

@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip

+ 1903 - 0
docs/球阀轮灌技术方案.md

@@ -0,0 +1,1903 @@
+# 智能轮灌系统 技术需求与技术方案文档
+
+**版本:** v1.6
+**日期:** 2026-02-26
+**状态:** 草稿
+**适用范围:** 后端开发团队、架构评审
+
+---
+
+## 1. 系统概述
+
+本系统为智能农业场景下的自动化轮灌控制平台,支持对多个灌区(灌溉组)按照预设规则进行有序、安全的轮流灌溉。系统具备以下核心能力:
+
+- **多种触发方式**:支持定时调度、传感器联动、手动触发等多种触发模式,同一任务可同时关联多个触发来源,满足不同农业场景需求。
+- **设备协同控制**:统一管理水泵(变频恒压/普通)、电磁阀、球阀(支持角度+压力控制)、施肥机等农业设备。
+- **安全执行保障**:内置先开后关、设备互锁、ACK确认、重试、安全关闭等多重安全机制。
+- **高可靠性架构**:基于 RocketMQ 延迟消息 + 责任链模式实现异步流程编排,配合看门狗机制保障任务不丢失。
+- **全程记录与报警**:每次执行保存完整节点日志,异常时多通道报警(短信 + 电话 + 移动端推送)。
+- **配置与执行解耦**:用户只表达灌溉"意图"(选设备、配时长),系统自动按安全规则生成精确执行计划;配置顺序与执行顺序分离,支持运行时动态跳过灌区与故障接续执行。
+
+---
+
+## 2. 业务需求
+
+### 2.1 触发方式
+
+轮灌任务支持以下触发方式,各触发方式可共存,同一任务允许同时关联多个触发来源(如既配定时又配联动)。无论哪个触发来源先触发,均创建同一套执行计划;任务正在运行时,其他触发来源的信号将被拒绝(见 §6.6 并发触发防护)。
+
+#### 2.1.1 定时轮灌
+
+**模式 A:按星期 + 时间重复**
+
+- 用户选择星期(可多选,如:周一、周三、周五)和具体时刻(如 02:00)。
+- 系统自动将配置转换为 Cron 表达式存储。
+- 适用场景:固定周期的常规灌溉计划。
+
+**模式 B:启动时间 + 执行次数 + 执行周期**
+
+- 用户配置起始时间、执行总次数(如 10 次)、执行间隔周期(如每 3 天)。
+- 系统使用简单调度器(SimpleScheduleBuilder)管理,记录已执行次数,达到上限后自动停用任务。
+- 适用场景:临时性或有限次数的灌溉计划。
+
+#### 2.1.2 联动控制(传感器触发)
+
+- 监听单个传感器实时数据,当数据满足指定条件时,自动触发轮灌流程。
+- 条件支持:`大于(>)` / `大于等于(>=)` / `小于(<)` / `小于等于(<=)` / `等于(==)` + 阈值。
+- **冷却时间机制**:成功触发一次后,在设定的冷却时间窗口内(如 N 分钟),即使传感器数据再次满足条件,也不重复触发,防止短时间内重复启动。
+
+**联动规则配置字段:**
+
+| 字段          | 说明                             |
+| ------------- | -------------------------------- |
+| 传感器设备 ID | 监听的具体传感器设备             |
+| 比较符        | `>` / `>=` / `<` / `<=` / `==`   |
+| 阈值          | 触发条件的数值边界               |
+| 冷却时间      | 单位:分钟,触发后的静默时间窗口 |
+| 关联任务 ID   | 满足条件时触发的轮灌任务         |
+
+#### 2.1.3 手动触发
+
+用户通过 APP 或后台手动点击“立即执行”,任务即时启动,无需等待定时或传感器触发。
+
+---
+
+### 2.2 设备配置
+
+每个轮灌任务可以独立配置以下设备:
+
+#### 2.2.1 压力控制配置
+
+灌溉系统的压力控制分为水泵压力和球阀压力两个维度,两者可以同时存在、独立生效:
+
+**维度一:水泵压力控制**
+
+通过变频恒压水泵控制管路压力,系统下发目标压力值给水泵,水泵自动调频维持恒压。任务创建时选择水泵压力模式:
+
+| 模式         | 说明                                                           | 配置位置                       |
+| ------------ | -------------------------------------------------------------- | ------------------------------ |
+| **无**       | 水泵作为普通水泵启停,不下发压力指令                           | 无额外配置                     |
+| **统一压力** | 所有灌区使用同一压力值,整个任务只下发一次 `SET_PUMP_PRESSURE` | 任务级配置 `targetPressureKpa` |
+| **分区压力** | 每个灌区可设置不同的水泵目标压力,灌区切换时自动下发新压力值   | 灌溉组级配置 `zonePressureKpa` |
+
+**维度二:球阀压力控制**
+
+通过球阀自身的压力控制功能调节管路压力,每个球阀在灌溉组中配置目标压力,系统在开启球阀时同时下发角度和压力指令。球阀压力控制独立于水泵压力控制,两者可同时工作,系统根据不同的配置综合控制水泵和球阀,确保整个灌溉系统的稳定性和安全性。
+
+| 配置位置                 | 说明                                                                        |
+| ------------------------ | --------------------------------------------------------------------------- |
+| 灌溉组设备列表中每个球阀 | 配置 `targetPressureKpa`,系统在 `OPEN_GROUP` 时同时下发角度+压力指令给球阀 |
+
+> **共存示例**:水泵设为统一压力 300kPa,同时灌区内的球阀各自配置了目标压力,系统会在启动时下发 `SET_PUMP_PRESSURE(300)` 给水泵,同时在 `OPEN_GROUP` 时将各球阀的目标压力随开启指令一起下发。
+
+**水泵配置参数:**
+
+| 参数           | 说明                                                                    |
+| -------------- | ----------------------------------------------------------------------- |
+| 水泵压力模式   | `NONE`(普通启停)/ `PUMP_UNIFIED`(统一压力)/ `PUMP_ZONE`(分区压力) |
+| 统一目标压力值 | 仅 `PUMP_UNIFIED` 模式需配置,单位:kPa                                 |
+
+#### 2.2.2 施肥机配置
+
+施肥机由以下子设备控制,每个子设备独立接收指令和回传 ACK:
+
+| 子设备   | 说明                                       |
+| -------- | ------------------------------------------ |
+| 施肥泵   | 将肥料从储肥桶注入灌溉管路的计量泵         |
+| 搅拌电机 | 对储肥桶中的肥料持续搅拌,确保肥料均匀溶解 |
+
+> **注意**:施肥机的主水泵与灌溉系统的水泵是**同一个水泵**,都是灌溉清水的,施肥泵用于抽取肥料,搅拌电机用于搅拌肥料。此设计节省设备成本并简化系统操作。
+
+施肥的配置参数、控制模式、时序规则及异常处理见 §2.7,技术方案见 §5.1.4。
+
+#### 2.2.3 任务级安全参数
+
+| 参数                    | 说明                                                                                                               | 默认值 |
+| ----------------------- | ------------------------------------------------------------------------------------------------------------------ | ------ |
+| `switch_stable_seconds` | 灌区切换时,开启下一灌区成功后到关闭上一灌区之前的稳压等待时间(秒)。用于平衡管路水压,防止压力突变损坏设备或爆管 | 5 秒   |
+
+> **设计原则**:`switch_stable_seconds` 由计划生成引擎在每个 `OPEN_GROUP(ZoneI)` 与 `CLOSE_GROUP(ZoneI-1)` 之间自动插入一个 `ZONE_SWITCH_WAIT` 节点,用户仅需配置秒数,无需关心插入位置。
+
+---
+
+### 2.3 灌溉组与任务灌区配置
+
+#### 2.3.1 灌溉组(灌区预配置模板)
+
+灌溉组是灌区设备的预配置模板,用于统一管理一个灌区内的设备组合及其控制参数。灌溉组独立于任务存在,可被多个任务复用。
+
+**灌溉组配置参数:**
+
+| 参数       | 说明                                                                                               |
+| ---------- | -------------------------------------------------------------------------------------------------- |
+| 灌区组名称 | 用于标识灌区组,方便管理和报警展示                                                                 |
+| 分区压力   | 本灌区的水泵目标压力(kPa);仅在任务压力模式为 `PUMP_ZONE` 时生效,灌区切换时系统自动下发新压力值 |
+| 设备列表   | 本灌区包含的设备及其控制参数(见下表)                                                             |
+
+**设备列表中每个设备的参数:**
+
+| 设备类型 | 参数             | 说明                                                                                      |
+| -------- | ---------------- | ----------------------------------------------------------------------------------------- |
+| 电磁阀   | 设备ID           | 开(ON)/ 关(OFF)控制                                                                   |
+| 球阀     | 设备ID、目标角度 | 角度范围 0~100%,用于流量调节                                                             |
+| 球阀     | 目标压力         | 本球阀的目标压力(kPa);若球阀支持压力控制则配置,系统在开启球阀时同时下发角度和压力指令 |
+
+一个灌溉组内可同时包含多个电磁阀和球阀,所有设备同步下发指令,全部 ACK 成功方视为开启/关闭成功。
+
+> **配置说明**:灌溉组中的分区压力仅在水泵压力模式为 `PUMP_ZONE` 时生效;球阀目标压力只要球阀配置了即生效(与水泵压力可共存)。用户在灌溉组中统一配置好设备和压力参数后,创建任务时只需选择灌溉组并配置每个灌区的灌溉时长即可。
+
+#### 2.3.2 任务级灌区配置
+
+创建任务时,用户从已有灌溉组中选择灌区,并为每个灌区配置灌溉时长:
+
+| 参数     | 说明                                                 |
+| -------- | ---------------------------------------------------- |
+| 灌溉组   | 从已配置的灌溉组中选择,选择顺序即灌溉意图顺序       |
+| 灌溉时长 | 本灌区的灌溉持续时间(分钟),每个灌区可设置不同时长 |
+
+**施肥配置:**
+
+是否施肥由任务是否选择了施肥机设备来决定,灌溉组中不需要额外的“是否施肥”字段。当任务选择了施肥机后,需配置施肥计划参数(任务级,所有灌区共用):
+
+| 参数         | 说明                                                                       |
+| ------------ | -------------------------------------------------------------------------- |
+| 施肥延迟时间 | (分钟)灌区开始灌溉后,等待多少分钟再启动搅拌,相对于灌溉开始时间计算     |
+| 搅拌提前时长 | (分钟)搅拌电机比施肥泵提前多少分钟启动,一直搅拌到施肥结束               |
+| 施肥时长     | (分钟)施肥泵持续运行时长;与施肥量二选一                                 |
+| 施肥量       | (升)施肥目标容积,下发定量指令给施肥机,施肥机自动停止;与施肥时长二选一 |
+
+> **设计说明**:灌溉时长属于任务级配置而非灌溉组模板配置,因为同一灌溉组在不同任务中可能需要不同的灌溉时长。施肥参数为任务级统一配置,所有启用施肥的灌区共用同一套施肥参数,系统根据每个灌区的灌溉时长自动判断施肥时间窗口是否充足。用户在表单中配置的灌溉组列表及顺序代表灌溉意图,实际执行顺序由系统根据安全规则自动编排。
+
+---
+
+### 2.4 安全执行规则
+
+安全执行规则是本系统的核心约束,所有执行计划必须严格遵守以下规则:
+
+| 规则编号 | 规则描述                                                                                                                                                      |
+| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| S-01     | 水泵必须在**第一个灌区开启成功之后**才能启动,严禁在任何灌区开启前运行水泵                                                                                    |
+| S-02     | 灌区切换时,必须**先开启下一个灌区成功**,再等待稳压时间(`switch_stable_seconds`),最后关闭上一个灌区(先开→稳压→后关),确保管路中始终有水流路径且压力平稳 |
+| S-03     | 正常结束时,固定执行顺序:先关水泵 → 最后关闭末个灌区电磁阀/球阀                                                                                              |
+| S-04     | **开路优先原则**(先开后关,全场景适用):任何涉及设备通断的操作,必须先建立新通路/开启新设备,再断开旧通路/关闭旧设备,防止管路瞬间封闭导致水锤效应或爆管    |
+| S-05     | **故障接续启动序列**:任务从 PAUSED 恢复执行时,若水泵已在安全关闭中停止,必须按"先开灌区 → 再启水泵"的顺序重建启动序列,严禁直接继续执行                     |
+
+---
+
+### 2.5 执行时序示例
+
+**示例 1:3 个灌区,水泵普通启停 + 球阀配置了目标压力**
+
+```
+步骤  操作
+----  ──────────────────────────
+ 1    开启灌区1(电磁阀/球阀,球阀同时下发角度+压力)
+ 2    开启水泵(普通启停,不下发压力)
+ 3    等待 3 分钟(灌溉)
+ 4    开启灌区2
+ 5    等待 5 秒(稳压,switch_stable_seconds)   ← S-02/S-04 稳压等待
+ 6    关闭灌区1
+ 7    等待 3 分钟(灌溉)
+ 8    开启灌区3
+ 9    等待 5 秒(稳压)
+10    关闭灌区2
+11    等待 3 分钟(灌溉)
+12    关闭水泵
+13    关闭灌区3
+```
+
+**示例 2:故障接续执行(泵已停,恢复后重建启动序列)**
+
+```
+步骤  操作
+----  ──────────────────────────
+(故障前已完成)
+ ✅   开启灌区1 → 开启水泵 → 等待3分钟
+
+(故障发生,安全关闭)
+ ✅   关闭水泵 → 关闭灌区1
+
+(用户修复完成,调用 Resume API)
+(系统检测到泵已停止,插入重建序列)
+
+ 1    开启灌区1(重新开启,遵循 S-05)           ← 先开灌区
+ 2    设置水泵目标压力值                         ← PUMP_UNIFIED/PUMP_ZONE 模式时插入
+ 3    开启水泵                                   ← 再开水泵(S-05)
+ 4    继续执行剩余灌溉(从中断处接续)
+```
+
+---
+
+### 2.6 执行记录与报警
+
+#### 2.6.1 执行记录
+
+每次任务执行需保存完整日志,包括:
+
+- 每个节点的开始时间、结束时间、执行结果(成功/失败)。
+- 每个设备的 ACK 状态、重试次数、失败原因。
+- 整体任务状态(运行中 / 成功 / 失败 / 手动终止)。
+
+#### 2.6.2 报警通知
+
+当任务发生异常(设备失败超过重试次数、超时、安全关闭等)时,触发多通道报警:
+
+| 通道       | 说明                                         |
+| ---------- | -------------------------------------------- |
+| 短信       | 向绑定手机号发送报警短信                     |
+| 电话       | 向绑定手机号拨打语音报警电话,适用于紧急告警 |
+| 移动端推送 | 通过 APP 消息推送通知移动端用户              |
+
+**报警内容包括:**
+
+- 失败节点名称与原因
+- 成功/失败设备列表
+- 安全关闭执行状态(是否完成、哪些设备关闭失败需人工处理)
+
+---
+
+### 2.7 施肥控制
+
+施肥机作为轮灌系统的组成部分,在灌溉过程中实现自动化施肥。是否施肥由任务是否选择了施肥机设备来决定,施肥参数在任务级统一配置(见 §2.3.2),所有灌区共用同一套施肥参数。施肥在灌溉时间窗口内并行执行,与主灌溉流程互不阻塞。设备组成见 §2.2.2。
+
+> **注意**:施肥机的主水泵与灌溉的水泵是同一个水泵,施肥机仅包含施肥泵和搅拌电机两个独立子设备。
+
+#### 2.7.1 施肥参数
+
+施肥参数在创建任务时统一配置(见 §2.3.2),所有灌区共用同一套参数:
+
+| 参数         | 类型 | 说明                                                                             |
+| ------------ | ---- | -------------------------------------------------------------------------------- |
+| 施肥延迟时间 | 分钟 | 灌区开始灌溉后,等待多少分钟再启动搅拌电机(相对于本灌区灌溉开始时刻)           |
+| 搅拌提前时长 | 分钟 | 搅拌电机比施肥泵提前多少分钟启动;搅拌电机从启动后一直运行至施肥结束             |
+| 施肥时长     | 分钟 | 施肥泵持续运行时长;与施肥量二选一,两者不可同时配置                             |
+| 施肥量       | 升   | 施肥目标容积,下发定量指令给施肥机,施肥机达到目标量后自动停止;与施肥时长二选一 |
+
+> **约束**:施肥延迟时间 + 搅拌提前时长 + 施肥时长 ≤ 灌区总灌溉时长。若超出,系统在保存时给出警告。施肥停止后灌溉继续进行至灌区时长结束,无需单独配置肥后清水时间。
+
+#### 2.7.2 施肥时序
+
+以单个灌区为例,各设备的启停时序如下(灌区总时长 30 分钟,施肥延迟 3 分钟,搅拌提前 2 分钟,施肥时长 5 分钟):
+
+```
+灌区开始时刻(T=0)
+  ├─ T+0:   水泵启动 + 灌区球阀/电磁阀开启(随灌溉开始)
+  ├─ T+3:   搅拌电机启动(施肥延迟时间 = 3min)
+  ├─ T+5:   施肥泵启动(T+3 + 搅拌提前 2min = T+5)
+  ├─ T+10:  施肥泵停止 + 搅拌电机停止(T+5 + 施肥时长 5min = T+10)
+  ├─ T+10 ~ T+30: 继续纯水灌溉(施肥已结束,灌溉继续)
+  └─ T+30:  灌区结束(关水泵、关阀门)
+```
+
+> **说明**:施肥停止后灌溉自然继续至灌区时长结束,无需单独的“肥后清水时间”参数。
+
+#### 2.7.3 施肥控制模式
+
+**时间模式**(配置施肥时长):
+
+- 施肥泵运行固定时长后停止。
+- 施肥泵停止时同时停止搅拌电机。
+- 流量计作为辅助监控(可选),不控制停止时机。
+
+**容量模式**(配置施肥量):
+
+- 系统下发特定容量的施肥任务指令给施肥机,施肥机通过流量计监控,达到目标量后自动停止施肥泵。
+- 容量模式下搅拌电机的停止由施肥机定量控制指令自行控制。
+- 灌区切换时,系统额外发送关闭施肥机的指令,确保安全性和准确性。
+- 若灌区灌溉结束时仍未达到目标量:停止施肥泵和搅拌电机,**不影响后续灌区**,不将剩余施肥量转移至下一灌区。
+
+> **搅拌停止规则**:时间模式下,施肥泵停止时同步停止搅拌电机;容量模式下,搅拌电机由施肥机定量指令自行控制停止。但无论哪种模式,灌区结束时必须确保搅拌电机已关闭。
+
+#### 2.7.4 施肥与灌溉任务的集成方式
+
+用户在创建任务时选择施肥机设备即启用施肥,施肥参数为任务级统一配置(见 §2.3.2),所有灌区共用同一套施肥参数。执行计划生成时:
+
+1. 灌溉水泵即施肥主水泵,随灌区开启而启动,无需单独控制。
+2. 搅拌电机和施肥泵通过**延迟子事件**在灌区灌溉等待期间触发,不占用主节点链路。
+3. 施肥结束后,灌溉继续进行至灌区时长结束。
+
+> **实现方式**:采用独立的施肥子任务(FertTask)模型,在 `WAIT(IRRIGATE)` 节点执行时通过延迟 MQ 消息触发各子设备启停,与主链路并行运行、互不阻塞。技术方案见 §5.1.4。
+
+#### 2.7.5 施肥异常处理
+
+| 异常类型             | 处理方式                                                          |
+| -------------------- | ----------------------------------------------------------------- |
+| 施肥泵启动失败       | 停止搅拌电机,向主任务报告警告,灌溉继续                          |
+| 搅拌电机启动失败     | 停止施肥泵(防止未搅匀施肥),向主任务报告警告,灌溉继续          |
+| 施肥过程整体异常     | 记录异常原因,本灌区施肥放弃,灌溉继续执行(不触发任务级 PAUSED) |
+| 灌区结束时施肥未完成 | 强制关闭施肥泵和搅拌电机,确保安全                                |
+
+> **设计原则**:施肥异常不中断灌溉主任务,二者相互独立,施肥失败仅触发告警,不影响灌区轮灌流程。灌溉水泵即施肥主水泵,水泵状态由灌溉主流程统一控制。
+
+---
+
+## 3. 整体技术架构
+
+### 3.1 架构分层
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                          触发层                                  │
+│   ┌──────────────────────┐    ┌───────────────────────────────┐  │
+│   │   Quartz 定时调度     │    │   传感器联动规则引擎            │  │
+│   │  (模式A: Cron表达式)  │    │   (实时数据 → 规则比对)        │  │
+│   │  (模式B: Simple调度)  │    │   (冷却时间: Redis TTL)        │  │
+│   └──────────┬───────────┘    └────────────────┬──────────────┘  │
+└──────────────┼─────────────────────────────────┼─────────────────┘
+               │  触发信号(发MQ消息)             │
+               ▼                                  ▼
+┌─────────────────────────────────────────────────────────────────┐
+│                        流程编排层                                 │
+│                                                                  │
+│   ┌─────────────────────────────────────────────────────────┐   │
+│   │            执行计划生成(任务保存时预生成)                │   │
+│   │         安全规则 → 有序节点列表 → JSON存储                │   │
+│   └──────────────────────────┬──────────────────────────────┘   │
+│                              │                                   │
+│   ┌──────────────────────────▼──────────────────────────────┐   │
+│   │         责任链节点执行器(NodeHandler Chain)              │   │
+│   │   OPEN_GROUP / CLOSE_GROUP / START_PUMP / STOP_PUMP      │   │
+│   │   SET_PUMP_PRESSURE / WAIT / ZONE_SWITCH_WAIT             │   │
+│   └──────────────────────────┬──────────────────────────────┘   │
+│                              │                                   │
+│   ┌──────────────────────────▼──────────────────────────────┐   │
+│   │             RocketMQ 延迟消息队列                         │   │
+│   │   CHECK消息(10s) / TIMEOUT兜底(30s) / 推进消息(Nmin)      │   │
+│   └──────────────────────────────────────────────────────────┘   │
+└─────────────────────────────────────────────────────────────────┘
+               │  MQTT指令下发
+               ▼
+┌─────────────────────────────────────────────────────────────────┐
+│                        设备通信层                                 │
+│                                                                  │
+│   ┌─────────────────────┐        ┌──────────────────────────┐   │
+│   │   EMQX MQTT Broker  │◄──────►│  电磁阀 / 球阀 / 水泵     │   │
+│   │   (指令下发 + ACK)   │        │  施肥机(4G连接)          │   │
+│   └─────────────────────┘        └──────────────────────────┘   │
+└─────────────────────────────────────────────────────────────────┘
+               │
+               ▼
+┌─────────────────────────────────────────────────────────────────┐
+│                          存储层                                   │
+│                                                                  │
+│   ┌──────────────┐  ┌────────────────┐  ┌────────────────────┐  │
+│   │    MySQL      │  │     Redis      │  │     InfluxDB       │  │
+│   │ 执行计划JSON  │  │ 幂等+ACK注册表 │  │  传感器时序数据    │  │
+│   │ 执行记录      │  │ 冷却时间TTL    │  │  (可选)            │  │
+│   │ 报警记录      │  │                │  │                    │  │
+│   └──────────────┘  └────────────────┘  └────────────────────┘  │
+└─────────────────────────────────────────────────────────────────┘
+               │
+               ▼
+┌─────────────────────────────────────────────────────────────────┐
+│                       监控报警层                                  │
+│                                                                  │
+│   ┌──────────────┐  ┌─────────────┐  ┌────────────────────────┐ │
+│   │    短信      │  │    电话      │  │   移动端推送(APP)       │ │
+│   │  报警通知    │  │  语音报警    │  │   报警通知             │ │
+│   └──────────────┘  └─────────────┘  └────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+### 3.2 核心设计原则
+
+- **Job 只做触发,不做执行**:Quartz/联动触发后只发 MQ 消息,立即释放线程。
+- **消息驱动,无阻塞等待**:所有等待(ACK等待、灌溉等待)均通过 RocketMQ 延迟消息实现,不使用 Thread.sleep。
+- **状态持久化**:流程状态全部存储在 DB 中,MQ 只是触发器,即使 MQ 消息丢失,看门狗也能从 DB 恢复流程。
+- **幂等处理**:所有消息消费均做幂等校验,防止重复执行。
+- **4G 设备各自独立**:所有球阀/电磁阀通过 4G 独立连接 MQTT Broker,设备之间不互通,平台作为统一协调中心。
+
+---
+
+## 4. 触发层设计
+
+### 4.1 定时触发(Quartz)
+
+#### 4.1.1 模式 A:Cron 表达式调度
+
+- 用户配置的星期 + 时间,在任务保存时由后端自动转换为标准 Cron 表达式。
+- 示例:每周一、三、五早上 02:00 → `0 0 2 ? * MON,WED,FRI`
+- Quartz 以**集群模式**部署,使用 JDBC 存储(将调度信息持久化到 MySQL),避免单点故障。
+- 节点竞争抢锁触发,同一时间只有一个节点执行,防止重复触发。
+- **Misfire 策略**:设置为 `DoNothing`,错过的任务不补跑(轮灌场景下错过就跳过)。
+
+#### 4.1.2 模式 B:Simple 计数调度
+
+- 使用 `SimpleScheduleBuilder` 按间隔天数重复执行。
+- 数据库中记录已执行次数(`executed_count`),每次触发后自增。
+- 当已执行次数达到配置的总次数时,自动标记为停用,不再触发。
+
+#### 4.1.3 Quartz Job 设计原则
+
+**Job 只做触发,不做执行:**
+
+```
+[Quartz Job 触发]
+       │
+       ├─► 生成 task_execution 记录(status=PENDING)
+       │   写入自动生成的 execution_plan JSON
+       │
+       ├─► 发送 MQ 消息:TASK_START(含 execution_id)
+       │
+       └─► 立即返回(Job 线程释放,不做任何等待)
+```
+
+#### 4.1.4 Quartz 集群配置要点
+
+- **存储模式**:JDBC(`job-store-type: jdbc`),支持集群。
+- **集群开关**:`isClustered: true`,自动ID分配。
+- **线程池**:`threadCount: 20`,Job 触发后立即返回,线程不会被长占。
+- **Misfire 策略**:`withMisfireHandlingInstructionDoNothing()`,服务重启后不补跑错过的任务。
+
+---
+
+### 4.2 联动触发(传感器规则引擎)
+
+#### 4.2.1 触发流程
+
+```
+[传感器数据上报(MQTT)]
+            │
+            ▼
+   [后端实时接收传感器数据]
+            │
+            ▼
+   [查询关联此传感器的联动规则(从DB缓存)]
+            │
+            ▼
+   [规则比对:设备ID + 比较符 + 阈值]
+            │
+       ┌────┴────┐
+      满足条件  不满足
+       │          │
+       ▼          └─► 忽略
+   [查询Redis: 冷却时间Key是否存在]
+       │
+  ┌────┴────┐
+ Key存在   Key不存在(冷却结束)
+  │          │
+  ▼          ▼
+ 忽略    [写入冷却时间Key(SET NX + TTL=冷却时长)]
+              │
+              ▼
+         [生成 task_execution 记录]
+              │
+              ▼
+         [发送 MQ 消息:TASK_START]
+```
+
+#### 4.2.2 冷却时间实现
+
+- Redis Key 格式:`linkage:cooldown:{rule_id}`
+- 触发时使用 `SET NX` 写入,TTL = 冷却时长(分钟级别),Key 自然过期代表冷却结束。
+- `SET NX` 原子操作保证并发场景下只允许一次触发成功。
+
+#### 4.2.3 规则存储方案
+
+- 当前阶段:规则存 DB,后端实时比对(简单够用,单条件)。
+- 未来扩展:如需支持多条件组合(如 `湿度 < 30 且 温度 > 35`),可引入 Drools 规则引擎。
+
+---
+
+## 5. 流程编排设计
+
+### 5.0 三层模型:配置层与执行层解耦
+
+系统在设计上明确将"用户表达的意图"与"系统实际执行的动作"分为三层,彻底解决配置顺序与执行顺序不一致的问题。
+
+```
+┌────────────────────────────────────────────────────────────────┐
+│  第一层:用户配置层(Intent Layer)                              │
+│  用户只表达"我要什么",不关心怎么做                              │
+│  水泵类型+压力 / 灌区组选择+各灌区时长                          │
+└──────────────────────┬─────────────────────────────────────────┘
+                       │ 任务保存 / 运行时动态调整时
+                       ▼
+┌────────────────────────────────────────────────────────────────┐
+│  第二层:计划生成引擎(Plan Generator)                          │
+│  系统根据配置 + 安全规则,自动生成完整有序节点列表               │
+│  处理:先开后关 / 水泵开启时机 / 跳过灌区重算                    │
+└──────────────────────┬─────────────────────────────────────────┘
+                       │
+                       ▼
+┌────────────────────────────────────────────────────────────────┐
+│  第三层:执行层(Execution Layer)                               │
+│  按物理节点列表顺序执行,支持暂停 / 恢复 / 跳过 / 动态重算        │
+└────────────────────────────────────────────────────────────────┘
+```
+
+**三层职责对照表:**
+
+| 用户配置的(意图)          | 计划生成引擎转换为(物理执行节点)                                                                              |
+| --------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| "水泵恒压-统一,300kPa"     | 自动在 Zone1 成功后插入 `SET_PUMP_PRESSURE(300)` → `START_PUMP`,仅下发一次                                     |
+| "水泵恒压-分区"             | Zone1 后插入 `SET_PUMP_PRESSURE(zone1.压力)` → `START_PUMP`,每次灌区切换时追加 `SET_PUMP_PRESSURE(新压力)`     |
+| "球阀配置了目标压力"        | 压力参数随 `OPEN_GROUP` 下发给球阀,与水泵压力模式无关,两者独立生效                                            |
+| "选择灌区组1→2→3,各配时长" | 自动展开为 `OPEN(1)→START_PUMP→WAIT(时长1)→OPEN(2)→ZONE_SWITCH_WAIT→CLOSE(1)→WAIT(时长2)→...→STOP_PUMP→CLOSE(N)`,严格遵循先开后关 |
+| "稳压等待 5 秒"             | 在每个 `OPEN_GROUP(ZoneI)` 成功后、`CLOSE_GROUP(ZoneI-1)` 之前插入 `ZONE_SWITCH_WAIT(5s)`                       |
+| "跳过灌区2(运行时)"       | 截断后续节点,重新生成,安全衔接已开启灌区                                                                      |
+| "人工修复后继续"            | PAUSED 状态接受 Resume API,检测泵状态,自动插入 S-05 重建序列后接续执行                                        |
+
+---
+
+### 5.1 执行计划自动生成
+
+#### 5.1.1 设计原则
+
+执行计划在**任务保存时**(而非运行时)根据安全规则自动计算并生成完整的有序节点列表,以 JSON 格式存储到 `task_execution.execution_plan` 字段。
+
+**运行时只按顺序逐个执行,不再做任何安全判断逻辑**,保证执行路径的确定性和可追溯性。
+
+#### 5.1.2 自动生成算法
+
+```
+输入:
+  灌溉组列表  [Zone1, Zone2, ... ZoneN]  ← 任务中选择的灌溉组(§2.3.2)
+  水泵压力模式  NONE / PUMP_UNIFIED / PUMP_ZONE
+  统一目标压力值(PUMP_UNIFIED 模式)
+  每组灌溉时长  ← 任务级配置(§2.3.2),每个灌区可不同
+  switch_stable_seconds  ← 灌区切换稳压等待秒数(任务级配置)
+  (球阀目标压力在灌溉组设备列表中配置,若有则随 OPEN_GROUP 下发)
+
+输出节点列表:
+
+1. OPEN_GROUP(Zone1)                      ← 开启第一个灌区(S-01 前置)
+   │                                        球阀配置了目标压力时:同时下发角度+压力给球阀
+2. [SET_PUMP_PRESSURE(压力值)]            ← PUMP_UNIFIED/PUMP_ZONE 模式插入;NONE 模式不插入
+3. START_PUMP                             ← 开启水泵(S-01:灌区开启后)
+
+循环 i = 2 到 N:
+   WAIT(灌溉时长)
+   OPEN_GROUP(ZoneI)                      ← 先开下一个灌区(S-02/S-04)
+   [SET_PUMP_PRESSURE(ZoneI.压力值)]     ← 仅 PUMP_ZONE 模式:灌区切换时调整水泵压力
+   ZONE_SWITCH_WAIT(switch_stable_seconds) ← 稳压等待(S-02,防止压力突变)
+   CLOSE_GROUP(ZoneI-1)                   ← 再关上一个灌区(S-02:稳压后才关)
+
+最后灌区结束:
+   WAIT(灌溉时长)
+   STOP_PUMP                              ← 关水泵(S-03)
+   CLOSE_GROUP(ZoneN)                     ← 最后关最后一个灌区(S-03/S-04)
+```
+
+#### 5.1.3 特殊情况处理
+
+**只有一个灌区:**
+
+```
+index=0   OPEN_GROUP        灌区1
+index=1   SET_PUMP_PRESSURE (PUMP_UNIFIED/PUMP_ZONE 模式)
+index=2   START_PUMP
+index=3   WAIT              N分钟
+index=4   STOP_PUMP
+index=5   CLOSE_GROUP       灌区1
+
+→ 没有中间的先开后关逻辑,直接安全关闭
+```
+
+**每个灌区等待时长不同:**
+
+```
+灌区1等3分钟,灌区2等5分钟,灌区3等10分钟,稳压等待5秒
+
+index=0   OPEN_GROUP        灌区1
+index=1   START_PUMP
+index=2   WAIT              3分钟     ← 灌区1的灌溉等待
+index=3   OPEN_GROUP        灌区2
+index=4   ZONE_SWITCH_WAIT  5秒       ← 稳压等待(S-02)
+index=5   CLOSE_GROUP       灌区1
+index=6   WAIT              5分钟     ← 灌区2的灌溉等待
+index=7   OPEN_GROUP        灌区3
+index=8   ZONE_SWITCH_WAIT  5秒       ← 稳压等待
+index=9   CLOSE_GROUP       灌区2
+index=10  WAIT              10分钟    ← 灌区3的灌溉等待
+index=11  STOP_PUMP
+index=12  CLOSE_GROUP       灌区3
+
+→ ZONE_SWITCH_WAIT 确保两个灌区同时开启的时间窗口内压力趋于平稳后再关旧灌区
+```
+
+#### 5.1.4 施肥机执行方案设计
+
+本节设计施肥机在轮灌执行过程中的控制方案,包括子任务模型、状态机、触发流程、数据结构及安全约束。
+
+##### 5.1.4.1 核心设计思路:施肥子任务(FertTask)
+
+施肥机的两个子设备(搅拌电机、施肥泵)的启停时序**完全在单个灌区时间窗口内发生**,与主执行计划的节点链路**平行运行**,互不阻塞。灌溉水泵即施肥主水泵,由灌溉主流程统一控制,施肥子任务不单独管理水泵。
+
+设计采用**施肥子任务(FertTask)**模型:
+
+```
+主流程节点链路(顺序执行):
+  OPEN_GROUP(Z1) → START_PUMP → WAIT(30min) → ... → STOP_PUMP → CLOSE_GROUP(Z1)
+
+施肥子任务(并行,由延迟 MQ 驱动):
+  T+3min → 搅拌电机 ON           (延迟MQ:延迟=施肥延迟时间)
+  T+5min → 施肥泵 ON             (延迟MQ:延迟=施肥延迟+搅拌提前)
+  T+10min→ 施肥泵 OFF + 搅拌电机 OFF  (延迟MQ 或 施肥机自动停止)
+```
+
+主流程的 `WAIT(30min)` 节点执行时,同步向 MQ 发送一条带参数的施肥调度消息,由施肥子任务处理器接管后续的子设备控制,**不影响主链路推进**。
+
+##### 5.1.4.2 施肥子任务状态机
+
+```
+FertTask 状态:
+  PENDING → STIR_WAITING → STIRRING → FERT_RUNNING → DONE
+                │               │           │
+                │               │           └─ 施肥泵 ON,等待完成
+                │               └─ 搅拌电机 ON,等待施肥泵启动
+                └─ 等待施肥延迟时间到
+  FAILED(任意子设备失败时进入,不影响主任务)
+```
+
+| 状态           | 说明                             | 下一状态                  |
+| -------------- | -------------------------------- | ------------------------- |
+| `PENDING`      | 已创建,等待施肥延迟时间到       | `STIR_WAITING` → 发延迟MQ |
+| `STIR_WAITING` | 发出搅拌电机启动指令,等待ACK    | `STIRRING`                |
+| `STIRRING`     | 搅拌中,等待搅拌提前时长到       | `FERT_RUNNING` → 发延迟MQ |
+| `FERT_RUNNING` | 施肥泵运行中,等待时长/容量达到  | `DONE`                    |
+| `DONE`         | 施肥子任务成功完成               | —                         |
+| `FAILED`       | 子设备故障,施肥放弃,主任务继续 | —                         |
+
+##### 5.1.4.3 施肥子任务触发流程
+
+```
+[主流程执行 WAIT(IRRIGATE) 节点]
+        │
+        ├─► 发主链路延迟MQ(N分钟后推进主节点)
+        │
+        └─ 检查任务是否选择了施肥机设备?
+               │ 是
+               ▼
+        [创建 FertTask 记录,status = PENDING]
+        [写入 fert_task 表:绑定 execution_id + zone_id]
+               │
+               ▼
+        [发施肥延迟MQ]
+        消息类型:FERT_STIR_START
+        延迟时间:施肥延迟时间(分钟)
+               │
+        ─── 施肥延迟时间后 ───────────────
+               │
+        [FERT_STIR_START 消息到达]
+               │
+               ▼
+        [下发搅拌电机 ON 指令,等待ACK]
+               ├─ ACK成功 → status = STIRRING
+               │   └─ 发 FERT_PUMP_START 延迟MQ(延迟=搅拌提前时长)
+               └─ ACK失败 → status = FAILED,发告警,不影响主任务
+
+        ─── 搅拌提前时长后 ───────────────
+               │
+        [FERT_PUMP_START 消息到达]
+               │
+               ▼
+        [下发施肥泵 ON 指令,等待ACK]
+               ├─ ACK成功 → status = FERT_RUNNING
+               │   ├─ [时间模式] 发 FERT_PUMP_STOP 延迟MQ(延迟=施肥时长)
+               │   └─ [容量模式] 下发定量施肥指令给施肥机(含目标施肥量参数)
+               └─ ACK失败 → 关搅拌电机 → status = FAILED,发告警
+
+        ─── 时间模式:施肥时长后 ─────────
+               │
+        [FERT_PUMP_STOP 消息到达]
+               │
+               ▼
+        [下发施肥泵 OFF 指令]
+        [下发搅拌电机 OFF 指令](同步停止)
+        [status = DONE]
+
+        ─── 容量模式:施肥机自动停止 ─────
+               │
+        施肥机达到目标量后自动停止施肥泵
+        搅拌电机由施肥机定量控制指令自行控制停止
+        系统无需主动发送停止指令
+        [status = DONE]
+
+        ─── 灌区结束 / 灌区切换(两种模式共用)───
+               │
+        [主 WAIT 节点完成 或 灌区切换]
+               │
+               ▼
+        [检查 FertTask.status]
+          ├─ DONE → 已正常完成,灌区切换时额外发送关闭施肥机指令(安全兜底)
+          └─ FERT_RUNNING → 施肥未完成
+                → 发送关闭施肥机指令(施肥泵 OFF + 搅拌电机 OFF)
+                → 记录实际施肥信息
+                → status = DONE(标记未完成目标)
+                → 发送告警
+```
+
+##### 5.1.4.4 容量模式施肥控制
+
+```
+容量模式(status = FERT_RUNNING 期间):
+  系统下发定量施肥指令给施肥机(含目标施肥量参数)
+  施肥机内部通过流量计监控,达到目标量后自动停止施肥泵
+  搅拌电机的停止由施肥机定量控制指令自行控制
+
+灌区灌溉结束时(主 WAIT 节点完成):
+  FertTask.status 仍为 FERT_RUNNING?
+    → 未达到目标量,发送关闭施肥机指令(施肥泵 OFF + 搅拌电机 OFF)
+    → 记录实际施肥量到 fert_task.actual_amount
+    → status = DONE(标记未完成目标)
+    → 发送告警:本次施肥未达目标量
+
+灌区切换时:
+  → 系统额外发送关闭施肥机指令,确保安全性
+```
+
+##### 5.1.4.5 FertTask 数据结构
+
+```java
+FertTask {
+    Long        id;
+    Long        executionId;        // 关联主任务执行实例
+    Long        zoneId;             // 绑定的灌区ID
+
+    // 施肥参数(从任务级统一配置冗余存储,防止任务修改影响执行记录)
+    Integer     delayMinutes;       // 施肥延迟时间
+    Integer     preStirMinutes;     // 搅拌提前时长
+    Integer     fertDurationMinutes; // 施肥时长(时间模式)
+    Integer     fertTargetLiters;   // 施肥目标量(容量模式,null=时间模式)
+
+    // 执行状态
+    FertStatus  status;             // PENDING/STIR_WAITING/STIRRING/FERT_RUNNING/DONE/FAILED
+    Integer     actualFertSeconds;  // 实际施肥时长
+    Integer     actualLiters;       // 实际施肥量(容量模式)
+    String      failReason;         // 失败原因
+
+    // 时间戳
+    DateTime    stirStartAt;        // 搅拌电机实际启动时间
+    DateTime    pumpStartAt;        // 施肥泵实际启动时间
+    DateTime    pumpStopAt;         // 施肥泵实际停止时间
+    DateTime    doneAt;             // 子任务完成时间
+}
+```
+
+##### 5.1.4.6 施肥参数与灌区的关系
+
+施肥参数在创建任务时按任务级统一配置(见 §2.3.2),是否施肥由任务是否选择了施肥机设备来决定,所有灌区共用同一套施肥参数:
+
+```
+任务灌区配置示例(任务已选择施肥机,施肥参数:延迟3min,搅拌提前2min,施肥时长5min):
+  灌区1(Z1):灌溉时长 30min → 创建 FertTask(时间模式,共用施肥参数)
+  灌区2(Z2):灌溉时长 20min → 创建 FertTask(时间模式,共用施肥参数)
+  灌区3(Z3):灌溉时长 25min → 创建 FertTask(时间模式,共用施肥参数)
+
+执行时:
+  执行 Z1 的 WAIT 节点 → 创建 FertTask(共用施肥参数)
+  执行 Z2 的 WAIT 节点 → 创建 FertTask(共用施肥参数)
+  执行 Z3 的 WAIT 节点 → 创建 FertTask(共用施肥参数)
+```
+
+##### 5.1.4.7 安全约束
+
+| 约束                           | 说明                                                                 |
+| ------------------------------ | -------------------------------------------------------------------- |
+| 施肥子任务不阻塞主链路         | FertTask 失败时,主任务继续;主任务可以无视 FertTask 状态            |
+| 灌区结束时强制停止所有施肥设备 | 无论 FertTask 处于什么状态,灌区 WAIT 完成时必须关停施肥泵和搅拌电机 |
+| 搅拌先于施肥泵关闭             | 施肥泵停止后,搅拌电机同步停止(防止残肥沉积)                       |
+| 灌区切换时发送关闭指令         | 容量模式下灌区切换时额外发送关闭施肥机指令,确保安全                 |
+
+---
+
+### 5.1.5 计划生成引擎(Plan Generator)详细设计
+
+计划生成引擎是系统安全性的核心保障,负责将用户配置的"灌溉意图"转换为"安全有序的物理执行节点列表"。本节详细描述引擎的内部架构、Pipeline 各阶段处理逻辑及调用时机。
+
+#### 5.1.5.1 引擎职责与调用时机
+
+引擎在以下三种场景下被调用,每次调用均输出完整或增量的节点列表:
+
+| 调用场景     | 触发时机                    | 输出                                                   |
+| ------------ | --------------------------- | ------------------------------------------------------ |
+| **全量生成** | 任务首次保存 / 任务配置修改 | 完整的 `execution_plan.nodes`                          |
+| **跳过重算** | 运行时跳过某灌区(5.4 节)  | 截断当前 index 后的 PENDING 节点,重新生成后续节点追加 |
+| **故障重建** | Resume API 触发(5.5.4 节) | 在 current_index 前插入启动重建序列节点                |
+
+#### 5.1.5.2 引擎输入契约(PlanGeneratorInput)
+
+```java
+PlanGeneratorInput {
+    // 基础信息
+    Long            taskId;
+    Long            executionId;          // 执行实例ID(全量生成时为新ID)
+
+    // 灌区配置(有序列表,顺序即灌溉意图顺序)
+    // ZoneConfig 合并灌溉组模板(zoneName, zonePressureKpa, deviceList)与任务级配置(durationSeconds)
+    List<ZoneConfig> zones;               // 每个灌区含:zoneId, zoneName, durationSeconds(任务级), zonePressureKpa(模板), deviceList(模板)
+
+    // 施肥配置(任务级统一,所有灌区共用;为null表示不施肥)
+    FertConfig      fertConfig;           // 含:delayMinutes, preStirMinutes, fertDurationMinutes/fertTargetLiters
+    Long            fertPumpDeviceId;     // 施肥泵设备ID(选择了施肥机时有值,否则null)
+    Long            stirDeviceId;         // 搅拌电机设备ID(选择了施肥机时有值,否则null)
+
+    // 压力控制配置
+    PressureMode    pressureMode;         // NONE / PUMP_UNIFIED / PUMP_ZONE
+    Integer         targetPressureKpa;   // 仅 PUMP_UNIFIED 模式有值
+    // 球阀目标压力在各灌溉组的设备列表中配置,若有则随 OPEN_GROUP 下发,与水泵压力模式无关
+
+    // 安全参数
+    Integer         switchStableSeconds; // 灌区切换稳压等待时间
+
+    // 运行时增量参数(跳过重算 / 故障重建时使用)
+    GenerateMode    mode;                 // FULL / SKIP_REBUILD / RESUME_REBUILD
+    Integer         fromIndex;           // 增量生成时的起始节点索引
+    Long            currentOpenZoneId;   // 当前已开启但未关闭的灌区ID(增量时用于衔接)
+    List<Long>      skipZoneIds;         // 运行时已跳过的灌区ID列表
+}
+```
+
+#### 5.1.5.3 引擎输出契约(PlanGeneratorOutput)
+
+```java
+PlanGeneratorOutput {
+    List<ExecutionNode> nodes;           // 生成的节点列表(全量或增量)
+    Integer             startIndex;      // 节点在完整计划中的起始 index
+    Integer             estimatedSeconds; // 本批节点预计总耗时(用于前端展示预估完成时间)
+}
+```
+
+#### 5.1.5.4 五阶段处理 Pipeline
+
+引擎内部按固定顺序执行五个阶段,每个阶段向节点列表中插入对应节点:
+
+```
+输入(PlanGeneratorInput)
+       │
+       ▼
+┌──────────────────────────────────────────────────────┐
+│  Phase 1:输入校验(Validator)                        │
+│  - 灌区列表不能为空                                    │
+│  - switch_stable_seconds ≥ 0                          │
+│  - PUMP_UNIFIED 模式必须有 targetPressureKpa            │
+│  - PUMP_ZONE 模式每个灌区必须有 zonePressureKpa        │
+│  - NONE 模式无额外压力校验                              │
+│  - 跳过重算时 currentOpenZoneId 必须不为空              │
+└──────────────────────┬───────────────────────────────┘
+                       │ 校验通过
+                       ▼
+┌──────────────────────────────────────────────────────┐
+│  Phase 2:首灌区启动序列生成(StartupBuilder)          │
+│                                                       │
+│  输出节点(按 S-01 规则):                            │
+│    OPEN_GROUP(Zone1)                                  │
+│      ← 球阀配置了目标压力时,压力参数随球阀指令下发     │
+│    [SET_PUMP_PRESSURE]  ← PUMP_UNIFIED/PUMP_ZONE 模式  │
+│    START_PUMP                                         │
+│  NONE 模式:                                            │
+│    OPEN_GROUP(Zone1)                                  │
+│    START_PUMP         ← 不插入 SET_PUMP_PRESSURE        │
+│                                                       │
+│  跳过重算(SKIP_REBUILD):                            │
+│    直接跳过此阶段(接续之前的启动状态)                 │
+│  故障重建(RESUME_REBUILD):                           │
+│    生成 S-05 重建启动序列(先开灌区→再开泵)            │
+└──────────────────────┬───────────────────────────────┘
+                       │
+                       ▼
+┌──────────────────────────────────────────────────────┐
+│  Phase 3:灌区轮转序列展开(ZoneRotationExpander)     │
+│                                                       │
+│  输入:有效灌区列表(去掉 skipZoneIds),              │
+│        当前已开启灌区(增量模式时用于衔接)             │
+│                                                       │
+│  对每个灌区(从第2个开始,或增量模式从下一个灌区开始): │
+│    追加: WAIT(zone[i-1].durationSeconds)              │
+│    追加: OPEN_GROUP(zone[i])          ← S-04 先开     │
+│    追加: [SET_PUMP_PRESSURE(zone[i].pressure)]         │
+│           ← 仅 PUMP_ZONE 模式:灌区切换时调整压力     │
+│    追加: ZONE_SWITCH_WAIT(switchStableSeconds) ← S-02 │
+│    追加: CLOSE_GROUP(zone[i-1])       ← S-04 后关     │
+│                                                       │
+│  最后一个灌区额外追加:                               │
+│    追加: WAIT(zone[N].durationSeconds)                │
+└──────────────────────┬───────────────────────────────┘
+                       │
+                       ▼
+┌──────────────────────────────────────────────────────┐
+│  Phase 4:正常结束序列追加(SafeShutdownBuilder)      │
+│                                                       │
+│  遵循 S-03(正常结束顺序):                          │
+│    STOP_PUMP                                          │
+│    CLOSE_GROUP(ZoneN) ← 最后一个灌区(S-04 最后关)   │
+│                                                       │
+│  注意:此处为正常结束流程的计划生成,异常安全关闭见§6.4 │
+│  异常时只关水泵+施肥设备,阀门保持当前状态              │
+└──────────────────────┬───────────────────────────────┘
+                       │
+                       ▼
+┌──────────────────────────────────────────────────────┐
+│  Phase 5:节点索引分配与元数据填充(NodeIndexer)      │
+│                                                       │
+│  对所有节点:                                         │
+│    分配连续 index(全量从0开始,增量从 fromIndex 开始) │
+│    填充 nodeName(用于日志和报警展示)                  │
+│    填充 maxRetry(ACK类节点=3,WAIT类节点=0)          │
+│    填充 params.source(IRRIGATE/ZONE_SWITCH/REBUILD)  │
+│    初始化 status = PENDING                             │
+│    初始化 devices(从 DB 查关联设备信息填入)           │
+│                                                       │
+│  施肥标注(当 fertConfig 不为 null 时):               │
+│    对每个 WAIT(source=IRRIGATE) 节点:                 │
+│      标注 params.fertEnabled = true                    │
+│      标注 params.fertPumpDeviceId / stirDeviceId       │
+│      运行时执行引擎据此创建 FertTask 并发送施肥MQ消息   │
+└──────────────────────┬───────────────────────────────┘
+                       │
+                       ▼
+               输出(PlanGeneratorOutput)
+```
+
+#### 5.1.5.5 三种调用模式的 Pipeline 差异
+
+| Phase              | 全量生成(FULL)   | 跳过重算(SKIP_REBUILD)                                | 故障重建(RESUME_REBUILD)                       |
+| ------------------ | ------------------ | ------------------------------------------------------- | ------------------------------------------------ |
+| Phase 1 校验       | 完整校验           | 额外校验 `currentOpenZoneId`                            | 额外校验泵状态                                   |
+| Phase 2 首启动序列 | ✅ 生成 Zone1 启动 | ❌ 跳过(接续之前的启动状态)                           | ✅ 生成重建启动序列(S-05)                      |
+| Phase 3 灌区展开   | 从 Zone2 开始展开  | 从跳过后的下一个灌区开始,以 `currentOpenZoneId` 为前驱 | 从当前灌区接续,跳过已完成灌区                   |
+| Phase 4 安全关闭   | ✅ 追加            | ✅ 追加                                                 | ✅ 追加                                          |
+| Phase 5 索引分配   | 从 index=0         | 从 `fromIndex` 续编                                     | 插入到 `current_index` 之前,已有节点 index 不变 |
+
+#### 5.1.5.6 引擎类结构(Java 伪代码)
+
+```java
+@Component
+public class PlanGenerator {
+
+    // 五个 Phase 处理器,按顺序注入
+    private final InputValidator        validator;
+    private final StartupBuilder        startupBuilder;
+    private final ZoneRotationExpander  rotationExpander;
+    private final SafeShutdownBuilder   shutdownBuilder;
+    private final NodeIndexer           indexer;
+
+    public PlanGeneratorOutput generate(PlanGeneratorInput input) {
+        // Phase 1
+        validator.validate(input);
+
+        List<ExecutionNode> nodes = new ArrayList<>();
+
+        // Phase 2
+        if (input.getMode() != SKIP_REBUILD) {
+            nodes.addAll(startupBuilder.build(input));
+        }
+
+        // Phase 3
+        nodes.addAll(rotationExpander.expand(input, nodes));
+
+        // Phase 4
+        nodes.addAll(shutdownBuilder.build(input, nodes));
+
+        // Phase 5
+        return indexer.assign(nodes, input);
+    }
+}
+```
+
+#### 5.1.5.7 安全约束检查(内嵌在各 Phase 中)
+
+每个 Phase 在构建节点前都内嵌对应安全规则检查,违反则抛出异常(不生成计划):
+
+| Phase   | 检查点                                                                                                                           | 违反时行为                    |
+| ------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
+| Phase 2 | START_PUMP 前必须已有 OPEN_GROUP(S-01);PUMP_UNIFIED/PUMP_ZONE 模式必须有 SET_PUMP_PRESSURE;NONE 模式不插入 SET_PUMP_PRESSURE | 抛出 SafetyViolationException |
+| Phase 3 | CLOSE_GROUP 前必须已有对应的 OPEN_GROUP 且中间有 ZONE_SWITCH_WAIT(S-02)                                                        | 抛出 SafetyViolationException |
+| Phase 4 | CLOSE_GROUP(ZoneN) 必须在 STOP_PUMP 之后(S-03)                                                                                     | 抛出 SafetyViolationException |
+
+> **设计意图**:安全检查在生成时一次性验证,运行时执行引擎无需重复校验,保证执行路径的确定性。
+
+---
+
+### 5.2 责任链 + 延迟 MQ 执行流程
+
+#### 5.2.1 核心设计思路
+
+- 每个节点由对应的 `NodeHandler` 执行,执行只做**下发指令**,立即返回,不等待设备响应。
+- 通过 RocketMQ 延迟消息实现异步等待,释放线程资源。
+- 整个流程完全由消息驱动,无同步阻塞。
+
+#### 5.2.2 主执行流程图
+
+```
+[MQ消息到达: TASK_START / NEXT_NODE]
+              │
+              ▼
+   [从DB加载 task_execution(含 execution_plan JSON)]
+              │
+              ▼
+   [读取 current_index,取当前节点]
+              │
+              ▼
+   [查找对应 NodeHandler(按节点类型路由)]
+              │
+              ▼
+   [NodeHandler.execute()]
+       │ 下发 MQTT 指令给所有关联设备
+       │ 在 Redis 注册 ACK 期望列表(ACK 注册表)
+       │ 立即返回(不等待设备响应)
+              │
+              ▼
+   [发送两条延迟MQ消息]
+       ├─► 消息A: CHECK_ACK(10秒后)
+       └─► 消息B: ACK_TIMEOUT(30秒后,兜底)
+              │
+              ▼
+   [线程释放,等待延迟消息到达]
+
+
+─────── 10秒后 ──────────────────────────────────────────
+
+[MQ消息到达: CHECK_ACK]
+              │
+              ▼
+   [查询 Redis ACK 注册表,检查所有设备状态]
+              │
+        ┌─────┴──────────────┐
+    全部成功             有失败 / 未全回
+        │                    │
+        ▼                    ▼
+   [更新节点状态=SUCCESS]  [触发重试逻辑(见第6章)]
+   [发送延迟MQ消息]             │
+   NEXT_NODE(N分钟后)    [或5秒后再次CHECK]
+        │
+        ▼
+   [线程释放]
+
+
+─────── N分钟后 ─────────────────────────────────────────
+
+[MQ消息到达: NEXT_NODE]
+              │
+              ▼
+   [current_index + 1,更新DB(乐观锁)]
+              │
+              ▼
+   [index 超出节点列表长度?]
+       │                │
+      否                是
+       │                │
+       ▼                ▼
+   [取下一节点]    [status = SUCCESS]
+   [重复执行]     [记录完成时间]
+```
+
+#### 5.2.3 WAIT 节点处理
+
+WAIT 节点不下发任何设备指令,仅发送一条延迟 MQ 消息(延迟时长 = 配置的灌溉等待时长),消息到达后直接推进到下一节点。线程不做任何等待。
+
+> **施肥子任务触发**:当 WAIT 节点类型为 `IRRIGATE`(灌区灌溉等待)且任务选择了施肥机设备时,WAIT 节点执行时还会同步创建 FertTask 记录并发送施肥延迟 MQ 消息,由施肥子任务处理器接管后续子设备控制,不阻塞主链路。详见 §5.1.4.3。
+
+#### 5.2.4 线程资源对比
+
+```
+Thread.sleep方案:
+  [线程]═══════════════════════════════════════(占用3小时)
+
+责任链+延迟MQ方案:
+  [线程]═[释放]     [线程]═[释放]     [线程]═[释放]
+         ↑延迟消息触发↑      ↑延迟消息触发↑
+
+  每次只占用毫秒级线程,等待期间零占用
+```
+
+---
+
+### 5.3 节点类型定义
+
+#### 5.3.1 节点类型枚举
+
+| 节点类型            | 说明                                                                                                   | 关联设备     | 是否需要 ACK |
+| ------------------- | ------------------------------------------------------------------------------------------------------ | ------------ | ------------ |
+| `OPEN_GROUP`        | 开启灌溉组(电磁阀开 + 球阀到目标角度;球阀配置了目标压力时同时下发压力指令)                          | 电磁阀、球阀 | 是           |
+| `CLOSE_GROUP`       | 关闭灌溉组(电磁阀关 + 球阀归零)                                                                      | 电磁阀、球阀 | 是           |
+| `START_PUMP`        | 启动水泵                                                                                               | 水泵         | 是           |
+| `STOP_PUMP`         | 关闭水泵                                                                                               | 水泵         | 是           |
+| `SET_PUMP_PRESSURE` | 设置变频恒压水泵目标压力(PUMP_UNIFIED 首次下发,PUMP_ZONE 每次灌区切换时下发;NONE 模式不插入此节点) | 水泵         | 是           |
+| `WAIT`              | 灌溉等待指定时长(灌区灌溉中)                                                                         | 无           | 否           |
+| `ZONE_SWITCH_WAIT`  | 灌区切换稳压等待(`switch_stable_seconds`),在 OPEN 后、CLOSE 前执行,防止压力突变                    | 无           | 否           |
+
+> **施肥消息类型**:施肥子任务通过独立的 MQ 延迟消息驱动,不在主节点链路中,因此不作为主链路节点类型。施肥相关的 MQ 消息类型定义如下:
+
+| 施肥消息类型       | 说明                                               | 触发时机                         |
+| ------------------ | -------------------------------------------------- | -------------------------------- |
+| `FERT_STIR_START`  | 搅拌电机启动                                       | 施肥延迟时间到达后触发           |
+| `FERT_PUMP_START`  | 施肥泵启动                                         | 搅拌提前时长到达后触发           |
+| `FERT_PUMP_STOP`   | 施肥泵+搅拌电机停止(仅时间模式)                   | 施肥时长到达后触发               |
+| `FERT_FORCE_STOP`  | 强制关闭施肥设备(灌区结束/切换时兜底)              | 灌区 WAIT 完成或灌区切换时触发   |
+
+#### 5.3.2 NodeHandler 统一接口
+
+```
+NodeHandler 接口:
+  ├── getType()          返回节点类型(用于路由匹配)
+  ├── execute(context)   执行本节点动作(发MQTT指令,立即返回)
+  ├── checkResult()      检查执行结果(查Redis ACK状态)
+  └── onFailure()        失败处理(记录日志、触发重试或终止)
+```
+
+#### 5.3.3 扩展说明
+
+系统采用**策略模式 + 节点类型路由**的扩展设计:
+
+- 每种节点类型对应一个独立的 `NodeHandler` 实现类。
+- 执行引擎通过节点类型字符串查找对应 Handler,执行 `handle(context)` 方法。
+- **新增节点类型只需**:
+  1. 定义新的节点类型枚举值
+  2. 实现对应 `NodeHandler`
+  3. 注册到 Handler 路由表
+- 无需修改任何现有 Handler 或执行引擎代码(开闭原则)。
+
+**未来可扩展的节点类型:**
+
+| 节点类型         | 说明                               |
+| ---------------- | ---------------------------------- |
+| `WEATHER_CHECK`  | 天气检查节点(恶劣天气自动终止)   |
+| `SOIL_SENSOR`    | 土壤传感器检查节点(湿度达标跳过) |
+| `NOTIFY`         | 通知节点(流程中间发通知)         |
+| `MANUAL_CONFIRM` | 人工确认节点(等待人工确认继续)   |
+
+---
+
+### 5.4 运行时动态跳过灌区
+
+当某个灌区的设备开启失败且用户决定跳过时,不能简单标记该节点为跳过,因为先开后关的拓扑关系需要重新计算。系统采用**截断重算**方案:
+
+#### 5.4.1 执行计划中的逻辑层扩展
+
+在 `execution_plan` JSON 根节点新增两个字段,用于支持运行时调整:
+
+```json
+{
+  "zone_sequence": [1, 2, 3],
+  "skip_zones": [],
+  "nodes": [ ... ]
+}
+```
+
+| 字段            | 说明                                                  |
+| --------------- | ----------------------------------------------------- |
+| `zone_sequence` | 当前有效的灌区执行顺序(逻辑顺序,运行时可调整)      |
+| `skip_zones`    | 运行时被跳过的灌区 ID 列表                            |
+| `nodes`         | 物理执行节点列表(由生成引擎根据 zone_sequence 生成) |
+
+#### 5.4.2 跳过流程
+
+```
+[灌区 N 开启失败,用户/系统决定跳过]
+       │
+       ▼
+1. 将 zone_n 写入 skip_zones 列表
+       │
+       ▼
+2. 截断 current_index 之后所有 PENDING 节点
+       │
+       ▼
+3. 以【剩余灌区(去掉 skip_zones)】为输入,
+   以【当前已开启但未关闭的灌区】为"前一个灌区",
+   调用 Plan Generator 重新生成后续节点
+       │
+       ▼
+4. 将新节点追加到 execution_plan.nodes 中
+       │
+       ▼
+5. 更新 current_index,继续执行
+   (先开后关的安全逻辑在重新生成时自动保证)
+```
+
+**示例**(跳过灌区2):
+
+```
+原始计划:
+  OPEN(Z1) → START_PUMP → WAIT(10) → OPEN(Z2) → CLOSE(Z1)
+  → WAIT(10) → OPEN(Z3) → CLOSE(Z2) → WAIT(10) → STOP_PUMP → CLOSE(Z3)
+
+灌区2开启失败,跳过:
+  ✅ OPEN(Z1) → START_PUMP → WAIT(10)
+  ❌ OPEN(Z2) 失败 → 跳过,截断后续节点
+  重新生成:→ OPEN(Z3) → CLOSE(Z1)    ← 正确衔接已开启的 Zone1
+            → WAIT(10) → STOP_PUMP → CLOSE(Z3)
+```
+
+---
+
+### 5.5 故障接续执行(PAUSED 状态)
+
+#### 5.5.1 状态扩展
+
+新增 `PAUSED` 状态,用于区分"可接续的暂停"与"不可恢复的终止":
+
+| 状态        | 含义                              | 后续操作                 |
+| ----------- | --------------------------------- | ------------------------ |
+| `FAILED`    | 硬件故障,不可恢复,触发安全关闭  | 无,任务结束             |
+| `PAUSED`    | 网络/超时类重试耗尽,等待人工介入 | 调用 Resume API 接续执行 |
+| `CANCELLED` | 人工主动放弃                      | 触发安全关闭             |
+
+#### 5.5.2 进入 PAUSED 的条件
+
+```
+重试策略判断(见 6.3):
+
+失败类型为"网络/超时" 且 retryCount >= 3:
+  → status = PAUSED
+  → 发送多通道报警(提示人工检查设备后接续)
+  → 停止发送任何新的 MQ 消息(流程冻结)
+  → 看门狗不再 recover(PAUSED 是预期状态)
+
+失败类型为"硬件故障":
+  → status = FAILED
+  → 触发安全关闭流程
+```
+
+#### 5.5.3 Resume API
+
+```
+POST /task-execution/{id}/resume
+
+Body:
+{
+  "action": "RETRY_CURRENT" | "SKIP_CURRENT"
+}
+```
+
+| Action          | 行为                                                                                             |
+| --------------- | ------------------------------------------------------------------------------------------------ |
+| `RETRY_CURRENT` | 重置当前节点状态为 `PENDING`,重置失败设备 ACK 为 `PENDING`,重新下发 MQTT 指令,流程继续        |
+| `SKIP_CURRENT`  | 若当前节点为 `OPEN_GROUP`,触发 5.4 的跳过流程;否则直接标记当前节点为 `SKIPPED`,推进到下一节点 |
+
+**接续流程:**
+
+```
+[人工修复设备完成]
+       │
+       ▼
+[调用 Resume API(action=RETRY_CURRENT)]
+       │
+       ▼
+[校验 status = PAUSED,否则拒绝]
+       │
+       ▼
+[检查安全关闭记录:泵是否已停止?]
+  ├─ 已停止 ──► [触发 5.5.4 故障接续启动序列重建]
+  └─ 未停止 ──► [直接重置当前节点,继续执行]
+       │
+       ▼
+[status = RUNNING]
+[流程从重建序列(或当前节点)继续执行]
+```
+
+#### 5.5.4 故障接续启动序列重建(S-05)
+
+当任务从 PAUSED 恢复时,若安全关闭流程已停止水泵,直接继续原节点会导致**无泵运行灌区**的危险状态。系统必须在继续执行前插入重建序列。
+
+**重建序列生成规则:**
+
+```
+检查执行计划中已执行的 STOP_PUMP(安全关闭节点):
+
+如果水泵已停止:
+  → 检查当前应灌的灌区(从 zone_sequence 和 skip_zones 推算)
+  → 生成重建序列:
+
+     OPEN_GROUP(当前灌区)             ← S-05:幂等确认灌区已开启(安全关闭未关阀门,灌区仍开启)
+     [SET_PUMP_PRESSURE(压力值)]       ← PUMP_UNIFIED/PUMP_ZONE 模式
+     START_PUMP                       ← S-05:再开水泵
+
+  → 将重建序列插入 execution_plan.nodes 中 current_index 之前
+  → 更新 current_index 指向重建序列首节点
+
+如果水泵未停止(仅部分设备故障):
+  → 直接重置当前节点,不插入重建序列
+```
+
+**示例(灌区1灌溉中,故障停泵后恢复):**
+
+```
+原执行计划(执行到 index=3 的 WAIT 时发生故障):
+  [0] OPEN_GROUP(Z1)  ✅
+  [1] START_PUMP      ✅
+  [2] WAIT(10min)     ← 执行中故障
+  ...
+
+安全关闭(仅关水泵+施肥设备,阀门保持当前状态,见§6.4):
+  [SC-1] STOP_PUMP       ✅
+  (灌区1的电磁阀/球阀仍处于开启状态)
+
+用户 Resume,系统插入重建序列:
+  [R-0] OPEN_GROUP(Z1)        ← 幂等确认灌区已开启(S-05)
+  [R-1] SET_PUMP_PRESSURE     ← 设置压力(PUMP_UNIFIED/PUMP_ZONE 模式)
+  [R-2] START_PUMP            ← 再开水泵(S-05)
+  [2']  WAIT(剩余灌溉时间)    ← 继续灌区1的剩余灌溉时间
+  [3]   OPEN_GROUP(Z2)
+  [4]   ZONE_SWITCH_WAIT(5s)
+  [5]   CLOSE_GROUP(Z1)
+  ...(后续节点不变)
+```
+
+#### 5.5.5 手动取消 API(Cancel)
+
+用户可主动取消正在运行或暂停中的任务,触发安全关闭流程。
+
+```
+POST /task-execution/{id}/cancel
+
+Body:(无参数)
+```
+
+**取消流程:**
+
+```
+[用户调用 Cancel API]
+       │
+       ▼
+[校验 status = RUNNING 或 PAUSED,否则拒绝]
+       │
+       ▼
+[status = CANCELLED]
+       │
+       ▼
+[触发安全关闭流程(见 §6.4)]
+  → 停止水泵 → 关闭施肥设备(阀门保持当前状态)
+       │
+       ▼
+[清理 Redis 运行标记(task:running:{task_id})]
+[发送通知:任务已被手动取消]
+```
+
+> **与 FAILED 的区别**:CANCELLED 是用户主动行为,FAILED 是系统判定不可恢复。两者均触发安全关闭,但报警内容和日志类型不同。
+
+---
+
+## 6. 执行安全保障
+
+### 6.1 三层安全保障体系
+
+```
+第一层:生成时保障
+  → 任务保存时根据安全规则(S-01~S-05)自动生成执行顺序
+  → 人工无法配置出错误的执行顺序
+  → 先开后关(S-02/S-04)、稳压等待(ZONE_SWITCH_WAIT)、水泵启动时机全部自动处理
+
+第二层:执行时保障
+  → 每个节点必须ACK成功才能进入下一个节点
+  → ACK失败立即触发重试或终止
+  → 不会出现上一步没完成就执行下一步
+
+第三层:故障时保障
+  → 安全关闭:关闭水泵 + 施肥设备(施肥泵、搅拌电机),阀门保持当前状态
+  → 从执行记录查已开启设备,确保不遗漏
+  → 关闭失败的设备加入报警,提示人工处理
+  → 故障恢复时自动重建启动序列(S-05):先开灌区→再开泵
+```
+
+---
+
+### 6.2 ACK 超时机制
+
+#### 6.2.1 ACK 注册表(Redis)
+
+发送 MQTT 指令后,在 Redis 中为每个设备写入期望 ACK 记录:
+
+- Key 格式:`ack:{execution_id}:{node_index}:{device_id}`
+- 初始状态:`PENDING`
+- 设备 MQTT 响应后,更新为 `SUCCESS` 或 `FAIL`
+- 设置 TTL = 5 分钟,自动清理过期数据。
+
+#### 6.2.2 ACK 超时处理流程
+
+```
+[发送MQTT指令]
+       │
+       ├─► Redis 注册所有设备为 PENDING
+       │
+       ├─► 发延迟消息A: CHECK_ACK(10秒后)
+       └─► 发延迟消息B: ACK_TIMEOUT(30秒后,兜底)
+
+
+─── 10秒后:CHECK_ACK 消息到达 ──────────────────────
+
+[检查 Redis ACK 注册表]
+       │
+       ├─ 所有设备 SUCCESS ──► [发 NEXT_NODE 延迟消息(N分钟后)]
+       │                        └─► Redis 写幂等标记(已推进)
+       │
+       ├─ 有设备 FAIL ──────► [触发重试流程(见6.3)]
+       │
+       └─ 有设备仍 PENDING ─► [未超deadline → 再发CHECK_ACK(5秒后)]
+                              [已超deadline → 按超时处理]
+
+
+─── 30秒后:ACK_TIMEOUT 消息到达 ────────────────────
+
+[检查 Redis 幂等标记]
+       │
+  ┌────┴────┐
+已推进     未推进
+  │          │
+  ▼          ▼
+ 忽略    [触发超时处理]
+(幂等)      │
+              ├─► 记录超时设备列表
+              ├─► 进入重试流程(或直接终止)
+              └─► 更新 task_execution.status
+```
+
+#### 6.2.3 幂等保障
+
+两条消息(CHECK 和 TIMEOUT)的幂等关系:
+
+```
+正常情况:
+  T+10s: CHECK消息到,ACK全部回来 → 状态推进 → 写幂等标记
+  T+30s: TIMEOUT消息到 → 发现幂等标记存在 → 忽略 ✅
+
+超时情况:
+  T+10s: CHECK消息到,ACK没全回 → 再发CHECK(T+15s)
+  T+15s: CHECK消息到,还没回 → 再发CHECK(T+20s)
+  T+30s: TIMEOUT消息到 → 无幂等标记 → 触发超时处理 ✅
+
+关键:用 status 字段 + Redis幂等标记 做幂等,两条消息不会重复处理
+```
+
+---
+
+### 6.3 重试策略
+
+#### 6.3.1 重试范围
+
+- **重试粒度:单设备级别**,只对状态为 `ACK_FAIL` 或 `TIMEOUT` 的设备重新下发 MQTT 指令。
+- 状态为 `SUCCESS` 的设备不重复操作。
+
+#### 6.3.2 重试规则
+
+| 失败次数                 | 处理方式                                                               |
+| ------------------------ | ---------------------------------------------------------------------- |
+| 第 1 次失败              | 立即重试(无等待)                                                     |
+| 第 2 次失败              | 等待 5 秒后重试                                                        |
+| 第 3 次失败              | 等待 15 秒后重试                                                       |
+| 超过 3 次(网络/超时类) | 任务进入 `PAUSED` 状态 → 多通道报警 → 等待人工调用 Resume API 接续执行 |
+| 硬件故障类错误           | 不重试,直接 `FAILED` → 触发安全关闭 → 发送报警                        |
+
+#### 6.3.3 重试流程
+
+```
+[ACK检查发现有失败设备]
+       │
+       ▼
+[检查失败原因]
+       │
+       ├─► 硬件故障 → 直接 FAILED + 安全关闭 + 报警 ❌
+       │
+       └─► 网络/超时 → 检查 retryCount
+                          │
+                   ┌──────┴──────┐
+            retryCount < 3    retryCount >= 3
+                   │                 │
+                   ▼                 ▼
+         [只对失败设备重发指令]  [status = PAUSED]
+         [retryCount + 1]      [发送多通道报警]
+         [重置失败设备ACK=PENDING] [等待人工调用 Resume API]
+         [发延迟消息(等待间隔后CHECK)]  [见 5.5 故障接续执行]
+         [更新last_heartbeat_at]
+```
+
+#### 6.3.4 部分设备失败的处理
+
+```
+灌区1有5个球阀:
+  valve_001 → ACK_OK  ✅
+  valve_002 → ACK_OK  ✅
+  valve_003 → TIMEOUT ❌  → 重试
+  valve_004 → ACK_OK  ✅
+  valve_005 → ACK_FAIL❌  → 重试
+
+重试时:只对 valve_003 和 valve_005 重发指令
+        valve_001、002、004 不重复发送
+```
+
+---
+
+### 6.4 安全关闭流程
+
+当任务因异常(重试耗尽、不可恢复错误)而终止时,必须触发安全关闭流程。
+
+#### 6.4.1 安全关闭流程图
+
+```
+[任务异常终止触发]
+       │
+       ▼
+[从 execution_plan JSON 查询已开启设备]
+(已执行且status=SUCCESS的OPEN/START类节点中的设备)
+       │
+       ▼
+步骤1: 关闭水泵(如已启动)
+       │ 下发 STOP_PUMP MQTT 指令
+       │ 等待 ACK(最多30秒)
+       │ 超时也继续(尽力而为原则)
+       └─► 失败设备记录入报警列表
+       │
+       ▼
+步骤2: 关闭施肥设备(如已启动)
+       │ 下发施肥泵 OFF、搅拌电机 OFF 指令
+       │ 等待 ACK(最多30秒)
+       │ 超时也继续(尽力而为原则)
+       └─► 失败设备记录入报警列表
+       │
+       ▼
+注意:电磁阀/球阀保持当前状态,不在异常关闭流程中关闭
+(管路中残余水自然排出,避免封闭管路导致水锤效应)
+       │
+       ▼
+[汇总安全关闭结果]
+       │
+       ▼
+[更新 task_execution.status = FAILED]
+[写入 fail_reason 字段]
+       │
+       ▼
+[发送多通道报警]
+  ├─► 短信:失败原因 + 设备列表 + 安全关闭状态
+  ├─► 电话:语音播报关键失败信息(紧急告警时)
+  └─► 移动端推送:同短信内容,推送至 APP
+```
+
+#### 6.4.2 尽力而为原则
+
+安全关闭过程中,即使某个设备关闭失败或超时,也继续执行后续关闭步骤。不因单个设备失败而中断整个安全关闭流程。最终将所有失败设备汇总到报警通知中,提示人工处理。
+
+> **为什么不关阀门**:异常情况下,水泵已关停、管路无水压,阀门保持当前状态不会造成危害。反之,在管路仍有残余压力时关闭阀门可能导致水锤效应或爆管。阀门的正常开关由计划执行引擎(S-03 正常结束序列)统一管理。
+
+#### 6.4.3 报警内容设计
+
+```
+轮灌任务执行失败
+─────────────────────────────
+任务名称:农场A区轮灌
+失败节点:开启灌区2
+失败时间:2026-02-25 02:03:35
+重试次数:3次(已达上限)
+
+失败设备:
+  valve_003  超时未响应
+  ball_005   设备离线
+
+成功设备:
+  valve_001  ✅
+  valve_002  ✅
+  valve_004  ✅
+
+安全关闭状态:
+  水泵       已关闭 ✅
+  施肥泵     已关闭 ✅
+  搅拌电机   已关闭 ✅
+  阀门       保持当前状态(不主动关闭)
+
+请检查失败设备后手动处理
+─────────────────────────────
+```
+
+---
+
+### 6.5 看门狗机制(Watchdog)
+
+系统依赖 RocketMQ 延迟消息驱动流程推进。当 MQ 消息因 Broker 宕机、网络异常等原因丢失时,流程可能停滞。看门狗机制作为最后一道保障,定期扫描"疑似卡住"的任务并触发恢复。
+
+> **设计要点**:由于灌区灌溉时长可能长达数小时甚至 10 小时以上,采用简单的 **12 小时心跳超时**方案。每个节点执行时更新 `last_heartbeat_at`,超过 12 小时未更新则判定为流程卡住。该方案简单可靠,避免了基于预计完成时间的复杂计算,且不会因 PAUSED 状态产生误判逻辑冲突。
+
+#### 6.5.1 心跳更新时机
+
+每个节点执行时(包括 WAIT 节点开始执行时)更新 `last_heartbeat_at = NOW()`:
+
+| 更新时机               | 说明                                                     |
+| ---------------------- | -------------------------------------------------------- |
+| 节点开始执行时          | 任何节点(ACK类、WAIT类)开始执行时立即更新               |
+| CHECK_ACK 消息处理时    | 每次检查 ACK 状态时更新,表明流程仍在活跃处理中           |
+| 重试发生时              | 每次重试下发指令时更新                                    |
+| NEXT_NODE 推进时        | 推进到下一节点时更新                                      |
+
+#### 6.5.2 看门狗扫描规则
+
+通过定时任务(Quartz/ScheduledExecutor)每 **30 分钟** 扫描一次:
+
+```sql
+SELECT * FROM task_execution
+WHERE status = 'RUNNING'
+  AND last_heartbeat_at < NOW() - INTERVAL 12 HOUR
+```
+
+> **为什么是 12 小时**:单个灌区灌溉时长最长约 10 小时,加上执行前的 WAIT 启动和切换等待,12 小时是一个合理的安全阈值。正常情况下每个节点执行都会更新心跳,只有 MQ 消息真正丢失时才会超过 12 小时无心跳。
+
+#### 6.5.3 恢复策略
+
+```
+[看门狗发现疑似卡住的任务]
+       │
+       ▼
+[检查 task_execution.status]
+       │
+  ┌────┴──────────┐
+RUNNING          PAUSED
+  │                │
+  ▼                ▼
+[重新发送当前节点]   [忽略,PAUSED 是预期状态]
+[NEXT_NODE 消息]    [等待人工 Resume]
+  │
+  ▼
+[查 current_index 对应节点状态]
+  │
+  ├─ PENDING → 重新触发 execute()
+  ├─ RUNNING → 重新发送 CHECK_ACK
+  └─ SUCCESS → 发送 NEXT_NODE 推进
+```
+
+#### 6.5.4 防重复恢复
+
+- 看门狗恢复也是通过发 MQ 消息触发,消费端通过 Redis 幂等标记和节点 status 做幂等校验。
+- 看门狗每次恢复后更新 `last_heartbeat_at`,防止下一轮扫描重复处理。
+
+---
+
+### 6.6 并发触发防护
+
+同一轮灌任务不允许出现多个同时运行的执行实例。由于同一任务可关联多个触发来源(定时 + 联动 + 手动),任意触发来源在创建新执行实例前,必须检查该任务是否存在未结束的执行实例:
+
+```
+[触发信号到达(Quartz / 联动规则引擎 / 手动触发)]
+       │
+       ▼
+[查询 task_execution 表:同一 task_id 是否存在 status = RUNNING / PAUSED 的记录]
+       │
+  ┌────┴────────────┐
+ 存在(正在执行)   不存在
+  │                  │
+  ▼                  ▼
+[本次触发放弃]    [正常创建 task_execution]
+[记录日志]        [发送 TASK_START MQ 消息]
+[可选:发送通知]
+```
+
+**实现方式:**
+
+所有触发来源统一使用 Redis 原子标记(`SET NX`)作为并发门禁:
+
+- Key 格式:`task:running:{task_id}`,值为 `execution_id`。
+- 触发时先 `SET NX`,成功则创建执行实例,失败则拒绝本次触发。
+- 任务结束(SUCCESS / FAILED / CANCELLED)时删除该 Key。
+- PAUSED 状态不删除 Key,防止暂停期间被重复触发。
+
+---
+
+## 7. 数据存储设计
+
+### 7.1 核心表结构
+
+#### 7.1.1 task_execution(任务执行实例表)
+
+| 字段名               | 类型        | 说明                                                                            |
+| -------------------- | ----------- | ------------------------------------------------------------------------------- |
+| `id`                 | BIGINT PK   | 执行实例唯一ID                                                                  |
+| `task_id`            | BIGINT      | 关联的轮灌任务ID                                                                |
+| `trigger_type`       | VARCHAR(20) | 触发类型:`SCHEDULED` / `LINKAGE` / `MANUAL`                                    |
+| `execution_plan`     | JSON        | 完整有序节点列表(核心字段,见7.2)                                             |
+| `current_index`      | INT         | 当前正在执行的节点索引                                                          |
+| `status`             | VARCHAR(20) | 执行状态:`PENDING` / `RUNNING` / `SUCCESS` / `FAILED` / `PAUSED` / `CANCELLED` |
+| `version`            | INT         | 乐观锁版本号,防止并发更新冲突                                                  |
+| `started_at`         | DATETIME    | 实际开始时间                                                                    |
+| `finished_at`        | DATETIME    | 完成时间                                                                        |
+| `expected_finish_at` | DATETIME    | 预计完成时间(含重试顺延后更新,前端展示用)                                    |
+| `last_heartbeat_at`  | DATETIME    | 最近一次心跳时间(看门狗主要判据,每个节点执行时更新,超过12小时未更新则判定卡住) |
+| `paused_at`          | DATETIME    | 进入 PAUSED 状态的时间                                                          |
+| `fail_reason`        | TEXT        | 失败/暂停原因描述                                                               |
+
+**乐观锁使用场景:**
+
+```sql
+UPDATE task_execution
+SET execution_plan = ?, current_index = ?, version = version + 1
+WHERE id = ? AND version = ?
+
+→ 防止集群下多节点同时更新同一个执行实例
+```
+
+#### 7.1.2 alarm_record(报警记录表)
+
+| 字段名              | 类型         | 说明                                                  |
+| ------------------- | ------------ | ----------------------------------------------------- |
+| `id`                | BIGINT PK    | 报警记录唯一ID                                        |
+| `execution_id`      | BIGINT       | 关联的执行实例ID                                      |
+| `task_id`           | BIGINT       | 关联的任务ID                                          |
+| `alarm_type`        | VARCHAR(50)  | 报警类型:`DEVICE_FAIL` / `TIMEOUT` / `SAFE_CLOSE` 等 |
+| `failed_node`       | VARCHAR(100) | 失败节点名称                                          |
+| `fail_reason`       | TEXT         | 详细失败原因                                          |
+| `failed_devices`    | JSON         | 失败设备列表                                          |
+| `success_devices`   | JSON         | 成功设备列表                                          |
+| `safe_close_status` | VARCHAR(20)  | 安全关闭结果:`SUCCESS` / `PARTIAL` / `FAILED`        |
+| `notified_channels` | VARCHAR(100) | 已通知渠道(SMS/PHONE/APP)                           |
+| `created_at`        | DATETIME     | 报警时间                                              |
+
+#### 7.1.3 fert_task(施肥子任务表)
+
+> 数据结构详见 §5.1.4.5。
+
+| 字段名                  | 类型        | 说明                                                                                     |
+| ----------------------- | ----------- | ---------------------------------------------------------------------------------------- |
+| `id`                    | BIGINT PK   | 施肥子任务唯一ID                                                                         |
+| `execution_id`          | BIGINT      | 关联主任务执行实例ID                                                                     |
+| `zone_id`               | BIGINT      | 绑定的灌区ID                                                                             |
+| `delay_minutes`         | INT         | 施肥延迟时间(分钟,来自任务级灌区配置)                                                 |
+| `pre_stir_minutes`      | INT         | 搅拌提前时长(分钟)                                                                     |
+| `fert_duration_minutes` | INT         | 施肥时长(时间模式,分钟)                                                               |
+| `fert_target_liters`    | INT         | 施肥目标量(容量模式,升;NULL 表示时间模式)                                            |
+| `status`                | VARCHAR(20) | 子任务状态:`PENDING` / `STIR_WAITING` / `STIRRING` / `FERT_RUNNING` / `DONE` / `FAILED` |
+| `actual_fert_seconds`   | INT         | 实际施肥时长(秒)                                                                       |
+| `actual_liters`         | INT         | 实际施肥量(容量模式)                                                                   |
+| `fail_reason`           | TEXT        | 失败原因                                                                                 |
+| `stir_start_at`         | DATETIME    | 搅拌电机实际启动时间                                                                     |
+| `pump_start_at`         | DATETIME    | 施肥泵实际启动时间                                                                       |
+| `pump_stop_at`          | DATETIME    | 施肥泵实际停止时间                                                                       |
+| `done_at`               | DATETIME    | 子任务完成时间                                                                           |
+
+---
+
+### 7.2 execution_plan JSON 结构
+
+```json
+{
+  "zone_sequence": [101, 102, 103],
+  "skip_zones": [],
+  "nodes": [
+    {
+      "index": 0,
+      "nodeType": "OPEN_GROUP",
+      "nodeName": "开启灌区1",
+      "refId": 101,
+      "params": {
+        "targetAngle": 80,
+        "targetPressureKpa": 250
+      },
+      "status": "SUCCESS",
+      "retryCount": 0,
+      "maxRetry": 3,
+      "startedAt": "2026-02-25T02:00:00",
+      "finishedAt": "2026-02-25T02:00:05",
+      "devices": [
+        {
+          "deviceId": "valve-001",
+          "deviceType": "SOLENOID_VALVE",
+          "ackStatus": "SUCCESS",
+          "retryCount": 0,
+          "failReason": null,
+          "ackAt": "2026-02-25T02:00:03"
+        },
+        {
+          "deviceId": "ball-valve-001",
+          "deviceType": "BALL_VALVE",
+          "ackStatus": "SUCCESS",
+          "retryCount": 1,
+          "failReason": null,
+          "ackAt": "2026-02-25T02:00:04"
+        }
+      ]
+    },
+    {
+      "index": 1,
+      "nodeType": "SET_PUMP_PRESSURE",
+      "nodeName": "设置水泵压力",
+      "refId": 201,
+      "params": {
+        "pressureKpa": 300
+      },
+      "status": "SUCCESS",
+      "retryCount": 0,
+      "maxRetry": 3,
+      "startedAt": "2026-02-25T02:00:06",
+      "finishedAt": "2026-02-25T02:00:08",
+      "devices": [
+        {
+          "deviceId": "pump-001",
+          "deviceType": "PRESSURE_PUMP",
+          "ackStatus": "SUCCESS",
+          "retryCount": 0,
+          "failReason": null,
+          "ackAt": "2026-02-25T02:00:07"
+        }
+      ]
+    },
+    {
+      "index": 2,
+      "nodeType": "START_PUMP",
+      "nodeName": "开启水泵",
+      "refId": 201,
+      "params": {},
+      "status": "RUNNING",
+      "retryCount": 0,
+      "maxRetry": 3,
+      "startedAt": "2026-02-25T02:00:09",
+      "finishedAt": null,
+      "devices": [
+        {
+          "deviceId": "pump-001",
+          "deviceType": "PRESSURE_PUMP",
+          "ackStatus": "PENDING",
+          "retryCount": 0,
+          "failReason": null,
+          "ackAt": null
+        }
+      ]
+    },
+    {
+      "index": 3,
+      "nodeType": "WAIT",
+      "nodeName": "等待3分钟",
+      "refId": null,
+      "params": {
+        "seconds": 180
+      },
+      "status": "PENDING",
+      "retryCount": 0,
+      "maxRetry": 0,
+      "startedAt": null,
+      "finishedAt": null,
+      "devices": []
+    }
+  ]
+}
+```
+
+**字段说明:**
+
+| 字段                       | 说明                                                                                                                                                |
+| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `zone_sequence`            | 当前有效的灌区执行顺序(灌区 ID 列表,运行时可调整)                                                                                                |
+| `skip_zones`               | 运行时被跳过的灌区 ID 列表,跳过后重新生成后续节点                                                                                                  |
+| `index`                    | 节点顺序索引,从 0 开始                                                                                                                             |
+| `nodeType`                 | 节点类型,对应 Handler 路由                                                                                                                         |
+| `nodeName`                 | 可读的节点名称,用于日志和报警展示                                                                                                                  |
+| `refId`                    | 关联实体 ID(灌溉组ID / 水泵ID)                                                                                                                    |
+| `params`                   | 节点执行参数;`OPEN_GROUP` 含 `targetAngle`、`targetPressureKpa`(球阀配置了目标压力时);`SET_PUMP_PRESSURE` 含 `pressureKpa`;`WAIT` 含 `seconds` |
+| `params.source`            | WAIT 类节点来源标识:`IRRIGATE`(灌溉等待)/ `ZONE_SWITCH`(稳压等待)/ `RESUME_REBUILD`(故障接续重建节点)                                        |
+| `status`                   | 节点状态:`PENDING` / `RUNNING` / `SUCCESS` / `FAILED` / `SKIPPED`                                                                                  |
+| `retryCount`               | 节点级别已重试次数                                                                                                                                  |
+| `maxRetry`                 | 最大重试次数(默认3,WAIT/ZONE_SWITCH_WAIT 节点为0)                                                                                                |
+| `startedAt` / `finishedAt` | 节点执行时间记录                                                                                                                                    |
+| `devices[].ackStatus`      | 单设备 ACK 状态:`PENDING` / `SUCCESS` / `FAIL` / `TIMEOUT`                                                                                         |
+| `devices[].retryCount`     | 单设备已重试次数                                                                                                                                    |
+| `devices[].failReason`     | 单设备失败原因                                                                                                                                      |
+
+### 7.3 JSON 存储优势
+
+```
+查某次执行的完整计划        → JSON一次读完 ✅
+查当前执行到哪一步          → current_index字段 ✅
+查某次执行是否成功          → status字段 ✅
+安全关闭查已开启设备        → 从JSON中内存过滤 ✅
+跳过灌区后重算后续计划      → 截断nodes + 重新追加新节点 ✅
+故障接续判断是否需要重建序列 → 查JSON中 STOP_PUMP 安全关闭节点 ✅
+执行历史归档               → task_execution记录保留,JSON即完整记录 ✅
+
+JSON大小估算:
+  10个节点,每节点含10个设备 → 约5-10KB
+  完全在MySQL JSON字段承受范围内
+```

+ 295 - 0
mvnw

@@ -0,0 +1,295 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.4
+#
+# Optional ENV vars
+# -----------------
+#   JAVA_HOME - location of a JDK home dir, required when download maven via java source
+#   MVNW_REPOURL - repo url base for downloading maven distribution
+#   MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+#   MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+  [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+  native_path() { cygpath --path --windows "$1"; }
+  ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+  # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+  if [ -n "${JAVA_HOME-}" ]; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+      # IBM's JDK on AIX uses strange locations for the executables
+      JAVACMD="$JAVA_HOME/jre/sh/java"
+      JAVACCMD="$JAVA_HOME/jre/sh/javac"
+    else
+      JAVACMD="$JAVA_HOME/bin/java"
+      JAVACCMD="$JAVA_HOME/bin/javac"
+
+      if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+        echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+        echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+        return 1
+      fi
+    fi
+  else
+    JAVACMD="$(
+      'set' +e
+      'unset' -f command 2>/dev/null
+      'command' -v java
+    )" || :
+    JAVACCMD="$(
+      'set' +e
+      'unset' -f command 2>/dev/null
+      'command' -v javac
+    )" || :
+
+    if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+      echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+      return 1
+    fi
+  fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+  str="${1:-}" h=0
+  while [ -n "$str" ]; do
+    char="${str%"${str#?}"}"
+    h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+    str="${str#?}"
+  done
+  printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+  printf %s\\n "$1" >&2
+  exit 1
+}
+
+trim() {
+  # MWRAPPER-139:
+  #   Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+  #   Needed for removing poorly interpreted newline sequences when running in more
+  #   exotic environments such as mingw bash on Windows.
+  printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+  case "${key-}" in
+  distributionUrl) distributionUrl=$(trim "${value-}") ;;
+  distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+  esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+  MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+  case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+  *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+  :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+  :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+  :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+  *)
+    echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+    distributionPlatform=linux-amd64
+    ;;
+  esac
+  distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+  ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+  unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+  exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+  verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+  exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+  clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+  trap clean HUP INT TERM EXIT
+else
+  die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+  distributionUrl="${distributionUrl%.zip}.tar.gz"
+  distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+  verbose "Found wget ... using wget"
+  wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+  verbose "Found curl ... using curl"
+  curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+  verbose "Falling back to use Java to download"
+  javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+  targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+  cat >"$javaSource" <<-END
+	public class Downloader extends java.net.Authenticator
+	{
+	  protected java.net.PasswordAuthentication getPasswordAuthentication()
+	  {
+	    return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+	  }
+	  public static void main( String[] args ) throws Exception
+	  {
+	    setDefault( new Downloader() );
+	    java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+	  }
+	}
+	END
+  # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+  verbose " - Compiling Downloader.java ..."
+  "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+  verbose " - Running Downloader.java ..."
+  "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+  distributionSha256Result=false
+  if [ "$MVN_CMD" = mvnd.sh ]; then
+    echo "Checksum validation is not supported for maven-mvnd." >&2
+    echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+    exit 1
+  elif command -v sha256sum >/dev/null; then
+    if echo "$distributionSha256Sum  $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+      distributionSha256Result=true
+    fi
+  elif command -v shasum >/dev/null; then
+    if echo "$distributionSha256Sum  $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+      distributionSha256Result=true
+    fi
+  else
+    echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+    echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+    exit 1
+  fi
+  if [ $distributionSha256Result = false ]; then
+    echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+    echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+    exit 1
+  fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+  unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+  tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+  if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+    actualDistributionDir="$distributionUrlNameMain"
+  fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+  # enable globbing to iterate over items
+  set +f
+  for dir in "$TMP_DOWNLOAD_DIR"/*; do
+    if [ -d "$dir" ]; then
+      if [ -f "$dir/bin/$MVN_CMD" ]; then
+        actualDistributionDir="$(basename "$dir")"
+        break
+      fi
+    fi
+  done
+  set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+  verbose "Contents of $TMP_DOWNLOAD_DIR:"
+  verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+  die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"

+ 189 - 0
mvnw.cmd

@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements.  See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership.  The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License.  You may obtain a copy of the License at
+@REM
+@REM    http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied.  See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM   MVNW_REPOURL - repo url base for downloading maven distribution
+@REM   MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM   MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+  IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+  $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+  Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+  "maven-mvnd-*" {
+    $USE_MVND = $true
+    $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+    $MVN_CMD = "mvnd.cmd"
+    break
+  }
+  default {
+    $USE_MVND = $false
+    $MVN_CMD = $script -replace '^mvnw','mvn'
+    break
+  }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
+if ($env:MVNW_REPOURL) {
+  $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+  $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+  $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+    New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+  $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+  $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+  Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+  Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+  exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+  Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+  if ($TMP_DOWNLOAD_DIR.Exists) {
+    try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+    catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+  }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+  $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+  if ($USE_MVND) {
+    Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+  }
+  Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+  if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+    Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+  }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+  $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+  Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+    $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+    if (Test-Path -Path $testPath -PathType Leaf) {
+      $actualDistributionDir = $_.Name
+    }
+  }
+}
+
+if (!$actualDistributionDir) {
+  Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+  Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+  if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+    Write-Error "fail to move MAVEN_HOME"
+  }
+} finally {
+  try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+  catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

+ 197 - 0
pom.xml

@@ -0,0 +1,197 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>cn.sciento</groupId>
+        <artifactId>stong-parent</artifactId>
+        <version>1.2.0-RELEASE</version>
+        <relativePath/> <!-- lookup parent from repository -->
+    </parent>
+    <groupId>cn.sciento.farm</groupId>
+    <artifactId>wf-equipment-automation-V2</artifactId>
+    <version>0.0.1-SNAPSHOT</version>
+    <name>wf-equipment-automation-V2</name>
+    <description>wf-equipment-automation-V2</description>
+
+    <properties>
+        <java.version>1.8</java.version>
+        <c3p0.version>0.9.5.2</c3p0.version>
+        <druid.version>1.0.25</druid.version>
+        <rocketmq.version>2.2.2</rocketmq.version>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-undertow</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.springframework.boot</groupId>
+                    <artifactId>spring-boot-starter-tomcat</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>cn.sciento.starter</groupId>
+            <artifactId>stong-starter-core</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>cn.sciento.starter</groupId>
+            <artifactId>stong-starter-feign-replay</artifactId>
+        </dependency>
+
+
+        <dependency>
+            <groupId>cn.sciento.starter</groupId>
+            <artifactId>stong-starter-redis</artifactId>
+        </dependency>
+
+
+        <dependency>
+            <groupId>cn.sciento.starter</groupId>
+            <artifactId>stong-starter-mybatis-mapper</artifactId>
+            <!--            <version>1.0.0-SNAPSHOT</version>-->
+        </dependency>
+
+        <dependency>
+            <groupId>cn.sciento.tool</groupId>
+            <artifactId>stong-tool-liquibase</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>cn.sciento.boot</groupId>
+            <artifactId>stong-boot-tenant</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+        </dependency>
+        <!-- 数据库连接池相关 -->
+        <dependency>
+            <groupId>com.mchange</groupId>
+            <artifactId>c3p0</artifactId>
+            <version>${c3p0.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-dbcp2</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>druid</artifactId>
+            <version>${druid.version}</version>
+        </dependency>
+
+        <!-- register and config -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-configuration-processor</artifactId>
+            <optional>true</optional>
+        </dependency>
+        <dependency>
+            <groupId>com.alibaba.cloud</groupId>
+            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
+        </dependency>
+
+
+        <!--MongoDB-->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-mongodb</artifactId>
+        </dependency>
+
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+
+
+        <!--timeFormat-->
+        <dependency>
+            <groupId>joda-time</groupId>
+            <artifactId>joda-time</artifactId>
+            <version>2.3</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <version>1.18.30</version>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- RocketMQ -->
+        <dependency>
+            <groupId>org.apache.rocketmq</groupId>
+            <artifactId>rocketmq-spring-boot-starter</artifactId>
+            <version>${rocketmq.version}</version>
+        </dependency>
+
+        <!-- Quartz Scheduler -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-quartz</artifactId>
+        </dependency>
+
+        <!-- WebSocket -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-websocket</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <repositories>
+        <repository>
+            <id>StongPublic</id>
+            <name>StongCentral</name>
+            <url>https://nexus.sciento.cn/repository/maven-public/</url>
+            <releases>
+                <enabled>true</enabled>
+            </releases>
+            <snapshots>
+                <enabled>true</enabled>
+            </snapshots>
+        </repository>
+    </repositories>
+
+    <distributionManagement>
+        <!--发布版本仓库-->
+        <repository>
+            <!--nexus服务器中用户名:在settings.xml中和<server>的id一致-->
+            <id>StongReleases</id>
+            <!--自定义名称-->
+            <name>StongReleases</name>
+            <!--仓库地址-->
+            <url>https://nexus.sciento.cn/repository/maven-releases/</url>
+        </repository>
+        <!--快照版本仓库-->
+        <snapshotRepository>
+            <!--nexus服务器中用户名:在settings.xml中和<server>的id一致-->
+            <id>StongSnapshots</id>
+            <!--自定义名称-->
+            <name>StongSnapshots</name>
+            <!--仓库地址-->
+            <url>https://nexus.sciento.cn/repository/maven-snapshots/</url>
+        </snapshotRepository>
+    </distributionManagement>
+
+</project>

+ 16 - 0
src/main/java/cn/sciento/farm/autoconfigure/EnableWfAutomationV2.java

@@ -0,0 +1,16 @@
+package cn.sciento.farm.autoconfigure;
+
+import org.springframework.context.annotation.Import;
+
+import java.lang.annotation.*;
+
+/**
+ * @author wumu
+ */
+@Target(ElementType.TYPE) // (注解的作用目标) 接口、类、枚举
+@Retention(RetentionPolicy.RUNTIME) //  (注解的保留位置) 注解会在class字节码文件中存在,在运行时可以通过反射获取到
+@Documented //说明该注解将被包含在javadoc中
+@Inherited //说明子类可以继承父类中的该注解
+@Import({WebConditionAutoConfiguration.class})
+public @interface EnableWfAutomationV2 {
+}

+ 18 - 0
src/main/java/cn/sciento/farm/autoconfigure/WebConditionAutoConfiguration.java

@@ -0,0 +1,18 @@
+package cn.sciento.farm.autoconfigure;
+
+import cn.sciento.resource.annoation.EnableSTongResourceServer;
+import org.springframework.cloud.openfeign.EnableFeignClients;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * @author wumu
+ */
+// @ComponentScan 扫描该类包下所有注解
+@ComponentScan({"cn.sciento.farm.automationv2.api", "cn.sciento.farm.automationv2.app", "cn.sciento.farm.automationv2.config",
+        "cn.sciento.farm.automationv2.domain", "cn.sciento.farm.automationv2.infra"})
+@Configuration
+@EnableSTongResourceServer
+@EnableFeignClients("cn.sciento.farm.automationv2.infra.feign")
+public class WebConditionAutoConfiguration {
+}

+ 17 - 0
src/main/java/cn/sciento/farm/automationv2/WfEquipmentAutomationV2Application.java

@@ -0,0 +1,17 @@
+package cn.sciento.farm.automationv2;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cloud.openfeign.EnableFeignClients;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@SpringBootApplication
+@EnableFeignClients
+@EnableScheduling
+public class WfEquipmentAutomationV2Application {
+
+    public static void main(String[] args) {
+        SpringApplication.run(WfEquipmentAutomationV2Application.class, args);
+    }
+
+}

+ 129 - 0
src/main/java/cn/sciento/farm/automationv2/api/controller/AlarmController.java

@@ -0,0 +1,129 @@
+package cn.sciento.farm.automationv2.api.controller;
+
+import cn.sciento.farm.automationv2.api.dto.PageRequest;
+import cn.sciento.farm.automationv2.api.dto.Result;
+import cn.sciento.farm.automationv2.app.service.AlarmNotificationService;
+import cn.sciento.farm.automationv2.app.service.AlarmRecordService;
+import cn.sciento.farm.automationv2.domain.entity.AlarmRecord;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 报警记录API
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/alarms")
+@RequiredArgsConstructor
+public class AlarmController {
+
+    private final AlarmRecordService alarmRecordService;
+    private final AlarmNotificationService alarmNotificationService;
+
+    /**
+     * 查询报警详情
+     */
+    @GetMapping("/{id}")
+    public Result<AlarmRecord> getAlarm(@PathVariable Long id) {
+        log.info("查询报警详情,alarmId={}", id);
+
+        AlarmRecord alarm = alarmRecordService.getAlarmById(id);
+        if (alarm == null) {
+            return Result.error("报警记录不存在");
+        }
+
+        return Result.success(alarm);
+    }
+
+    /**
+     * 分页查询报警记录
+     */
+    @GetMapping
+    public Result<List<AlarmRecord>> listAlarms(PageRequest pageRequest) {
+        log.info("分页查询报警记录,pageNum={}, pageSize={}, tenantId={}",
+                pageRequest.getPageNum(), pageRequest.getPageSize(), pageRequest.getTenantId());
+
+        List<AlarmRecord> alarms = alarmRecordService.listAlarms(
+                pageRequest.getTenantId(),
+                pageRequest.getOffset(),
+                pageRequest.getLimit()
+        );
+
+        return Result.success(alarms);
+    }
+
+    /**
+     * 查询未处理报警
+     */
+    @GetMapping("/unhandled")
+    public Result<List<AlarmRecord>> listUnhandledAlarms(@RequestParam Long tenantId,
+                                                          @RequestParam(defaultValue = "50") Integer limit) {
+        log.info("查询未处理报警,tenantId={}, limit={}", tenantId, limit);
+
+        List<AlarmRecord> alarms = alarmRecordService.listUnhandledAlarms(tenantId, limit);
+
+        return Result.success(alarms);
+    }
+
+    /**
+     * 标记报警已处理
+     */
+    @PostMapping("/{id}/handle")
+    public Result<Void> handleAlarm(@PathVariable Long id,
+                                     @RequestParam Long handledBy,
+                                     @RequestParam(required = false) String remark) {
+        log.info("标记报警已处理,alarmId={}, handledBy={}, remark={}", id, handledBy, remark);
+
+        try {
+            alarmRecordService.markAsHandled(id, handledBy, remark);
+            return Result.success();
+
+        } catch (Exception e) {
+            log.error("标记报警失败,alarmId={}", id, e);
+            return Result.error("标记失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 重新发送报警通知
+     */
+    @PostMapping("/{id}/resend")
+    public Result<Void> resendAlarm(@PathVariable Long id) {
+        log.info("重新发送报警通知,alarmId={}", id);
+
+        try {
+            AlarmRecord alarm = alarmRecordService.getAlarmById(id);
+            if (alarm == null) {
+                return Result.error("报警记录不存在");
+            }
+
+            alarmNotificationService.sendAlarm(alarm.getExecutionId());
+
+            return Result.success();
+
+        } catch (Exception e) {
+            log.error("重新发送报警失败,alarmId={}", id, e);
+            return Result.error("发送失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 发送测试报警短信
+     */
+    @PostMapping("/test-sms")
+    public Result<Void> sendTestSms(@RequestParam String phoneNumber) {
+        log.info("发送测试报警短信,phoneNumber={}", phoneNumber);
+
+        try {
+            alarmNotificationService.sendTestAlarm(phoneNumber);
+            return Result.success();
+
+        } catch (Exception e) {
+            log.error("发送测试短信失败,phoneNumber={}", phoneNumber, e);
+            return Result.error("发送失败: " + e.getMessage());
+        }
+    }
+}

+ 165 - 0
src/main/java/cn/sciento/farm/automationv2/api/controller/ExecutionMonitorController.java

@@ -0,0 +1,165 @@
+package cn.sciento.farm.automationv2.api.controller;
+
+import cn.sciento.farm.automationv2.api.dto.PageRequest;
+import cn.sciento.farm.automationv2.api.dto.Result;
+import cn.sciento.farm.automationv2.app.service.SafeShutdownService;
+import cn.sciento.farm.automationv2.domain.entity.TaskExecution;
+import cn.sciento.farm.automationv2.domain.enums.ExecutionStatus;
+import cn.sciento.farm.automationv2.infra.repository.TaskExecutionMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 执行监控API
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/executions")
+@RequiredArgsConstructor
+public class ExecutionMonitorController {
+
+    private final TaskExecutionMapper taskExecutionMapper;
+    private final SafeShutdownService safeShutdownService;
+
+    /**
+     * 查询执行详情(含完整execution_plan)
+     */
+    @GetMapping("/{id}")
+    public Result<TaskExecution> getExecution(@PathVariable Long id) {
+        log.info("查询执行详情,executionId={}", id);
+
+        TaskExecution execution = taskExecutionMapper.selectById(id);
+        if (execution == null) {
+            return Result.error("执行实例不存在");
+        }
+
+        return Result.success(execution);
+    }
+
+    /**
+     * 分页查询执行历史
+     */
+    @GetMapping
+    public Result<List<TaskExecution>> listExecutions(PageRequest pageRequest) {
+        log.info("分页查询执行历史,pageNum={}, pageSize={}, tenantId={}",
+                pageRequest.getPageNum(), pageRequest.getPageSize(), pageRequest.getTenantId());
+
+        List<TaskExecution> executions = taskExecutionMapper.selectByTenant(
+                pageRequest.getTenantId(),
+                pageRequest.getOffset(),
+                pageRequest.getLimit()
+        );
+
+        return Result.success(executions);
+    }
+
+    /**
+     * 根据任务ID查询执行历史
+     */
+    @GetMapping("/task/{taskId}")
+    public Result<List<TaskExecution>> listExecutionsByTask(
+            @PathVariable Long taskId,
+            PageRequest pageRequest) {
+        log.info("查询任务执行历史,taskId={}, pageNum={}, pageSize={}",
+                taskId, pageRequest.getPageNum(), pageRequest.getPageSize());
+
+        List<TaskExecution> executions = taskExecutionMapper.selectByTaskId(
+                taskId,
+                pageRequest.getOffset(),
+                pageRequest.getLimit()
+        );
+
+        return Result.success(executions);
+    }
+
+    /**
+     * 查询执行状态
+     */
+    @GetMapping("/{id}/status")
+    public Result<ExecutionStatus> getExecutionStatus(@PathVariable Long id) {
+        log.info("查询执行状态,executionId={}", id);
+
+        TaskExecution execution = taskExecutionMapper.selectById(id);
+        if (execution == null) {
+            return Result.error("执行实例不存在");
+        }
+
+        return Result.success(execution.getStatus());
+    }
+
+    /**
+     * 取消执行(触发安全关闭)
+     */
+    @PostMapping("/{id}/cancel")
+    public Result<Void> cancelExecution(@PathVariable Long id) {
+        log.info("取消执行,executionId={}", id);
+
+        try {
+            // 加载执行实例
+            TaskExecution execution = taskExecutionMapper.selectById(id);
+            if (execution == null) {
+                return Result.error("执行实例不存在");
+            }
+
+            // 检查是否可以取消
+            if (execution.isTerminal()) {
+                return Result.error("执行已终止,无法取消");
+            }
+
+            // 更新状态为CANCELLED
+            execution.markAsCancelled();
+            execution.setFinishedAt(LocalDateTime.now());
+            taskExecutionMapper.updateByVersion(execution);
+
+            // 触发安全关闭
+            SafeShutdownService.ShutdownResult result = safeShutdownService.shutdown(id);
+
+            // 更新安全关闭结果
+            execution.setSafeCloseStatus(result.isSuccess() ? "SUCCESS" : "PARTIAL");
+            execution.setSafeCloseDetails(String.format(
+                    "{\"success\":[%s],\"failed\":[%s]}",
+                    String.join(",", result.getSuccessDevices().stream()
+                            .map(d -> "\"" + d + "\"").toArray(String[]::new)),
+                    String.join(",", result.getFailedDevices().stream()
+                            .map(d -> "\"" + d + "\"").toArray(String[]::new))
+            ));
+            taskExecutionMapper.updateByVersion(execution);
+
+            log.info("执行已取消,executionId={}, shutdownResult={}", id, result.getSummary());
+
+            return Result.success();
+
+        } catch (Exception e) {
+            log.error("取消执行失败,executionId={}", id, e);
+            return Result.error("取消执行失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 查询正在执行的任务
+     */
+    @GetMapping("/running")
+    public Result<List<TaskExecution>> listRunningExecutions(@RequestParam Long tenantId) {
+        log.info("查询正在执行的任务,tenantId={}", tenantId);
+
+        List<TaskExecution> executions = taskExecutionMapper.selectRunningByTenant(tenantId);
+
+        return Result.success(executions);
+    }
+
+    /**
+     * 统计执行次数
+     */
+    @GetMapping("/task/{taskId}/count")
+    public Result<Integer> countExecutions(@PathVariable Long taskId) {
+        log.info("统计任务执行次数,taskId={}", taskId);
+
+        int count = taskExecutionMapper.countByTaskId(taskId);
+
+        return Result.success(count);
+    }
+}

+ 276 - 0
src/main/java/cn/sciento/farm/automationv2/api/controller/IrrigationTaskController.java

@@ -0,0 +1,276 @@
+package cn.sciento.farm.automationv2.api.controller;
+
+import cn.sciento.farm.automationv2.api.dto.CreateTaskRequest;
+import cn.sciento.farm.automationv2.api.dto.PageRequest;
+import cn.sciento.farm.automationv2.api.dto.Result;
+import cn.sciento.farm.automationv2.app.service.QuartzManagementService;
+import cn.sciento.farm.automationv2.app.service.TaskTriggerService;
+import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
+import cn.sciento.farm.automationv2.domain.entity.TaskGroupConfig;
+import cn.sciento.farm.automationv2.domain.enums.*;
+import cn.sciento.farm.automationv2.infra.repository.IrrigationTaskMapper;
+import cn.sciento.farm.automationv2.infra.repository.TaskGroupConfigMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 灌溉任务管理API
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/tasks")
+@RequiredArgsConstructor
+public class IrrigationTaskController {
+
+    private final IrrigationTaskMapper irrigationTaskMapper;
+    private final TaskGroupConfigMapper taskGroupConfigMapper;
+    private final QuartzManagementService quartzManagementService;
+    private final TaskTriggerService taskTriggerService;
+
+    /**
+     * 创建任务
+     */
+    @PostMapping
+    public Result<Long> createTask(@Valid @RequestBody CreateTaskRequest request) {
+        log.info("创建灌溉任务,taskName={}", request.getTaskName());
+
+        try {
+            // 构建任务实体
+            IrrigationTask task = IrrigationTask.builder()
+                    .taskName(request.getTaskName())
+                    .taskCode(request.getTaskCode())
+                    .triggerType(TriggerType.valueOf(request.getTriggerType()))
+                    .scheduleType(request.getScheduleType() != null ?
+                            ScheduleType.valueOf(request.getScheduleType()) : null)
+                    .cronExpression(request.getCronExpression())
+                    .intervalDays(request.getIntervalDays())
+                    .totalTimes(request.getTotalTimes())
+                    .executedCount(0)
+                    .pumpId(request.getPumpId())
+                    .pressureMode(PressureMode.valueOf(request.getPressureMode()))
+                    .targetPressureKpa(request.getTargetPressureKpa())
+                    .switchStableSeconds(request.getSwitchStableSeconds() != null ? request.getSwitchStableSeconds() : 5)
+                    .fertilizerPumpId(request.getFertilizerPumpId())
+                    .stirMotorId(request.getStirMotorId())
+                    .fertilizerControlMode(request.getFertilizerControlMode() != null ?
+                            FertilizerControlMode.valueOf(request.getFertilizerControlMode()) : null)
+                    .fertDelayMinutes(request.getFertDelayMinutes())
+                    .preStirMinutes(request.getPreStirMinutes())
+                    .fertDurationMinutes(request.getFertDurationMinutes())
+                    .fertTargetLiters(request.getFertTargetLiters())
+                    .status(TaskStatus.ENABLED)
+                    .enabled(request.getEnabled() != null ? request.getEnabled() : true)
+                    .tenantId(request.getTenantId())
+                    .createdAt(LocalDateTime.now())
+                    .deleted(false)
+                    .build();
+
+            // 持久化任务
+            irrigationTaskMapper.insert(task);
+
+            // 保存灌区配置(任务与灌溉组的关联)
+            List<TaskGroupConfig> groupConfigs = new ArrayList<>();
+            for (CreateTaskRequest.GroupConfigDTO dto : request.getGroupConfigs()) {
+                TaskGroupConfig config = TaskGroupConfig.builder()
+                        .taskId(task.getId())
+                        .groupId(dto.getGroupId())
+                        .sortOrder(dto.getSortOrder())
+                        .irrigationDurationMinutes(dto.getIrrigationDurationMinutes())
+                        .build();
+                groupConfigs.add(config);
+            }
+            taskGroupConfigMapper.batchInsert(groupConfigs);
+
+            log.info("任务创建成功,taskId={}, groupCount={}", task.getId(), groupConfigs.size());
+
+            return Result.success(task.getId());
+
+        } catch (Exception e) {
+            log.error("创建任务失败,taskName={}", request.getTaskName(), e);
+            return Result.error("创建任务失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 查询任务详情
+     */
+    @GetMapping("/{id}")
+    public Result<IrrigationTask> getTask(@PathVariable Long id) {
+        log.info("查询任务详情,taskId={}", id);
+
+        IrrigationTask task = irrigationTaskMapper.selectById(id);
+        if (task == null) {
+            return Result.error("任务不存在");
+        }
+
+        return Result.success(task);
+    }
+
+    /**
+     * 分页查询任务列表
+     */
+    @GetMapping
+    public Result<List<IrrigationTask>> listTasks(PageRequest pageRequest) {
+        log.info("分页查询任务列表,pageNum={}, pageSize={}, tenantId={}",
+                pageRequest.getPageNum(), pageRequest.getPageSize(), pageRequest.getTenantId());
+
+        List<IrrigationTask> tasks = irrigationTaskMapper.selectByTenant(
+                pageRequest.getTenantId(),
+                pageRequest.getOffset(),
+                pageRequest.getLimit()
+        );
+
+        return Result.success(tasks);
+    }
+
+    /**
+     * 更新任务
+     */
+    @PutMapping("/{id}")
+    public Result<Void> updateTask(@PathVariable Long id, @RequestBody IrrigationTask task) {
+        log.info("更新任务,taskId={}", id);
+
+        task.setId(id);
+        task.setUpdatedAt(LocalDateTime.now());
+
+        int updated = irrigationTaskMapper.updateById(task);
+        if (updated == 0) {
+            return Result.error("任务不存在或更新失败");
+        }
+
+        return Result.success();
+    }
+
+    /**
+     * 删除任务
+     */
+    @DeleteMapping("/{id}")
+    public Result<Void> deleteTask(@PathVariable Long id) {
+        log.info("删除任务,taskId={}", id);
+
+        try {
+            // 先删除Quartz Job
+            quartzManagementService.deleteJob(id);
+
+            // 逻辑删除任务
+            int deleted = irrigationTaskMapper.deleteById(id);
+            if (deleted == 0) {
+                return Result.error("任务不存在或删除失败");
+            }
+
+            return Result.success();
+
+        } catch (Exception e) {
+            log.error("删除任务失败,taskId={}", id, e);
+            return Result.error("删除任务失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 启用任务(添加定时调度)
+     */
+    @PostMapping("/{id}/enable")
+    public Result<Void> enableTask(@PathVariable Long id) {
+        log.info("启用任务,taskId={}", id);
+
+        try {
+            IrrigationTask task = irrigationTaskMapper.selectById(id);
+            if (task == null) {
+                return Result.error("任务不存在");
+            }
+
+            if (!TriggerType.SCHEDULED.equals(task.getTriggerType())) {
+                return Result.error("只有定时触发类型的任务才需要启用调度");
+            }
+
+            // 根据调度类型添加Job
+            if (ScheduleType.CRON.equals(task.getScheduleType())) {
+                quartzManagementService.addCronJob(id, task.getCronExpression());
+            } else if (ScheduleType.SIMPLE.equals(task.getScheduleType())) {
+                quartzManagementService.addSimpleJob(id, task.getIntervalDays(), task.getTotalTimes());
+            } else {
+                return Result.error("未知的调度类型");
+            }
+
+            // 更新任务状态
+            task.setEnabled(true);
+            task.setStatus(TaskStatus.ENABLED);
+            task.setUpdatedAt(LocalDateTime.now());
+            irrigationTaskMapper.updateById(task);
+
+            return Result.success();
+
+        } catch (Exception e) {
+            log.error("启用任务失败,taskId={}", id, e);
+            return Result.error("启用任务失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 禁用任务(暂停定时调度)
+     */
+    @PostMapping("/{id}/disable")
+    public Result<Void> disableTask(@PathVariable Long id) {
+        log.info("禁用任务,taskId={}", id);
+
+        try {
+            // 暂停Quartz Job
+            quartzManagementService.pauseJob(id);
+
+            // 更新任务状态
+            IrrigationTask task = irrigationTaskMapper.selectById(id);
+            if (task != null) {
+                task.setEnabled(false);
+                task.setStatus(TaskStatus.DISABLED);
+                task.setUpdatedAt(LocalDateTime.now());
+                irrigationTaskMapper.updateById(task);
+            }
+
+            return Result.success();
+
+        } catch (Exception e) {
+            log.error("禁用任务失败,taskId={}", id, e);
+            return Result.error("禁用任务失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 手动触发任务
+     */
+    @PostMapping("/{id}/trigger")
+    public Result<Long> triggerTask(@PathVariable Long id) {
+        log.info("手动触发任务,taskId={}", id);
+
+        try {
+            Long executionId = taskTriggerService.manualTrigger(id);
+            return Result.success(executionId);
+
+        } catch (Exception e) {
+            log.error("手动触发任务失败,taskId={}", id, e);
+            return Result.error("触发任务失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 立即执行一次定时任务
+     */
+    @PostMapping("/{id}/execute-now")
+    public Result<Void> executeNow(@PathVariable Long id) {
+        log.info("立即执行任务,taskId={}", id);
+
+        try {
+            quartzManagementService.triggerJobNow(id);
+            return Result.success();
+
+        } catch (Exception e) {
+            log.error("立即执行任务失败,taskId={}", id, e);
+            return Result.error("执行失败: " + e.getMessage());
+        }
+    }
+}

+ 156 - 0
src/main/java/cn/sciento/farm/automationv2/api/dto/CreateTaskRequest.java

@@ -0,0 +1,156 @@
+package cn.sciento.farm.automationv2.api.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import java.util.List;
+
+/**
+ * 创建任务请求DTO
+ */
+@Data
+public class CreateTaskRequest {
+
+    /**
+     * 任务名称
+     */
+    @NotBlank(message = "任务名称不能为空")
+    private String taskName;
+
+    /**
+     * 任务编码
+     */
+    private String taskCode;
+
+    /**
+     * 触发类型:SCHEDULED / LINKAGE / MANUAL
+     */
+    @NotBlank(message = "触发类型不能为空")
+    private String triggerType;
+
+    /**
+     * 定时类型:CRON / SIMPLE
+     */
+    private String scheduleType;
+
+    /**
+     * Cron表达式(scheduleType=CRON时必填)
+     */
+    private String cronExpression;
+
+    /**
+     * 执行间隔天数(scheduleType=SIMPLE时必填)
+     */
+    private Integer intervalDays;
+
+    /**
+     * 执行总次数(scheduleType=SIMPLE时必填)
+     */
+    private Integer totalTimes;
+
+    // ========== 水泵配置 ==========
+
+    /**
+     * 水泵设备ID
+     */
+    @NotBlank(message = "水泵设备ID不能为空")
+    private String pumpId;
+
+    /**
+     * 水泵压力模式:NONE / PUMP_UNIFIED / PUMP_ZONE
+     */
+    @NotBlank(message = "水泵压力模式不能为空")
+    private String pressureMode;
+
+    /**
+     * 统一目标压力值(kPa,PUMP_UNIFIED模式必填)
+     */
+    private Integer targetPressureKpa;
+
+    /**
+     * 灌区切换稳压等待时间(秒),默认5秒
+     */
+    private Integer switchStableSeconds;
+
+    // ========== 施肥机配置(可选) ==========
+
+    /**
+     * 施肥泵设备ID
+     */
+    private String fertilizerPumpId;
+
+    /**
+     * 搅拌电机设备ID
+     */
+    private String stirMotorId;
+
+    /**
+     * 施肥控制模式:TIME / VOLUME
+     */
+    private String fertilizerControlMode;
+
+    /**
+     * 施肥延迟时间(分钟)
+     */
+    private Integer fertDelayMinutes;
+
+    /**
+     * 搅拌提前时长(分钟)
+     */
+    private Integer preStirMinutes;
+
+    /**
+     * 施肥时长(分钟,TIME模式必填)
+     */
+    private Integer fertDurationMinutes;
+
+    /**
+     * 施肥目标量(升,VOLUME模式必填)
+     */
+    private Integer fertTargetLiters;
+
+    // ========== 灌区配置 ==========
+
+    /**
+     * 灌区配置列表
+     */
+    @NotEmpty(message = "灌区配置列表不能为空")
+    private List<GroupConfigDTO> groupConfigs;
+
+    /**
+     * 是否启用
+     */
+    private Boolean enabled;
+
+    /**
+     * 租户ID
+     */
+    @NotNull(message = "租户ID不能为空")
+    private Long tenantId;
+
+    /**
+     * 灌区配置DTO
+     */
+    @Data
+    public static class GroupConfigDTO {
+        /**
+         * 灌溉组ID(从已有灌溉组中选择)
+         */
+        @NotNull(message = "灌溉组ID不能为空")
+        private Long groupId;
+
+        /**
+         * 执行顺序(0开始)
+         */
+        @NotNull(message = "执行顺序不能为空")
+        private Integer sortOrder;
+
+        /**
+         * 灌溉时长(分钟)
+         */
+        @NotNull(message = "灌溉时长不能为空")
+        private Integer irrigationDurationMinutes;
+    }
+}

+ 39 - 0
src/main/java/cn/sciento/farm/automationv2/api/dto/PageRequest.java

@@ -0,0 +1,39 @@
+package cn.sciento.farm.automationv2.api.dto;
+
+import lombok.Data;
+
+/**
+ * 分页查询请求DTO
+ */
+@Data
+public class PageRequest {
+
+    /**
+     * 页码(从1开始)
+     */
+    private Integer pageNum = 1;
+
+    /**
+     * 每页大小
+     */
+    private Integer pageSize = 20;
+
+    /**
+     * 租户ID
+     */
+    private Long tenantId;
+
+    /**
+     * 获取偏移量
+     */
+    public Integer getOffset() {
+        return (pageNum - 1) * pageSize;
+    }
+
+    /**
+     * 获取限制数量
+     */
+    public Integer getLimit() {
+        return pageSize;
+    }
+}

+ 45 - 0
src/main/java/cn/sciento/farm/automationv2/api/dto/Result.java

@@ -0,0 +1,45 @@
+package cn.sciento.farm.automationv2.api.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 统一响应结果类
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class Result<T> {
+
+    /**
+     * 响应码:0成功,其他失败
+     */
+    private Integer code;
+
+    /**
+     * 响应消息
+     */
+    private String message;
+
+    /**
+     * 响应数据
+     */
+    private T data;
+
+    public static <T> Result<T> success(T data) {
+        return new Result<>(0, "success", data);
+    }
+
+    public static <T> Result<T> success() {
+        return new Result<>(0, "success", null);
+    }
+
+    public static <T> Result<T> error(String message) {
+        return new Result<>(1, message, null);
+    }
+
+    public static <T> Result<T> error(Integer code, String message) {
+        return new Result<>(code, message, null);
+    }
+}

+ 75 - 0
src/main/java/cn/sciento/farm/automationv2/app/context/ExecutionContext.java

@@ -0,0 +1,75 @@
+package cn.sciento.farm.automationv2.app.context;
+
+import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
+import cn.sciento.farm.automationv2.domain.entity.TaskExecution;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionNode;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 执行上下文
+ * 用于在节点处理器之间传递执行信息
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ExecutionContext {
+
+    /**
+     * 任务执行实例
+     */
+    private TaskExecution execution;
+
+    /**
+     * 当前执行的节点
+     */
+    private ExecutionNode currentNode;
+
+    /**
+     * 轮灌任务配置(可选,用于获取任务配置信息)
+     */
+    private IrrigationTask task;
+
+    /**
+     * 租户ID
+     */
+    private Long tenantId;
+
+    /**
+     * 执行实例ID(快捷访问)
+     */
+    public Long getExecutionId() {
+        return execution != null ? execution.getId() : null;
+    }
+
+    /**
+     * 当前节点索引(快捷访问)
+     */
+    public Integer getCurrentIndex() {
+        return execution != null ? execution.getCurrentIndex() : null;
+    }
+
+    /**
+     * 获取水泵ID
+     */
+    public String getPumpId() {
+        return task != null ? task.getPumpId() : null;
+    }
+
+    /**
+     * 获取施肥泵ID
+     */
+    public String getFertilizerPumpId() {
+        return task != null ? task.getFertilizerPumpId() : null;
+    }
+
+    /**
+     * 获取搅拌电机ID
+     */
+    public String getStirMotorId() {
+        return task != null ? task.getStirMotorId() : null;
+    }
+}

+ 37 - 0
src/main/java/cn/sciento/farm/automationv2/app/handler/NodeHandler.java

@@ -0,0 +1,37 @@
+package cn.sciento.farm.automationv2.app.handler;
+
+import cn.sciento.farm.automationv2.app.context.ExecutionContext;
+import cn.sciento.farm.automationv2.domain.enums.NodeType;
+
+/**
+ * 节点处理器接口(责任链模式)
+ * 每种节点类型对应一个独立的Handler实现
+ */
+public interface NodeHandler {
+
+    /**
+     * 获取节点类型(用于路由匹配)
+     */
+    NodeType getType();
+
+    /**
+     * 执行节点动作
+     * 功能:下发设备指令、注册ACK期望
+     * 注意:立即返回,不等待设备响应
+     *
+     * @param context 执行上下文
+     */
+    void execute(ExecutionContext context);
+
+    /**
+     * 获取等待时长(秒)
+     * 用于确定延迟消息的延迟时间
+     * 默认返回0,WAIT节点需要覆盖此方法
+     *
+     * @param context 执行上下文
+     * @return 等待时长(秒)
+     */
+    default int getWaitSeconds(ExecutionContext context) {
+        return 0;
+    }
+}

+ 74 - 0
src/main/java/cn/sciento/farm/automationv2/app/handler/NodeHandlerFactory.java

@@ -0,0 +1,74 @@
+package cn.sciento.farm.automationv2.app.handler;
+
+import cn.sciento.farm.automationv2.domain.enums.NodeType;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * 节点处理器工厂
+ * 功能:根据节点类型获取对应的Handler
+ *
+ * 设计模式:策略模式 + 工厂模式
+ */
+@Slf4j
+@Component
+public class NodeHandlerFactory {
+
+    /**
+     * Handler映射表:NodeType -> NodeHandler
+     */
+    private final Map<NodeType, NodeHandler> handlerMap;
+
+    /**
+     * 构造函数:自动注入所有NodeHandler实现类
+     * Spring会自动将所有实现NodeHandler接口的Bean注入到这个List中
+     */
+    public NodeHandlerFactory(List<NodeHandler> handlers) {
+        // 将Handler列表转换为Map,key为节点类型,value为Handler实例
+        this.handlerMap = handlers.stream()
+                .collect(Collectors.toMap(
+                        NodeHandler::getType,
+                        Function.identity()
+                ));
+
+        log.info("NodeHandlerFactory初始化完成,已注册Handler数量:{}", handlerMap.size());
+        handlerMap.forEach((type, handler) ->
+                log.info("  - {} -> {}", type.getCode(), handler.getClass().getSimpleName()));
+    }
+
+    /**
+     * 根据节点类型获取Handler
+     *
+     * @param nodeType 节点类型
+     * @return NodeHandler实例
+     * @throws IllegalArgumentException 如果找不到对应的Handler
+     */
+    public NodeHandler getHandler(NodeType nodeType) {
+        NodeHandler handler = handlerMap.get(nodeType);
+
+        if (handler == null) {
+            throw new IllegalArgumentException("找不到对应的NodeHandler,nodeType=" + nodeType);
+        }
+
+        return handler;
+    }
+
+    /**
+     * 判断是否存在对应的Handler
+     */
+    public boolean hasHandler(NodeType nodeType) {
+        return handlerMap.containsKey(nodeType);
+    }
+
+    /**
+     * 获取所有已注册的节点类型
+     */
+    public java.util.Set<NodeType> getAllNodeTypes() {
+        return handlerMap.keySet();
+    }
+}

+ 66 - 0
src/main/java/cn/sciento/farm/automationv2/app/handler/impl/CloseGroupNodeHandler.java

@@ -0,0 +1,66 @@
+package cn.sciento.farm.automationv2.app.handler.impl;
+
+import cn.sciento.farm.automationv2.app.context.ExecutionContext;
+import cn.sciento.farm.automationv2.app.handler.NodeHandler;
+import cn.sciento.farm.automationv2.domain.enums.NodeType;
+import cn.sciento.farm.automationv2.domain.valueobject.DeviceInfo;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionNode;
+import cn.sciento.farm.automationv2.infra.mq.producer.DeviceCommandProducer;
+import cn.sciento.farm.automationv2.infra.redis.AckManager;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * 关闭灌区节点处理器
+ * 功能:关闭电磁阀 + 球阀归零
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CloseGroupNodeHandler implements NodeHandler {
+
+    private final DeviceCommandProducer deviceCommandProducer;
+    private final AckManager ackManager;
+
+    @Override
+    public NodeType getType() {
+        return NodeType.CLOSE_GROUP;
+    }
+
+    @Override
+    public void execute(ExecutionContext context) {
+        ExecutionNode node = context.getCurrentNode();
+        Long executionId = context.getExecutionId();
+        Integer nodeIndex = context.getCurrentIndex();
+        Long tenantId = context.getTenantId();
+
+        log.info("执行关闭灌区节点,executionId={}, nodeIndex={}, nodeName={}",
+                executionId, nodeIndex, node.getNodeName());
+
+        List<DeviceInfo> devices = node.getDevices();
+        if (devices == null || devices.isEmpty()) {
+            log.warn("灌区没有配置设备,executionId={}, nodeIndex={}", executionId, nodeIndex);
+            return;
+        }
+
+        // 下发指令给所有设备
+        for (DeviceInfo device : devices) {
+            if ("SOLENOID_VALVE".equals(device.getDeviceType())) {
+                // 关闭电磁阀
+                deviceCommandProducer.sendCloseSolenoidValveCommand(executionId, nodeIndex, device, tenantId);
+            } else if ("BALL_VALVE".equals(device.getDeviceType())) {
+                // 球阀归零(角度设为0)
+                deviceCommandProducer.sendBallValveAngleCommand(executionId, nodeIndex, device, 0, tenantId);
+            }
+        }
+
+        // 注册ACK期望
+        ackManager.registerAck(executionId, nodeIndex, devices);
+
+        log.info("关闭灌区指令已发送,executionId={}, nodeIndex={}, deviceCount={}",
+                executionId, nodeIndex, devices.size());
+    }
+}

+ 73 - 0
src/main/java/cn/sciento/farm/automationv2/app/handler/impl/OpenGroupNodeHandler.java

@@ -0,0 +1,73 @@
+package cn.sciento.farm.automationv2.app.handler.impl;
+
+import cn.sciento.farm.automationv2.app.context.ExecutionContext;
+import cn.sciento.farm.automationv2.app.handler.NodeHandler;
+import cn.sciento.farm.automationv2.domain.enums.NodeType;
+import cn.sciento.farm.automationv2.domain.valueobject.DeviceInfo;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionNode;
+import cn.sciento.farm.automationv2.infra.mq.producer.DeviceCommandProducer;
+import cn.sciento.farm.automationv2.infra.redis.AckManager;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * 开启灌区节点处理器
+ * 功能:开启电磁阀 + 球阀到目标角度
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OpenGroupNodeHandler implements NodeHandler {
+
+    private final DeviceCommandProducer deviceCommandProducer;
+    private final AckManager ackManager;
+
+    @Override
+    public NodeType getType() {
+        return NodeType.OPEN_GROUP;
+    }
+
+    @Override
+    public void execute(ExecutionContext context) {
+        ExecutionNode node = context.getCurrentNode();
+        Long executionId = context.getExecutionId();
+        Integer nodeIndex = context.getCurrentIndex();
+        Long tenantId = context.getTenantId();
+
+        log.info("执行开启灌区节点,executionId={}, nodeIndex={}, nodeName={}",
+                executionId, nodeIndex, node.getNodeName());
+
+        List<DeviceInfo> devices = node.getDevices();
+        if (devices == null || devices.isEmpty()) {
+            log.warn("灌区没有配置设备,executionId={}, nodeIndex={}", executionId, nodeIndex);
+            return;
+        }
+
+        // 获取球阀目标角度(如果有)
+        Integer targetAngle = node.getTargetAngle();
+
+        // 下发指令给所有设备
+        for (DeviceInfo device : devices) {
+            if ("SOLENOID_VALVE".equals(device.getDeviceType())) {
+                // 开启电磁阀
+                deviceCommandProducer.sendOpenSolenoidValveCommand(executionId, nodeIndex, device, tenantId);
+            } else if ("BALL_VALVE".equals(device.getDeviceType())) {
+                // 球阀到目标角度
+                if (targetAngle != null) {
+                    deviceCommandProducer.sendBallValveAngleCommand(executionId, nodeIndex, device, targetAngle, tenantId);
+                } else {
+                    log.warn("球阀未配置目标角度,deviceId={}", device.getDeviceId());
+                }
+            }
+        }
+
+        // 注册ACK期望
+        ackManager.registerAck(executionId, nodeIndex, devices);
+
+        log.info("开启灌区指令已发送,executionId={}, nodeIndex={}, deviceCount={}",
+                executionId, nodeIndex, devices.size());
+    }
+}

+ 57 - 0
src/main/java/cn/sciento/farm/automationv2/app/handler/impl/SetPumpPressureNodeHandler.java

@@ -0,0 +1,57 @@
+package cn.sciento.farm.automationv2.app.handler.impl;
+
+import cn.sciento.farm.automationv2.app.context.ExecutionContext;
+import cn.sciento.farm.automationv2.app.handler.NodeHandler;
+import cn.sciento.farm.automationv2.domain.enums.NodeType;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionNode;
+import cn.sciento.farm.automationv2.infra.mq.producer.DeviceCommandProducer;
+import cn.sciento.farm.automationv2.infra.redis.AckManager;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * 设置水泵压力节点处理器
+ * 功能:设置恒压水泵的目标压力值
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SetPumpPressureNodeHandler implements NodeHandler {
+
+    private final DeviceCommandProducer deviceCommandProducer;
+    private final AckManager ackManager;
+
+    @Override
+    public NodeType getType() {
+        return NodeType.SET_PUMP_PRESSURE;
+    }
+
+    @Override
+    public void execute(ExecutionContext context) {
+        ExecutionNode node = context.getCurrentNode();
+        Long executionId = context.getExecutionId();
+        Integer nodeIndex = context.getCurrentIndex();
+        Long tenantId = context.getTenantId();
+        String pumpId = context.getPumpId();
+
+        // 获取目标压力
+        Integer targetPressure = node.getTargetPressure();
+        if (targetPressure == null) {
+            log.error("目标压力值为空,executionId={}, nodeIndex={}", executionId, nodeIndex);
+            throw new IllegalArgumentException("目标压力值不能为空");
+        }
+
+        log.info("执行设置水泵压力节点,executionId={}, nodeIndex={}, pumpId={}, pressureKpa={}",
+                executionId, nodeIndex, pumpId, targetPressure);
+
+        // 下发设置压力指令
+        deviceCommandProducer.sendSetPumpPressureCommand(executionId, nodeIndex, pumpId, targetPressure, tenantId);
+
+        // 注册ACK期望
+        ackManager.registerAck(executionId, nodeIndex, node.getDevices());
+
+        log.info("设置水泵压力指令已发送,executionId={}, nodeIndex={}, pressureKpa={}",
+                executionId, nodeIndex, targetPressure);
+    }
+}

+ 64 - 0
src/main/java/cn/sciento/farm/automationv2/app/handler/impl/StartFertilizerNodeHandler.java

@@ -0,0 +1,64 @@
+package cn.sciento.farm.automationv2.app.handler.impl;
+
+import cn.sciento.farm.automationv2.app.context.ExecutionContext;
+import cn.sciento.farm.automationv2.app.handler.NodeHandler;
+import cn.sciento.farm.automationv2.domain.enums.NodeType;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionNode;
+import cn.sciento.farm.automationv2.infra.mq.producer.DeviceCommandProducer;
+import cn.sciento.farm.automationv2.infra.redis.AckManager;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * 启动施肥机节点处理器
+ * 功能:启动施肥机并下发程序编号
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class StartFertilizerNodeHandler implements NodeHandler {
+
+    private final DeviceCommandProducer deviceCommandProducer;
+    private final AckManager ackManager;
+
+    @Override
+    public NodeType getType() {
+        return NodeType.START_FERTILIZER;
+    }
+
+    @Override
+    public void execute(ExecutionContext context) {
+        ExecutionNode node = context.getCurrentNode();
+        Long executionId = context.getExecutionId();
+        Integer nodeIndex = context.getCurrentIndex();
+        Long tenantId = context.getTenantId();
+        String fertilizerId = context.getFertilizerPumpId();
+
+        // 获取施肥程序编号
+        Object programNoObj = node.getParams().get("programNo");
+        Integer programNo = null;
+        if (programNoObj instanceof Integer) {
+            programNo = (Integer) programNoObj;
+        } else if (programNoObj instanceof String) {
+            programNo = Integer.parseInt((String) programNoObj);
+        }
+
+        if (programNo == null) {
+            log.error("施肥程序编号为空,executionId={}, nodeIndex={}", executionId, nodeIndex);
+            throw new IllegalArgumentException("施肥程序编号不能为空");
+        }
+
+        log.info("执行启动施肥机节点,executionId={}, nodeIndex={}, fertilizerId={}, programNo={}",
+                executionId, nodeIndex, fertilizerId, programNo);
+
+        // 下发启动施肥机指令
+        deviceCommandProducer.sendStartFertilizerCommand(executionId, nodeIndex, fertilizerId, programNo, tenantId);
+
+        // 注册ACK期望
+        ackManager.registerAck(executionId, nodeIndex, node.getDevices());
+
+        log.info("启动施肥机指令已发送,executionId={}, nodeIndex={}, programNo={}",
+                executionId, nodeIndex, programNo);
+    }
+}

+ 49 - 0
src/main/java/cn/sciento/farm/automationv2/app/handler/impl/StartPumpNodeHandler.java

@@ -0,0 +1,49 @@
+package cn.sciento.farm.automationv2.app.handler.impl;
+
+import cn.sciento.farm.automationv2.app.context.ExecutionContext;
+import cn.sciento.farm.automationv2.app.handler.NodeHandler;
+import cn.sciento.farm.automationv2.domain.enums.NodeType;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionNode;
+import cn.sciento.farm.automationv2.infra.mq.producer.DeviceCommandProducer;
+import cn.sciento.farm.automationv2.infra.redis.AckManager;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * 启动水泵节点处理器
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class StartPumpNodeHandler implements NodeHandler {
+
+    private final DeviceCommandProducer deviceCommandProducer;
+    private final AckManager ackManager;
+
+    @Override
+    public NodeType getType() {
+        return NodeType.START_PUMP;
+    }
+
+    @Override
+    public void execute(ExecutionContext context) {
+        ExecutionNode node = context.getCurrentNode();
+        Long executionId = context.getExecutionId();
+        Integer nodeIndex = context.getCurrentIndex();
+        Long tenantId = context.getTenantId();
+        String pumpId = context.getPumpId();
+
+        log.info("执行启动水泵节点,executionId={}, nodeIndex={}, pumpId={}",
+                executionId, nodeIndex, pumpId);
+
+        // 下发启动水泵指令
+        deviceCommandProducer.sendStartPumpCommand(executionId, nodeIndex, pumpId, tenantId);
+
+        // 注册ACK期望
+        ackManager.registerAck(executionId, nodeIndex, node.getDevices());
+
+        log.info("启动水泵指令已发送,executionId={}, nodeIndex={}, pumpId={}",
+                executionId, nodeIndex, pumpId);
+    }
+}

+ 49 - 0
src/main/java/cn/sciento/farm/automationv2/app/handler/impl/StopFertilizerNodeHandler.java

@@ -0,0 +1,49 @@
+package cn.sciento.farm.automationv2.app.handler.impl;
+
+import cn.sciento.farm.automationv2.app.context.ExecutionContext;
+import cn.sciento.farm.automationv2.app.handler.NodeHandler;
+import cn.sciento.farm.automationv2.domain.enums.NodeType;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionNode;
+import cn.sciento.farm.automationv2.infra.mq.producer.DeviceCommandProducer;
+import cn.sciento.farm.automationv2.infra.redis.AckManager;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * 关闭施肥机节点处理器
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class StopFertilizerNodeHandler implements NodeHandler {
+
+    private final DeviceCommandProducer deviceCommandProducer;
+    private final AckManager ackManager;
+
+    @Override
+    public NodeType getType() {
+        return NodeType.STOP_FERTILIZER;
+    }
+
+    @Override
+    public void execute(ExecutionContext context) {
+        ExecutionNode node = context.getCurrentNode();
+        Long executionId = context.getExecutionId();
+        Integer nodeIndex = context.getCurrentIndex();
+        Long tenantId = context.getTenantId();
+        String fertilizerId = context.getFertilizerPumpId();
+
+        log.info("执行关闭施肥机节点,executionId={}, nodeIndex={}, fertilizerId={}",
+                executionId, nodeIndex, fertilizerId);
+
+        // 下发关闭施肥机指令
+        deviceCommandProducer.sendStopFertilizerCommand(executionId, nodeIndex, fertilizerId, tenantId);
+
+        // 注册ACK期望
+        ackManager.registerAck(executionId, nodeIndex, node.getDevices());
+
+        log.info("关闭施肥机指令已发送,executionId={}, nodeIndex={}, fertilizerId={}",
+                executionId, nodeIndex, fertilizerId);
+    }
+}

+ 49 - 0
src/main/java/cn/sciento/farm/automationv2/app/handler/impl/StopPumpNodeHandler.java

@@ -0,0 +1,49 @@
+package cn.sciento.farm.automationv2.app.handler.impl;
+
+import cn.sciento.farm.automationv2.app.context.ExecutionContext;
+import cn.sciento.farm.automationv2.app.handler.NodeHandler;
+import cn.sciento.farm.automationv2.domain.enums.NodeType;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionNode;
+import cn.sciento.farm.automationv2.infra.mq.producer.DeviceCommandProducer;
+import cn.sciento.farm.automationv2.infra.redis.AckManager;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * 关闭水泵节点处理器
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class StopPumpNodeHandler implements NodeHandler {
+
+    private final DeviceCommandProducer deviceCommandProducer;
+    private final AckManager ackManager;
+
+    @Override
+    public NodeType getType() {
+        return NodeType.STOP_PUMP;
+    }
+
+    @Override
+    public void execute(ExecutionContext context) {
+        ExecutionNode node = context.getCurrentNode();
+        Long executionId = context.getExecutionId();
+        Integer nodeIndex = context.getCurrentIndex();
+        Long tenantId = context.getTenantId();
+        String pumpId = context.getPumpId();
+
+        log.info("执行关闭水泵节点,executionId={}, nodeIndex={}, pumpId={}",
+                executionId, nodeIndex, pumpId);
+
+        // 下发关闭水泵指令
+        deviceCommandProducer.sendStopPumpCommand(executionId, nodeIndex, pumpId, tenantId);
+
+        // 注册ACK期望
+        ackManager.registerAck(executionId, nodeIndex, node.getDevices());
+
+        log.info("关闭水泵指令已发送,executionId={}, nodeIndex={}, pumpId={}",
+                executionId, nodeIndex, pumpId);
+    }
+}

+ 44 - 0
src/main/java/cn/sciento/farm/automationv2/app/handler/impl/WaitNodeHandler.java

@@ -0,0 +1,44 @@
+package cn.sciento.farm.automationv2.app.handler.impl;
+
+import cn.sciento.farm.automationv2.app.context.ExecutionContext;
+import cn.sciento.farm.automationv2.app.handler.NodeHandler;
+import cn.sciento.farm.automationv2.domain.enums.NodeType;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionNode;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * 等待节点处理器
+ * 功能:等待指定时长(灌溉时长)
+ * 注意:WAIT节点不下发任何设备指令,仅发送延迟MQ消息
+ */
+@Slf4j
+@Component
+public class WaitNodeHandler implements NodeHandler {
+
+    @Override
+    public NodeType getType() {
+        return NodeType.WAIT;
+    }
+
+    @Override
+    public void execute(ExecutionContext context) {
+        ExecutionNode node = context.getCurrentNode();
+        Long executionId = context.getExecutionId();
+        Integer nodeIndex = context.getCurrentIndex();
+
+        int waitSeconds = node.getWaitSeconds();
+
+        log.info("执行等待节点,executionId={}, nodeIndex={}, waitSeconds={}({}分钟)",
+                executionId, nodeIndex, waitSeconds, waitSeconds / 60);
+
+        // WAIT节点不需要下发设备指令
+        // 只需要在执行引擎中发送延迟MQ消息(等待时长后推进到下一节点)
+    }
+
+    @Override
+    public int getWaitSeconds(ExecutionContext context) {
+        ExecutionNode node = context.getCurrentNode();
+        return node.getWaitSeconds();
+    }
+}

+ 62 - 0
src/main/java/cn/sciento/farm/automationv2/app/job/IrrigationScheduledJob.java

@@ -0,0 +1,62 @@
+package cn.sciento.farm.automationv2.app.job;
+
+import cn.sciento.farm.automationv2.app.service.TaskTriggerService;
+import lombok.extern.slf4j.Slf4j;
+import org.quartz.JobDataMap;
+import org.quartz.JobExecutionContext;
+import org.quartz.JobExecutionException;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.quartz.QuartzJobBean;
+import org.springframework.stereotype.Component;
+
+/**
+ * 灌溉定时任务Job
+ * 功能:定时触发灌溉任务执行
+ *
+ * 说明:
+ * - Job只负责触发,不执行具体业务
+ * - 发送MQ消息后立即返回
+ * - 任务ID通过JobDataMap传递
+ */
+@Slf4j
+@Component
+public class IrrigationScheduledJob extends QuartzJobBean {
+
+    @Autowired
+    private TaskTriggerService taskTriggerService;
+
+    /**
+     * JobDataMap参数key
+     */
+    public static final String PARAM_TASK_ID = "taskId";
+
+    @Override
+    protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
+        // 获取任务ID
+        JobDataMap dataMap = context.getMergedJobDataMap();
+        Long taskId = dataMap.getLong(PARAM_TASK_ID);
+
+        if (taskId == null) {
+            log.error("任务ID为空,jobKey={}", context.getJobDetail().getKey());
+            return;
+        }
+
+        log.info("定时任务触发,taskId={}, jobKey={}, fireTime={}",
+                taskId, context.getJobDetail().getKey(), context.getFireTime());
+
+        try {
+            // 触发任务(发送MQ消息后立即返回)
+            Long executionId = taskTriggerService.scheduledTrigger(taskId);
+
+            log.info("定时任务触发成功,taskId={}, executionId={}, nextFireTime={}",
+                    taskId, executionId, context.getNextFireTime());
+
+        } catch (Exception e) {
+            log.error("定时任务触发失败,taskId={}, jobKey={}",
+                    taskId, context.getJobDetail().getKey(), e);
+
+            // 抛出异常,Quartz会记录失败信息
+            throw new JobExecutionException("定时任务触发失败", e);
+        }
+    }
+}

+ 76 - 0
src/main/java/cn/sciento/farm/automationv2/app/listener/SensorDataListener.java

@@ -0,0 +1,76 @@
+package cn.sciento.farm.automationv2.app.listener;
+
+import cn.sciento.farm.automationv2.app.service.LinkageRuleEngine;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
+import org.apache.rocketmq.spring.core.RocketMQListener;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * 传感器数据监听器
+ * 功能:监听传感器数据Topic,调用联动规则引擎评估是否触发任务
+ *
+ * 数据格式:
+ * {
+ *   "sensorDeviceId": "sensor_001",
+ *   "value": 25.5,
+ *   "timestamp": 1709012345678,
+ *   "tenantId": 1
+ * }
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+@RocketMQMessageListener(
+        topic = "SENSOR_DATA",
+        consumerGroup = "irrigation-sensor-data-consumer"
+)
+public class SensorDataListener implements RocketMQListener<Map<String, Object>> {
+
+    private final LinkageRuleEngine linkageRuleEngine;
+
+    @Override
+    public void onMessage(Map<String, Object> message) {
+        try {
+            // 解析传感器设备ID
+            String sensorDeviceId = (String) message.get("sensorDeviceId");
+            if (sensorDeviceId == null || sensorDeviceId.isEmpty()) {
+                log.warn("传感器设备ID为空,忽略消息,message={}", message);
+                return;
+            }
+
+            // 解析数值
+            Object valueObj = message.get("value");
+            Double value = null;
+
+            if (valueObj instanceof Number) {
+                value = ((Number) valueObj).doubleValue();
+            } else if (valueObj instanceof String) {
+                try {
+                    value = Double.parseDouble((String) valueObj);
+                } catch (NumberFormatException e) {
+                    log.warn("传感器数值格式错误,sensorDeviceId={}, value={}",
+                            sensorDeviceId, valueObj);
+                    return;
+                }
+            }
+
+            if (value == null) {
+                log.warn("传感器数值为空,sensorDeviceId={}", sensorDeviceId);
+                return;
+            }
+
+            log.debug("收到传感器数据,sensorDeviceId={}, value={}", sensorDeviceId, value);
+
+            // 调用联动规则引擎评估
+            linkageRuleEngine.evaluate(sensorDeviceId, value);
+
+        } catch (Exception e) {
+            log.error("处理传感器数据异常,message={}", message, e);
+            // 不抛出异常,避免消息重试
+        }
+    }
+}

+ 171 - 0
src/main/java/cn/sciento/farm/automationv2/app/service/AlarmNotificationService.java

@@ -0,0 +1,171 @@
+package cn.sciento.farm.automationv2.app.service;
+
+import cn.sciento.farm.automationv2.domain.entity.AlarmRecord;
+import cn.sciento.farm.automationv2.domain.enums.AlarmType;
+import cn.sciento.farm.automationv2.infra.feign.SmsServiceClient;
+import cn.sciento.farm.automationv2.infra.repository.AlarmRecordMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.time.format.DateTimeFormatter;
+import java.util.Map;
+
+/**
+ * 报警通知服务
+ * 功能:通过短信方式发送报警通知
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AlarmNotificationService {
+
+    private final AlarmRecordMapper alarmRecordMapper;
+    private final SmsServiceClient smsServiceClient;
+
+    /**
+     * 报警通知接收手机号(从配置文件读取,多个号码用逗号分隔)
+     */
+    @Value("${alarm.notification.phone-numbers:}")
+    private String alarmPhoneNumbers;
+
+    /**
+     * 是否启用短信通知
+     */
+    @Value("${alarm.notification.sms.enabled:true}")
+    private boolean smsEnabled;
+
+    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    /**
+     * 发送报警通知
+     *
+     * @param executionId 执行实例ID
+     */
+    public void sendAlarm(Long executionId) {
+        log.info("开始发送报警通知,executionId={}", executionId);
+
+        // 加载报警记录
+        AlarmRecord alarm = alarmRecordMapper.selectByExecutionId(executionId);
+        if (alarm == null) {
+            log.warn("报警记录不存在,executionId={}", executionId);
+            return;
+        }
+
+        // 检查是否启用短信通知
+        if (!smsEnabled) {
+            log.info("短信通知未启用,跳过发送,executionId={}", executionId);
+            return;
+        }
+
+        // 检查接收手机号
+        if (alarmPhoneNumbers == null || alarmPhoneNumbers.trim().isEmpty()) {
+            log.warn("未配置报警接收手机号,跳过发送,executionId={}", executionId);
+            return;
+        }
+
+        try {
+            // 构建短信内容
+            String smsContent = buildAlarmMessage(alarm);
+
+            // 调用短信服务
+            SmsServiceClient.SmsRequest request = new SmsServiceClient.SmsRequest(
+                    alarm.getTenantId(),
+                    alarmPhoneNumbers,
+                    smsContent
+            );
+
+            Map<String, Object> result = smsServiceClient.sendSms(request);
+
+            log.info("报警短信发送成功,executionId={}, result={}", executionId, result);
+
+            // 更新报警记录通知状态
+            alarm.markNotifySuccess("SMS");
+            alarmRecordMapper.updateById(alarm);
+
+        } catch (Exception e) {
+            log.error("发送报警短信失败,executionId={}", executionId, e);
+            // 不抛出异常,避免影响主流程
+        }
+    }
+
+    /**
+     * 构建报警短信内容
+     */
+    private String buildAlarmMessage(AlarmRecord alarm) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("【灌溉系统报警】\n");
+        sb.append("任务:").append(alarm.getTaskName()).append("\n");
+        sb.append("类型:").append(getAlarmTypeDesc(alarm.getAlarmType())).append("\n");
+        sb.append("时间:").append(alarm.getCreatedAt().format(TIME_FORMATTER)).append("\n");
+        sb.append("原因:").append(alarm.getFailReason()).append("\n");
+
+        // 失败设备
+        if (alarm.getFailedDevices() != null && !alarm.getFailedDevices().isEmpty()) {
+            sb.append("失败设备:").append(alarm.getFailedDevices()).append("\n");
+        }
+
+        // 安全关闭状态
+        if (alarm.getSafeCloseStatus() != null) {
+            sb.append("安全关闭:").append(alarm.getSafeCloseStatus()).append("\n");
+        }
+
+        sb.append("请及时处理!");
+
+        return sb.toString();
+    }
+
+    /**
+     * 获取报警类型描述
+     */
+    private String getAlarmTypeDesc(AlarmType alarmType) {
+        if (alarmType == null) {
+            return "未知报警";
+        }
+
+        switch (alarmType) {
+            case TASK_FAILURE:
+                return "任务执行失败";
+            case DEVICE_TIMEOUT:
+                return "设备响应超时";
+            case DEVICE_FAILURE:
+                return "设备执行失败";
+            case SAFE_CLOSE_FAILURE:
+                return "安全关闭失败";
+            default:
+                return alarmType.name();
+        }
+    }
+
+    /**
+     * 发送测试报警(用于测试短信通道)
+     */
+    public void sendTestAlarm(String phoneNumber) {
+        log.info("发送测试报警短信,phoneNumber={}", phoneNumber);
+
+        if (!smsEnabled) {
+            log.warn("短信通知未启用,无法发送测试短信");
+            throw new IllegalStateException("短信通知未启用");
+        }
+
+        try {
+            String testContent = String.format("【灌溉系统】\n这是一条测试报警短信。\n发送时间:%s",
+                    java.time.LocalDateTime.now().format(TIME_FORMATTER));
+
+            SmsServiceClient.SmsRequest request = new SmsServiceClient.SmsRequest(
+                    null, // 测试短信不需要tenantId
+                    phoneNumber,
+                    testContent
+            );
+
+            Map<String, Object> result = smsServiceClient.sendSms(request);
+
+            log.info("测试报警短信发送成功,phoneNumber={}, result={}", phoneNumber, result);
+
+        } catch (Exception e) {
+            log.error("发送测试报警短信失败,phoneNumber={}", phoneNumber, e);
+            throw new RuntimeException("发送测试短信失败", e);
+        }
+    }
+}

+ 199 - 0
src/main/java/cn/sciento/farm/automationv2/app/service/AlarmRecordService.java

@@ -0,0 +1,199 @@
+package cn.sciento.farm.automationv2.app.service;
+
+import cn.sciento.farm.automationv2.domain.entity.AlarmRecord;
+import cn.sciento.farm.automationv2.domain.entity.TaskExecution;
+import cn.sciento.farm.automationv2.domain.enums.AlarmType;
+import cn.sciento.farm.automationv2.domain.valueobject.DeviceInfo;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionNode;
+import cn.sciento.farm.automationv2.infra.repository.AlarmRecordMapper;
+import cn.sciento.farm.automationv2.infra.repository.TaskExecutionMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 报警记录服务
+ * 功能:创建和管理报警记录
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AlarmRecordService {
+
+    private final AlarmRecordMapper alarmRecordMapper;
+    private final TaskExecutionMapper taskExecutionMapper;
+
+    /**
+     * 创建任务执行失败报警记录
+     *
+     * @param executionId 执行实例ID
+     * @param failReason  失败原因
+     * @return 报警记录ID
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Long createTaskFailureAlarm(Long executionId, String failReason) {
+        log.info("创建任务失败报警记录,executionId={}, failReason={}", executionId, failReason);
+
+        // 加载执行实例
+        TaskExecution execution = taskExecutionMapper.selectById(executionId);
+        if (execution == null) {
+            log.error("执行实例不存在,executionId={}", executionId);
+            return null;
+        }
+
+        // 提取失败设备信息
+        List<String> failedDevices = extractFailedDevices(execution);
+        List<String> successDevices = extractSuccessDevices(execution);
+
+        // 构建报警记录
+        AlarmRecord alarm = AlarmRecord.builder()
+                .executionId(executionId)
+                .taskId(execution.getTaskId())
+                .taskName(execution.getTaskName())
+                .alarmType(AlarmType.TASK_FAILURE)
+                .alarmLevel("HIGH")
+                .failReason(failReason)
+                .failedDevices(String.join(",", failedDevices))
+                .successDevices(String.join(",", successDevices))
+                .safeCloseStatus(execution.getSafeCloseStatus())
+                .safeCloseDetails(execution.getSafeCloseDetails())
+                .tenantId(execution.getTenantId())
+                .createdAt(LocalDateTime.now())
+                .handled(false)
+                .build();
+
+        // 持久化报警记录
+        alarmRecordMapper.insert(alarm);
+
+        log.info("报警记录创建成功,alarmId={}, executionId={}, failedDeviceCount={}",
+                alarm.getId(), executionId, failedDevices.size());
+
+        return alarm.getId();
+    }
+
+    /**
+     * 创建设备ACK超时报警
+     *
+     * @param executionId 执行实例ID
+     * @param nodeIndex   节点索引
+     * @param deviceIds   超时设备ID列表
+     * @return 报警记录ID
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Long createDeviceTimeoutAlarm(Long executionId, Integer nodeIndex, List<String> deviceIds) {
+        log.info("创建设备超时报警记录,executionId={}, nodeIndex={}, deviceCount={}",
+                executionId, nodeIndex, deviceIds.size());
+
+        TaskExecution execution = taskExecutionMapper.selectById(executionId);
+        if (execution == null) {
+            return null;
+        }
+
+        String failReason = String.format("节点%d设备ACK超时(30秒)", nodeIndex);
+
+        AlarmRecord alarm = AlarmRecord.builder()
+                .executionId(executionId)
+                .taskId(execution.getTaskId())
+                .taskName(execution.getTaskName())
+                .alarmType(AlarmType.DEVICE_TIMEOUT)
+                .alarmLevel("MEDIUM")
+                .failReason(failReason)
+                .failedDevices(String.join(",", deviceIds))
+                .tenantId(execution.getTenantId())
+                .createdAt(LocalDateTime.now())
+                .handled(false)
+                .build();
+
+        alarmRecordMapper.insert(alarm);
+
+        log.info("设备超时报警记录创建成功,alarmId={}, executionId={}",
+                alarm.getId(), executionId);
+
+        return alarm.getId();
+    }
+
+    /**
+     * 标记报警已处理
+     *
+     * @param alarmId   报警ID
+     * @param handledBy 处理人ID
+     * @param remark    处理备注
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void markAsHandled(Long alarmId, Long handledBy, String remark) {
+        AlarmRecord alarm = alarmRecordMapper.selectById(alarmId);
+        if (alarm == null) {
+            log.warn("报警记录不存在,alarmId={}", alarmId);
+            return;
+        }
+
+        alarm.setHandled(true);
+        alarm.setHandledBy(handledBy);
+        alarm.setHandledAt(LocalDateTime.now());
+        alarm.setHandleRemark(remark);
+
+        alarmRecordMapper.updateById(alarm);
+
+        log.info("报警记录已标记为处理,alarmId={}, handledBy={}", alarmId, handledBy);
+    }
+
+    /**
+     * 从执行计划中提取失败设备
+     */
+    private List<String> extractFailedDevices(TaskExecution execution) {
+        if (execution.getExecutionPlan() == null) {
+            return Collections.emptyList();
+        }
+
+        return execution.getExecutionPlan().getNodes().stream()
+                .flatMap(node -> node.getDevices().stream())
+                .filter(device -> "FAIL".equals(device.getAckStatus()) ||
+                        "TIMEOUT".equals(device.getAckStatus()))
+                .map(DeviceInfo::getDeviceId)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 从执行计划中提取成功设备
+     */
+    private List<String> extractSuccessDevices(TaskExecution execution) {
+        if (execution.getExecutionPlan() == null) {
+            return Collections.emptyList();
+        }
+
+        return execution.getExecutionPlan().getNodes().stream()
+                .flatMap(node -> node.getDevices().stream())
+                .filter(device -> "SUCCESS".equals(device.getAckStatus()))
+                .map(DeviceInfo::getDeviceId)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 查询报警记录
+     */
+    public AlarmRecord getAlarmById(Long alarmId) {
+        return alarmRecordMapper.selectById(alarmId);
+    }
+
+    /**
+     * 分页查询未处理报警
+     */
+    public List<AlarmRecord> listUnhandledAlarms(Long tenantId, Integer limit) {
+        return alarmRecordMapper.selectUnhandledAlarms(tenantId, limit);
+    }
+
+    /**
+     * 分页查询所有报警
+     */
+    public List<AlarmRecord> listAlarms(Long tenantId, Integer offset, Integer limit) {
+        return alarmRecordMapper.selectByTenant(tenantId, offset, limit);
+    }
+}

+ 173 - 0
src/main/java/cn/sciento/farm/automationv2/app/service/LinkageRuleEngine.java

@@ -0,0 +1,173 @@
+package cn.sciento.farm.automationv2.app.service;
+
+import cn.sciento.farm.automationv2.domain.entity.LinkageRule;
+import cn.sciento.farm.automationv2.infra.repository.LinkageRuleMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 联动规则引擎
+ * 功能:根据传感器数据评估联动规则,自动触发任务
+ *
+ * 冷却机制:
+ * - 使用Redis SET NX实现冷却时间控制
+ * - 同一规则在冷却期内不重复触发
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class LinkageRuleEngine {
+
+    private final LinkageRuleMapper linkageRuleMapper;
+    private final TaskTriggerService taskTriggerService;
+    private final StringRedisTemplate stringRedisTemplate;
+
+    /**
+     * Redis Key前缀
+     */
+    private static final String COOLDOWN_KEY_PREFIX = "linkage:cooldown:";
+
+    /**
+     * 评估传感器数据是否触发联动规则
+     *
+     * @param sensorDeviceId 传感器设备ID
+     * @param value          传感器数值
+     */
+    public void evaluate(String sensorDeviceId, Double value) {
+        log.debug("评估联动规则,sensorDeviceId={}, value={}", sensorDeviceId, value);
+
+        // 查询该传感器的所有启用规则
+        List<LinkageRule> rules = linkageRuleMapper.selectEnabledBySensorId(sensorDeviceId);
+
+        if (rules == null || rules.isEmpty()) {
+            log.debug("传感器无启用的联动规则,sensorDeviceId={}", sensorDeviceId);
+            return;
+        }
+
+        log.info("找到{}条启用的联动规则,sensorDeviceId={}", rules.size(), sensorDeviceId);
+
+        // 逐条评估规则
+        for (LinkageRule rule : rules) {
+            try {
+                evaluateRule(rule, value);
+            } catch (Exception e) {
+                log.error("评估联动规则异常,ruleId={}, sensorDeviceId={}",
+                        rule.getId(), sensorDeviceId, e);
+            }
+        }
+    }
+
+    /**
+     * 评估单条规则
+     */
+    private void evaluateRule(LinkageRule rule, Double value) {
+        Long ruleId = rule.getId();
+        String operator = rule.getOperator();
+        java.math.BigDecimal threshold = rule.getThreshold();
+        Long taskId = rule.getTaskId();
+        Integer cooldownMinutes = rule.getCooldownMinutes();
+
+        log.debug("评估规则,ruleId={}, operator={}, threshold={}, value={}",
+                ruleId, operator, threshold, value);
+
+        // 判断条件是否满足
+        boolean conditionMet = matchCondition(operator, value, threshold);
+
+        if (!conditionMet) {
+            log.debug("规则条件不满足,ruleId={}, {} {} {} = false",
+                    ruleId, value, operator, threshold);
+            return;
+        }
+
+        log.info("规则条件满足,ruleId={}, {} {} {} = true",
+                ruleId, value, operator, threshold);
+
+        // 检查冷却时间
+        String cooldownKey = COOLDOWN_KEY_PREFIX + ruleId;
+        Boolean success = stringRedisTemplate.opsForValue()
+                .setIfAbsent(cooldownKey, "1", cooldownMinutes, TimeUnit.MINUTES);
+
+        if (Boolean.FALSE.equals(success)) {
+            log.info("规则在冷却期内,忽略触发,ruleId={}, cooldownMinutes={}",
+                    ruleId, cooldownMinutes);
+            return;
+        }
+
+        log.info("规则触发任务,ruleId={}, taskId={}, sensorDeviceId={}, value={}",
+                ruleId, taskId, rule.getSensorDeviceId(), value);
+
+        try {
+            // 触发任务
+            Long executionId = taskTriggerService.linkageTrigger(taskId);
+
+            log.info("联动触发任务成功,ruleId={}, taskId={}, executionId={}",
+                    ruleId, taskId, executionId);
+
+        } catch (Exception e) {
+            log.error("联动触发任务失败,ruleId={}, taskId={}",
+                    ruleId, taskId, e);
+
+            // 触发失败,清除冷却标记(允许重试)
+            stringRedisTemplate.delete(cooldownKey);
+        }
+    }
+
+    /**
+     * 匹配条件
+     *
+     * @param operator  操作符:> / >= / < / <= / = / !=
+     * @param value     实际值
+     * @param threshold 阈值
+     * @return true-条件满足,false-条件不满足
+     */
+    private boolean matchCondition(String operator, Double value, java.math.BigDecimal threshold) {
+        if (value == null || threshold == null) {
+            return false;
+        }
+
+        double thresholdValue = threshold.doubleValue();
+
+        switch (operator) {
+            case ">":
+                return value > thresholdValue;
+            case ">=":
+                return value >= thresholdValue;
+            case "<":
+                return value < thresholdValue;
+            case "<=":
+                return value <= thresholdValue;
+            case "=":
+            case "==":
+                return Math.abs(value - thresholdValue) < 0.0001; // 浮点数相等判断
+            case "!=":
+                return Math.abs(value - thresholdValue) >= 0.0001;
+            default:
+                log.warn("未知的操作符,operator={}", operator);
+                return false;
+        }
+    }
+
+    /**
+     * 手动清除规则冷却标记(用于测试或管理)
+     */
+    public void clearCooldown(Long ruleId) {
+        String cooldownKey = COOLDOWN_KEY_PREFIX + ruleId;
+        Boolean deleted = stringRedisTemplate.delete(cooldownKey);
+
+        log.info("清除规则冷却标记,ruleId={}, deleted={}", ruleId, deleted);
+    }
+
+    /**
+     * 检查规则是否在冷却期
+     */
+    public boolean isInCooldown(Long ruleId) {
+        String cooldownKey = COOLDOWN_KEY_PREFIX + ruleId;
+        Boolean exists = stringRedisTemplate.hasKey(cooldownKey);
+        return Boolean.TRUE.equals(exists);
+    }
+}

+ 247 - 0
src/main/java/cn/sciento/farm/automationv2/app/service/QuartzManagementService.java

@@ -0,0 +1,247 @@
+package cn.sciento.farm.automationv2.app.service;
+
+import cn.sciento.farm.automationv2.app.job.IrrigationScheduledJob;
+import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
+import cn.sciento.farm.automationv2.infra.repository.IrrigationTaskMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.quartz.*;
+import org.springframework.stereotype.Service;
+
+/**
+ * Quartz调度管理服务
+ * 功能:管理定时任务的添加、暂停、恢复、删除
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class QuartzManagementService {
+
+    private final Scheduler scheduler;
+    private final IrrigationTaskMapper irrigationTaskMapper;
+
+    /**
+     * Job和Trigger的命名规则
+     */
+    private static final String JOB_PREFIX = "irrigation_job_";
+    private static final String TRIGGER_PREFIX = "irrigation_trigger_";
+    private static final String GROUP_NAME = "irrigation_group";
+
+    /**
+     * 为任务添加Cron定时调度
+     *
+     * @param taskId         任务ID
+     * @param cronExpression Cron表达式
+     */
+    public void addCronJob(Long taskId, String cronExpression) {
+        try {
+            // 加载任务信息
+            IrrigationTask task = irrigationTaskMapper.selectById(taskId);
+            if (task == null) {
+                throw new IllegalArgumentException("任务不存在,taskId=" + taskId);
+            }
+
+            // 构建JobKey和TriggerKey
+            JobKey jobKey = JobKey.jobKey(JOB_PREFIX + taskId, GROUP_NAME);
+            TriggerKey triggerKey = TriggerKey.triggerKey(TRIGGER_PREFIX + taskId, GROUP_NAME);
+
+            // 检查是否已存在
+            if (scheduler.checkExists(jobKey)) {
+                log.warn("定时任务已存在,将先删除后重建,taskId={}", taskId);
+                deleteJob(taskId);
+            }
+
+            // 构建JobDetail
+            JobDetail jobDetail = JobBuilder.newJob(IrrigationScheduledJob.class)
+                    .withIdentity(jobKey)
+                    .withDescription("灌溉任务: " + task.getTaskName())
+                    .usingJobData(IrrigationScheduledJob.PARAM_TASK_ID, taskId)
+                    .storeDurably()
+                    .build();
+
+            // 构建CronTrigger
+            CronTrigger trigger = TriggerBuilder.newTrigger()
+                    .withIdentity(triggerKey)
+                    .withDescription("灌溉任务触发器: " + task.getTaskName())
+                    .withSchedule(CronScheduleBuilder.cronSchedule(cronExpression)
+                            .withMisfireHandlingInstructionFireAndProceed())
+                    .build();
+
+            // 调度Job
+            scheduler.scheduleJob(jobDetail, trigger);
+
+            log.info("添加Cron定时任务成功,taskId={}, cronExpression={}, nextFireTime={}",
+                    taskId, cronExpression, trigger.getNextFireTime());
+
+        } catch (SchedulerException e) {
+            log.error("添加Cron定时任务失败,taskId={}, cronExpression={}",
+                    taskId, cronExpression, e);
+            throw new RuntimeException("添加Cron定时任务失败", e);
+        }
+    }
+
+    /**
+     * 为任务添加简单周期调度
+     *
+     * @param taskId       任务ID
+     * @param intervalDays 间隔天数
+     * @param totalTimes   总执行次数(0表示无限)
+     */
+    public void addSimpleJob(Long taskId, int intervalDays, int totalTimes) {
+        try {
+            // 加载任务信息
+            IrrigationTask task = irrigationTaskMapper.selectById(taskId);
+            if (task == null) {
+                throw new IllegalArgumentException("任务不存在,taskId=" + taskId);
+            }
+
+            // 构建JobKey和TriggerKey
+            JobKey jobKey = JobKey.jobKey(JOB_PREFIX + taskId, GROUP_NAME);
+            TriggerKey triggerKey = TriggerKey.triggerKey(TRIGGER_PREFIX + taskId, GROUP_NAME);
+
+            // 检查是否已存在
+            if (scheduler.checkExists(jobKey)) {
+                log.warn("定时任务已存在,将先删除后重建,taskId={}", taskId);
+                deleteJob(taskId);
+            }
+
+            // 构建JobDetail
+            JobDetail jobDetail = JobBuilder.newJob(IrrigationScheduledJob.class)
+                    .withIdentity(jobKey)
+                    .withDescription("灌溉任务: " + task.getTaskName())
+                    .usingJobData(IrrigationScheduledJob.PARAM_TASK_ID, taskId)
+                    .storeDurably()
+                    .build();
+
+            // 构建SimpleTrigger
+            SimpleScheduleBuilder scheduleBuilder = SimpleScheduleBuilder.simpleSchedule()
+                    .withIntervalInHours(intervalDays * 24)
+                    .withMisfireHandlingInstructionFireNow();
+
+            if (totalTimes > 0) {
+                scheduleBuilder.withRepeatCount(totalTimes - 1); // repeatCount = totalTimes - 1
+            } else {
+                scheduleBuilder.repeatForever();
+            }
+
+            SimpleTrigger trigger = TriggerBuilder.newTrigger()
+                    .withIdentity(triggerKey)
+                    .withDescription("灌溉任务触发器: " + task.getTaskName())
+                    .startNow()
+                    .withSchedule(scheduleBuilder)
+                    .build();
+
+            // 调度Job
+            scheduler.scheduleJob(jobDetail, trigger);
+
+            log.info("添加简单周期任务成功,taskId={}, intervalDays={}, totalTimes={}, nextFireTime={}",
+                    taskId, intervalDays, totalTimes, trigger.getNextFireTime());
+
+        } catch (SchedulerException e) {
+            log.error("添加简单周期任务失败,taskId={}, intervalDays={}, totalTimes={}",
+                    taskId, intervalDays, totalTimes, e);
+            throw new RuntimeException("添加简单周期任务失败", e);
+        }
+    }
+
+    /**
+     * 暂停定时任务
+     */
+    public void pauseJob(Long taskId) {
+        try {
+            JobKey jobKey = JobKey.jobKey(JOB_PREFIX + taskId, GROUP_NAME);
+
+            if (!scheduler.checkExists(jobKey)) {
+                log.warn("定时任务不存在,taskId={}", taskId);
+                return;
+            }
+
+            scheduler.pauseJob(jobKey);
+
+            log.info("暂停定时任务成功,taskId={}", taskId);
+
+        } catch (SchedulerException e) {
+            log.error("暂停定时任务失败,taskId={}", taskId, e);
+            throw new RuntimeException("暂停定时任务失败", e);
+        }
+    }
+
+    /**
+     * 恢复定时任务
+     */
+    public void resumeJob(Long taskId) {
+        try {
+            JobKey jobKey = JobKey.jobKey(JOB_PREFIX + taskId, GROUP_NAME);
+
+            if (!scheduler.checkExists(jobKey)) {
+                log.warn("定时任务不存在,taskId={}", taskId);
+                return;
+            }
+
+            scheduler.resumeJob(jobKey);
+
+            log.info("恢复定时任务成功,taskId={}", taskId);
+
+        } catch (SchedulerException e) {
+            log.error("恢复定时任务失败,taskId={}", taskId, e);
+            throw new RuntimeException("恢复定时任务失败", e);
+        }
+    }
+
+    /**
+     * 删除定时任务
+     */
+    public void deleteJob(Long taskId) {
+        try {
+            JobKey jobKey = JobKey.jobKey(JOB_PREFIX + taskId, GROUP_NAME);
+
+            if (!scheduler.checkExists(jobKey)) {
+                log.warn("定时任务不存在,taskId={}", taskId);
+                return;
+            }
+
+            scheduler.deleteJob(jobKey);
+
+            log.info("删除定时任务成功,taskId={}", taskId);
+
+        } catch (SchedulerException e) {
+            log.error("删除定时任务失败,taskId={}", taskId, e);
+            throw new RuntimeException("删除定时任务失败", e);
+        }
+    }
+
+    /**
+     * 立即执行一次任务(不影响原调度计划)
+     */
+    public void triggerJobNow(Long taskId) {
+        try {
+            JobKey jobKey = JobKey.jobKey(JOB_PREFIX + taskId, GROUP_NAME);
+
+            if (!scheduler.checkExists(jobKey)) {
+                log.warn("定时任务不存在,taskId={}", taskId);
+                return;
+            }
+
+            scheduler.triggerJob(jobKey);
+
+            log.info("立即执行任务成功,taskId={}", taskId);
+
+        } catch (SchedulerException e) {
+            log.error("立即执行任务失败,taskId={}", taskId, e);
+            throw new RuntimeException("立即执行任务失败", e);
+        }
+    }
+
+    /**
+     * 检查定时任务是否存在
+     */
+    public boolean jobExists(Long taskId) {
+        try {
+            JobKey jobKey = JobKey.jobKey(JOB_PREFIX + taskId, GROUP_NAME);
+            return scheduler.checkExists(jobKey);
+        } catch (SchedulerException e) {
+            log.error("检查定时任务失败,taskId={}", taskId, e);
+            return false;
+        }
+    }
+}

+ 137 - 0
src/main/java/cn/sciento/farm/automationv2/app/service/RetryManager.java

@@ -0,0 +1,137 @@
+package cn.sciento.farm.automationv2.app.service;
+
+import cn.sciento.farm.automationv2.domain.entity.TaskExecution;
+import cn.sciento.farm.automationv2.domain.enums.NodeStatus;
+import cn.sciento.farm.automationv2.domain.valueobject.DeviceInfo;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionNode;
+import cn.sciento.farm.automationv2.infra.mq.producer.FlowControlProducer;
+import cn.sciento.farm.automationv2.infra.repository.TaskExecutionMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 重试管理器
+ * 功能:管理设备指令重试逻辑
+ *
+ * 重试策略:
+ * - 最多重试3次
+ * - 延迟:0秒(第1次)/ 5秒(第2次)/ 15秒(第3次)
+ * - 只对失败的设备重发指令
+ * - 超过3次后触发节点失败
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class RetryManager {
+
+    private final TaskExecutionMapper taskExecutionMapper;
+    private final FlowControlProducer flowControlProducer;
+
+    /**
+     * 最大重试次数
+     */
+    private static final int MAX_RETRY_COUNT = 3;
+
+    /**
+     * 重试延迟(秒):第1次0秒,第2次5秒,第3次15秒
+     */
+    private static final int[] RETRY_DELAYS = {0, 5, 15};
+
+    /**
+     * 触发重试
+     *
+     * @param executionId   执行实例ID
+     * @param nodeIndex     节点索引
+     * @param failedDevices 失败的设备列表
+     * @return true-可以继续重试,false-已达重试上限
+     */
+    public boolean retry(Long executionId, Integer nodeIndex, List<String> failedDevices) {
+        // 加载执行实例
+        TaskExecution execution = taskExecutionMapper.selectById(executionId);
+        if (execution == null) {
+            log.error("执行实例不存在,executionId={}", executionId);
+            return false;
+        }
+
+        // 获取当前节点
+        ExecutionNode node = execution.getExecutionPlan().getNode(nodeIndex);
+        if (node == null) {
+            log.error("节点不存在,executionId={}, nodeIndex={}", executionId, nodeIndex);
+            return false;
+        }
+
+        int currentRetryCount = node.getRetryCount();
+
+        // 检查是否已达重试上限
+        if (currentRetryCount >= MAX_RETRY_COUNT) {
+            log.warn("节点已达最大重试次数,executionId={}, nodeIndex={}, retryCount={}",
+                    executionId, nodeIndex, currentRetryCount);
+            return false;
+        }
+
+        // 更新重试次数
+        node.setRetryCount(currentRetryCount + 1);
+
+        // 更新失败设备的重试次数
+        for (DeviceInfo device : node.getDevices()) {
+            if (failedDevices.contains(device.getDeviceId())) {
+                device.setRetryCount(device.getRetryCount() + 1);
+            }
+        }
+
+        // 持久化更新
+        int updated = taskExecutionMapper.updateByVersion(execution);
+        if (updated == 0) {
+            log.error("更新执行实例失败(乐观锁冲突),executionId={}, version={}",
+                    executionId, execution.getVersion());
+            return false;
+        }
+
+        log.info("触发设备重试,executionId={}, nodeIndex={}, retryCount={}/{}, failedDeviceCount={}",
+                executionId, nodeIndex, node.getRetryCount(), MAX_RETRY_COUNT, failedDevices.size());
+
+        // 计算延迟时间
+        int delaySeconds = getRetryDelay(node.getRetryCount());
+
+        // 发送CHECK_ACK延迟消息
+        flowControlProducer.sendCheckAck(executionId, nodeIndex, node.getRetryCount(), delaySeconds);
+
+        log.info("已发送重试CHECK_ACK消息,executionId={}, nodeIndex={}, delaySeconds={}",
+                executionId, nodeIndex, delaySeconds);
+
+        return true;
+    }
+
+    /**
+     * 检查节点是否已达重试上限
+     */
+    public boolean hasReachedMaxRetry(Long executionId, Integer nodeIndex) {
+        TaskExecution execution = taskExecutionMapper.selectById(executionId);
+        if (execution == null) {
+            return true;
+        }
+
+        ExecutionNode node = execution.getExecutionPlan().getNode(nodeIndex);
+        if (node == null) {
+            return true;
+        }
+
+        return node.getRetryCount() >= MAX_RETRY_COUNT;
+    }
+
+    /**
+     * 获取重试延迟时间(秒)
+     *
+     * @param retryCount 当前重试次数(1/2/3)
+     * @return 延迟秒数(0/5/15)
+     */
+    private int getRetryDelay(int retryCount) {
+        if (retryCount < 1 || retryCount > MAX_RETRY_COUNT) {
+            return 0;
+        }
+        return RETRY_DELAYS[retryCount - 1];
+    }
+}

+ 297 - 0
src/main/java/cn/sciento/farm/automationv2/app/service/SafeShutdownService.java

@@ -0,0 +1,297 @@
+package cn.sciento.farm.automationv2.app.service;
+
+import cn.sciento.farm.automationv2.domain.entity.TaskExecution;
+import cn.sciento.farm.automationv2.domain.enums.AckStatus;
+import cn.sciento.farm.automationv2.domain.valueobject.DeviceInfo;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionNode;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionPlan;
+import cn.sciento.farm.automationv2.infra.mq.producer.DeviceCommandProducer;
+import cn.sciento.farm.automationv2.infra.redis.AckManager;
+import cn.sciento.farm.automationv2.infra.repository.TaskExecutionMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 安全关闭服务
+ * 功能:在任务失败或取消时,按照安全顺序关闭已开启的设备
+ *
+ * 关闭顺序(S-03安全规则):
+ * 1. 关闭施肥机
+ * 2. 关闭水泵
+ * 3. 关闭所有电磁阀和球阀
+ *
+ * 超时策略:
+ * - 每个设备等待ACK最多30秒
+ * - 设备关闭失败不中断流程,继续关闭其他设备
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SafeShutdownService {
+
+    private final TaskExecutionMapper taskExecutionMapper;
+    private final DeviceCommandProducer deviceCommandProducer;
+    private final AckManager ackManager;
+
+    /**
+     * ACK超时时间(毫秒)
+     */
+    private static final long ACK_TIMEOUT_MS = 30000; // 30秒
+
+    /**
+     * ACK检查间隔(毫秒)
+     */
+    private static final long ACK_CHECK_INTERVAL_MS = 1000; // 1秒
+
+    /**
+     * 安全关闭所有已开启的设备
+     *
+     * @param executionId 执行实例ID
+     * @return 关闭结果摘要
+     */
+    public ShutdownResult shutdown(Long executionId) {
+        log.info("开始安全关闭设备,executionId={}", executionId);
+
+        // 加载执行实例
+        TaskExecution execution = taskExecutionMapper.selectById(executionId);
+        if (execution == null) {
+            log.error("执行实例不存在,executionId={}", executionId);
+            return ShutdownResult.error("执行实例不存在");
+        }
+
+        // 提取已开启的设备
+        OpenedDevices openedDevices = extractOpenedDevices(execution.getExecutionPlan());
+
+        List<String> failedDevices = new ArrayList<>();
+        List<String> successDevices = new ArrayList<>();
+
+        // 步骤1: 关闭施肥机
+        if (openedDevices.fertilizer != null) {
+            log.info("步骤1: 关闭施肥机,deviceId={}", openedDevices.fertilizer.getDeviceId());
+            boolean success = closeDevice(executionId, openedDevices.fertilizer, execution.getTenantId());
+            if (success) {
+                successDevices.add(openedDevices.fertilizer.getDeviceId());
+            } else {
+                failedDevices.add(openedDevices.fertilizer.getDeviceId());
+            }
+        }
+
+        // 步骤2: 关闭水泵
+        if (openedDevices.pump != null) {
+            log.info("步骤2: 关闭水泵,deviceId={}", openedDevices.pump.getDeviceId());
+            boolean success = closeDevice(executionId, openedDevices.pump, execution.getTenantId());
+            if (success) {
+                successDevices.add(openedDevices.pump.getDeviceId());
+            } else {
+                failedDevices.add(openedDevices.pump.getDeviceId());
+            }
+        }
+
+        // 步骤3: 关闭所有电磁阀和球阀
+        for (DeviceInfo valve : openedDevices.valves) {
+            log.info("步骤3: 关闭阀门,deviceId={}, deviceType={}",
+                    valve.getDeviceId(), valve.getDeviceType());
+            boolean success = closeDevice(executionId, valve, execution.getTenantId());
+            if (success) {
+                successDevices.add(valve.getDeviceId());
+            } else {
+                failedDevices.add(valve.getDeviceId());
+            }
+        }
+
+        // 构建结果
+        ShutdownResult result;
+        if (failedDevices.isEmpty()) {
+            result = ShutdownResult.success(successDevices);
+            log.info("安全关闭完成,全部设备关闭成功,executionId={}, successCount={}",
+                    executionId, successDevices.size());
+        } else {
+            result = ShutdownResult.partial(successDevices, failedDevices);
+            log.warn("安全关闭完成,部分设备关闭失败,executionId={}, successCount={}, failedCount={}",
+                    executionId, successDevices.size(), failedDevices.size());
+        }
+
+        return result;
+    }
+
+    /**
+     * 关闭单个设备
+     *
+     * @param executionId 执行实例ID
+     * @param device      设备信息
+     * @param tenantId    租户ID
+     * @return true-成功,false-失败
+     */
+    private boolean closeDevice(Long executionId, DeviceInfo device, Long tenantId) {
+        String deviceId = device.getDeviceId();
+        String deviceType = device.getDeviceType();
+
+        try {
+            // 下发关闭指令
+            if ("PUMP".equals(deviceType)) {
+                deviceCommandProducer.sendStopPumpCommand(executionId, -1, deviceId, tenantId);
+            } else if ("FERTILIZER".equals(deviceType)) {
+                deviceCommandProducer.sendStopFertilizerCommand(executionId, -1, deviceId, tenantId);
+            } else if ("SOLENOID_VALVE".equals(deviceType)) {
+                deviceCommandProducer.sendCloseSolenoidValveCommand(executionId, -1, device, tenantId);
+            } else if ("BALL_VALVE".equals(deviceType)) {
+                // 球阀归零(角度=0)
+                deviceCommandProducer.sendBallValveAngleCommand(executionId, -1, device, 0, tenantId);
+            } else {
+                log.warn("未知设备类型,deviceId={}, deviceType={}", deviceId, deviceType);
+                return false;
+            }
+
+            // 注册ACK期望
+            ackManager.registerAck(executionId, -1, Collections.singletonList(device));
+
+            // 等待ACK响应(最多30秒)
+            long startTime = System.currentTimeMillis();
+            while (System.currentTimeMillis() - startTime < ACK_TIMEOUT_MS) {
+                Map<String, AckStatus> ackMap = ackManager.checkAckStatus(executionId, -1,
+                        Collections.singletonList(device));
+
+                AckStatus status = ackMap.get(deviceId);
+                if (status == AckStatus.SUCCESS) {
+                    log.info("设备关闭成功,deviceId={}", deviceId);
+                    return true;
+                } else if (status == AckStatus.FAIL || status == AckStatus.TIMEOUT) {
+                    log.error("设备关闭失败,deviceId={}, status={}", deviceId, status);
+                    return false;
+                }
+
+                // 继续等待
+                Thread.sleep(ACK_CHECK_INTERVAL_MS);
+            }
+
+            // 超时
+            log.error("设备关闭超时,deviceId={}", deviceId);
+            return false;
+
+        } catch (Exception e) {
+            log.error("关闭设备异常,deviceId={}", deviceId, e);
+            return false;
+        }
+    }
+
+    /**
+     * 从执行计划中提取已开启的设备
+     */
+    private OpenedDevices extractOpenedDevices(ExecutionPlan plan) {
+        OpenedDevices result = new OpenedDevices();
+
+        for (ExecutionNode node : plan.getSuccessNodes()) {
+            switch (node.getNodeType()) {
+                case START_PUMP:
+                    // 找到水泵设备
+                    result.pump = node.getDevices().stream()
+                            .filter(d -> "PUMP".equals(d.getDeviceType()))
+                            .findFirst()
+                            .orElse(null);
+                    break;
+
+                case START_FERTILIZER:
+                    // 找到施肥机设备
+                    result.fertilizer = node.getDevices().stream()
+                            .filter(d -> "FERTILIZER".equals(d.getDeviceType()))
+                            .findFirst()
+                            .orElse(null);
+                    break;
+
+                case OPEN_GROUP:
+                    // 收集电磁阀和球阀
+                    List<DeviceInfo> valves = node.getDevices().stream()
+                            .filter(d -> "SOLENOID_VALVE".equals(d.getDeviceType()) ||
+                                    "BALL_VALVE".equals(d.getDeviceType()))
+                            .collect(Collectors.toList());
+                    result.valves.addAll(valves);
+                    break;
+
+                default:
+                    // 其他节点忽略
+                    break;
+            }
+        }
+
+        // 去重阀门(如果同一阀门在多个OPEN_GROUP中出现)
+        result.valves = result.valves.stream()
+                .collect(Collectors.toMap(DeviceInfo::getDeviceId, d -> d, (d1, d2) -> d1))
+                .values()
+                .stream()
+                .collect(Collectors.toList());
+
+        log.info("提取已开启设备,pump={}, fertilizer={}, valves={}",
+                result.pump != null ? result.pump.getDeviceId() : "null",
+                result.fertilizer != null ? result.fertilizer.getDeviceId() : "null",
+                result.valves.size());
+
+        return result;
+    }
+
+    /**
+     * 已开启设备容器
+     */
+    private static class OpenedDevices {
+        DeviceInfo pump;
+        DeviceInfo fertilizer;
+        List<DeviceInfo> valves = new ArrayList<>();
+    }
+
+    /**
+     * 关闭结果
+     */
+    public static class ShutdownResult {
+        private final boolean success;
+        private final List<String> successDevices;
+        private final List<String> failedDevices;
+        private final String errorMessage;
+
+        private ShutdownResult(boolean success, List<String> successDevices,
+                               List<String> failedDevices, String errorMessage) {
+            this.success = success;
+            this.successDevices = successDevices;
+            this.failedDevices = failedDevices;
+            this.errorMessage = errorMessage;
+        }
+
+        public static ShutdownResult success(List<String> successDevices) {
+            return new ShutdownResult(true, successDevices, Collections.emptyList(), null);
+        }
+
+        public static ShutdownResult partial(List<String> successDevices, List<String> failedDevices) {
+            return new ShutdownResult(false, successDevices, failedDevices, "部分设备关闭失败");
+        }
+
+        public static ShutdownResult error(String errorMessage) {
+            return new ShutdownResult(false, Collections.emptyList(),
+                    Collections.emptyList(), errorMessage);
+        }
+
+        public boolean isSuccess() {
+            return success;
+        }
+
+        public List<String> getSuccessDevices() {
+            return successDevices;
+        }
+
+        public List<String> getFailedDevices() {
+            return failedDevices;
+        }
+
+        public String getErrorMessage() {
+            return errorMessage;
+        }
+
+        public String getSummary() {
+            if (errorMessage != null) {
+                return errorMessage;
+            }
+            return String.format("成功%d个,失败%d个", successDevices.size(), failedDevices.size());
+        }
+    }
+}

+ 426 - 0
src/main/java/cn/sciento/farm/automationv2/app/service/TaskExecutionEngine.java

@@ -0,0 +1,426 @@
+package cn.sciento.farm.automationv2.app.service;
+
+import cn.sciento.farm.automationv2.app.context.ExecutionContext;
+import cn.sciento.farm.automationv2.app.handler.NodeHandler;
+import cn.sciento.farm.automationv2.app.handler.NodeHandlerFactory;
+import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
+import cn.sciento.farm.automationv2.domain.entity.TaskExecution;
+import cn.sciento.farm.automationv2.domain.enums.AckStatus;
+import cn.sciento.farm.automationv2.domain.enums.ExecutionStatus;
+import cn.sciento.farm.automationv2.domain.enums.NodeStatus;
+import cn.sciento.farm.automationv2.domain.enums.NodeType;
+import cn.sciento.farm.automationv2.domain.valueobject.DeviceInfo;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionNode;
+import cn.sciento.farm.automationv2.infra.mq.producer.FlowControlProducer;
+import cn.sciento.farm.automationv2.infra.redis.AckManager;
+import cn.sciento.farm.automationv2.infra.redis.IdempotencyManager;
+import cn.sciento.farm.automationv2.infra.repository.IrrigationTaskMapper;
+import cn.sciento.farm.automationv2.infra.repository.TaskExecutionMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 任务执行引擎(核心组件)
+ * 功能:驱动整个任务执行流程
+ *
+ * 核心方法:
+ * 1. executeCurrentNode() - 执行当前节点
+ * 2. checkAckAndProceed() - 检查ACK并推进
+ * 3. proceedToNextNode() - 推进到下一节点
+ * 4. handleNodeFailure() - 处理节点失败
+ * 5. handleAckTimeout() - 处理ACK超时
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class TaskExecutionEngine {
+
+    private final TaskExecutionMapper taskExecutionMapper;
+    private final IrrigationTaskMapper irrigationTaskMapper;
+    private final NodeHandlerFactory nodeHandlerFactory;
+    private final FlowControlProducer flowControlProducer;
+    private final AckManager ackManager;
+    private final IdempotencyManager idempotencyManager;
+    private final RetryManager retryManager;
+    private final SafeShutdownService safeShutdownService;
+    private final AlarmRecordService alarmRecordService;
+    private final AlarmNotificationService alarmNotificationService;
+
+    /**
+     * 执行当前节点
+     *
+     * @param executionId 执行实例ID
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void executeCurrentNode(Long executionId) {
+        log.info("开始执行当前节点,executionId={}", executionId);
+
+        // 加载执行实例
+        TaskExecution execution = taskExecutionMapper.selectById(executionId);
+        if (execution == null) {
+            log.error("执行实例不存在,executionId={}", executionId);
+            return;
+        }
+
+        // 检查执行状态
+        if (execution.isTerminal()) {
+            log.warn("执行已终止,忽略执行请求,executionId={}, status={}",
+                    executionId, execution.getStatus());
+            return;
+        }
+
+        // 获取当前节点
+        ExecutionNode currentNode = execution.getCurrentNode();
+        if (currentNode == null) {
+            log.error("当前节点不存在,executionId={}, currentIndex={}",
+                    executionId, execution.getCurrentIndex());
+            handleNodeFailure(executionId, execution.getCurrentIndex(), "当前节点不存在");
+            return;
+        }
+
+        Integer nodeIndex = currentNode.getIndex();
+        NodeType nodeType = currentNode.getNodeType();
+
+        log.info("执行节点,executionId={}, nodeIndex={}, nodeType={}, nodeName={}",
+                executionId, nodeIndex, nodeType, currentNode.getNodeName());
+
+        try {
+            // 更新节点状态为RUNNING
+            currentNode.setStatus(NodeStatus.RUNNING.name());
+            currentNode.setStartedAt(LocalDateTime.now());
+            execution.setStatus(ExecutionStatus.RUNNING);
+            execution.updateHeartbeat();
+
+            int updated = taskExecutionMapper.updateByVersion(execution);
+            if (updated == 0) {
+                log.error("更新执行实例失败(乐观锁冲突),executionId={}, version={}",
+                        executionId, execution.getVersion());
+                return;
+            }
+
+            // 构建执行上下文
+            IrrigationTask task = irrigationTaskMapper.selectById(execution.getTaskId());
+            ExecutionContext context = ExecutionContext.builder()
+                    .execution(execution)
+                    .currentNode(currentNode)
+                    .task(task)
+                    .tenantId(execution.getTenantId())
+                    .build();
+
+            // 获取节点处理器
+            NodeHandler handler = nodeHandlerFactory.getHandler(nodeType);
+
+            // 执行节点
+            handler.execute(context);
+
+            // 判断是否需要等待ACK
+            if (nodeType.requiresAck()) {
+                // 发送CHECK_ACK延迟消息(初始延迟10秒)
+                flowControlProducer.sendCheckAck(executionId, nodeIndex, 0, 10);
+
+                // 发送ACK_TIMEOUT兜底消息(30秒)
+                flowControlProducer.sendAckTimeout(executionId, nodeIndex);
+
+                log.info("节点执行完成,等待设备ACK,executionId={}, nodeIndex={}",
+                        executionId, nodeIndex);
+            } else {
+                // WAIT节点:不需要ACK,直接推进
+                int waitSeconds = handler.getWaitSeconds(context);
+                int waitMinutes = waitSeconds / 60;
+
+                // 更新节点状态为SUCCESS
+                currentNode.setStatus(NodeStatus.SUCCESS.name());
+                currentNode.setFinishedAt(LocalDateTime.now());
+                taskExecutionMapper.updateByVersion(execution);
+
+                // 标记推进(幂等)
+                idempotencyManager.markProceed(executionId, nodeIndex);
+
+                // 发送NEXT_NODE延迟消息
+                flowControlProducer.sendNextNode(executionId, nodeIndex, waitMinutes);
+
+                log.info("WAIT节点执行完成,等待{}分钟后推进,executionId={}, nodeIndex={}",
+                        waitMinutes, executionId, nodeIndex);
+            }
+
+        } catch (Exception e) {
+            log.error("执行节点异常,executionId={}, nodeIndex={}",
+                    executionId, nodeIndex, e);
+            handleNodeFailure(executionId, nodeIndex, "执行节点异常: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 检查ACK状态并决定下一步动作
+     *
+     * @param executionId 执行实例ID
+     * @param nodeIndex   节点索引
+     */
+    public void checkAckAndProceed(Long executionId, Integer nodeIndex) {
+        log.info("检查ACK状态,executionId={}, nodeIndex={}", executionId, nodeIndex);
+
+        // 加载执行实例
+        TaskExecution execution = taskExecutionMapper.selectById(executionId);
+        if (execution == null) {
+            log.error("执行实例不存在,executionId={}", executionId);
+            return;
+        }
+
+        // 检查是否已推进(幂等)
+        if (idempotencyManager.isProceed(executionId, nodeIndex)) {
+            log.info("节点已推进,忽略CHECK_ACK,executionId={}, nodeIndex={}",
+                    executionId, nodeIndex);
+            return;
+        }
+
+        // 获取节点
+        ExecutionNode node = execution.getExecutionPlan().getNode(nodeIndex);
+        if (node == null) {
+            log.error("节点不存在,executionId={}, nodeIndex={}", executionId, nodeIndex);
+            return;
+        }
+
+        // 检查ACK状态
+        Map<String, AckStatus> ackMap = ackManager.checkAckStatus(executionId, nodeIndex, node.getDevices());
+
+        // 统计ACK状态
+        long successCount = ackMap.values().stream().filter(AckStatus::isSuccess).count();
+        long failureCount = ackMap.values().stream().filter(AckStatus::isFailure).count();
+        long pendingCount = ackMap.values().stream().filter(AckStatus::isPending).count();
+
+        log.info("ACK状态统计,executionId={}, nodeIndex={}, success={}, failure={}, pending={}",
+                executionId, nodeIndex, successCount, failureCount, pendingCount);
+
+        if (ackManager.allSuccess(ackMap)) {
+            // 全部成功:推进到下一节点
+            log.info("设备ACK全部成功,准备推进,executionId={}, nodeIndex={}",
+                    executionId, nodeIndex);
+
+            // 标记推进(幂等)
+            boolean canProceed = idempotencyManager.markProceed(executionId, nodeIndex);
+            if (!canProceed) {
+                log.warn("节点已被推进,忽略重复推进,executionId={}, nodeIndex={}",
+                        executionId, nodeIndex);
+                return;
+            }
+
+            // 更新节点状态为SUCCESS
+            node.setStatus(NodeStatus.SUCCESS.name());
+            node.setFinishedAt(LocalDateTime.now());
+            execution.updateHeartbeat();
+            taskExecutionMapper.updateByVersion(execution);
+
+            // 发送NEXT_NODE消息(立即,延迟=0)
+            flowControlProducer.sendNextNode(executionId, nodeIndex, 0);
+
+            log.info("节点执行成功,已发送NEXT_NODE消息,executionId={}, nodeIndex={}",
+                    executionId, nodeIndex);
+
+        } else if (ackManager.hasFailure(ackMap)) {
+            // 有失败设备:触发重试
+            List<String> failedDevices = ackManager.getFailedDevices(ackMap);
+
+            log.warn("检测到设备失败,executionId={}, nodeIndex={}, failedDevices={}",
+                    executionId, nodeIndex, failedDevices);
+
+            // 尝试重试
+            boolean canRetry = retryManager.retry(executionId, nodeIndex, failedDevices);
+
+            if (!canRetry) {
+                // 已达重试上限,触发节点失败
+                String failReason = String.format("设备失败且已重试%d次: %s",
+                        node.getRetryCount(), String.join(", ", failedDevices));
+                handleNodeFailure(executionId, nodeIndex, failReason);
+            } else {
+                log.info("已触发重试,executionId={}, nodeIndex={}, retryCount={}",
+                        executionId, nodeIndex, node.getRetryCount());
+            }
+
+        } else {
+            // 全部PENDING:继续等待
+            log.info("设备ACK仍在等待,继续CHECK_ACK,executionId={}, nodeIndex={}, pendingCount={}",
+                    executionId, nodeIndex, pendingCount);
+
+            // 继续发送CHECK_ACK消息(延迟5秒)
+            flowControlProducer.sendCheckAck(executionId, nodeIndex, node.getRetryCount(), 5);
+        }
+    }
+
+    /**
+     * 推进到下一节点
+     *
+     * @param executionId 执行实例ID
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void proceedToNextNode(Long executionId) {
+        log.info("推进到下一节点,executionId={}", executionId);
+
+        // 加载执行实例
+        TaskExecution execution = taskExecutionMapper.selectById(executionId);
+        if (execution == null) {
+            log.error("执行实例不存在,executionId={}", executionId);
+            return;
+        }
+
+        // 检查是否有下一节点
+        if (!execution.hasNextNode()) {
+            // 全部节点执行完成
+            log.info("所有节点执行完成,任务成功,executionId={}", executionId);
+
+            execution.markAsSuccess();
+            execution.setFinishedAt(LocalDateTime.now());
+            taskExecutionMapper.updateByVersion(execution);
+
+            return;
+        }
+
+        // 推进到下一节点
+        int nextIndex = execution.getCurrentIndex() + 1;
+        log.info("推进索引,executionId={}, currentIndex={} -> nextIndex={}",
+                executionId, execution.getCurrentIndex(), nextIndex);
+
+        // 乐观锁更新
+        execution.setCurrentIndex(nextIndex);
+        execution.updateHeartbeat();
+        int updated = taskExecutionMapper.updateByVersion(execution);
+
+        if (updated == 0) {
+            log.error("推进节点失败(乐观锁冲突),executionId={}, version={}",
+                    executionId, execution.getVersion());
+            return;
+        }
+
+        // 执行下一节点
+        executeCurrentNode(executionId);
+    }
+
+    /**
+     * 处理节点失败
+     *
+     * @param executionId 执行实例ID
+     * @param nodeIndex   节点索引
+     * @param failReason  失败原因
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void handleNodeFailure(Long executionId, Integer nodeIndex, String failReason) {
+        log.error("节点执行失败,executionId={}, nodeIndex={}, failReason={}",
+                executionId, nodeIndex, failReason);
+
+        // 加载执行实例
+        TaskExecution execution = taskExecutionMapper.selectById(executionId);
+        if (execution == null) {
+            log.error("执行实例不存在,executionId={}", executionId);
+            return;
+        }
+
+        // 更新节点状态为FAILED
+        ExecutionNode node = execution.getExecutionPlan().getNode(nodeIndex);
+        if (node != null) {
+            node.setStatus(NodeStatus.FAILED.name());
+            node.setFinishedAt(LocalDateTime.now());
+        }
+
+        // 更新执行实例状态为FAILED
+        execution.markAsFailed(failReason);
+        execution.setFinishedAt(LocalDateTime.now());
+        taskExecutionMapper.updateByVersion(execution);
+
+        // 触发安全关闭
+        log.info("触发安全关闭,executionId={}", executionId);
+        SafeShutdownService.ShutdownResult shutdownResult = safeShutdownService.shutdown(executionId);
+
+        // 更新安全关闭结果
+        execution.setSafeCloseStatus(shutdownResult.isSuccess() ? "SUCCESS" : "PARTIAL");
+
+        // 使用JSON格式存储关闭详情
+        String closeDetails = String.format("{\"success\":[%s],\"failed\":[%s]}",
+                shutdownResult.getSuccessDevices().stream()
+                        .map(d -> "\"" + d + "\"")
+                        .collect(Collectors.joining(",")),
+                shutdownResult.getFailedDevices().stream()
+                        .map(d -> "\"" + d + "\"")
+                        .collect(Collectors.joining(",")));
+        execution.setSafeCloseDetails(closeDetails);
+        taskExecutionMapper.updateByVersion(execution);
+
+        // 创建报警记录
+        Long alarmId = alarmRecordService.createTaskFailureAlarm(executionId, failReason);
+
+        // 发送报警通知(异步,不阻塞)
+        if (alarmId != null) {
+            try {
+                alarmNotificationService.sendAlarm(executionId);
+            } catch (Exception e) {
+                log.error("发送报警通知异常,executionId={}, alarmId={}", executionId, alarmId, e);
+                // 不抛出异常,避免影响主流程
+            }
+        }
+
+        log.warn("任务执行失败,executionId={}, failReason={}, shutdownResult={}, alarmId={}",
+                executionId, failReason, shutdownResult.getSummary(), alarmId);
+    }
+
+    /**
+     * 处理ACK超时
+     *
+     * @param executionId 执行实例ID
+     * @param nodeIndex   节点索引
+     */
+    public void handleAckTimeout(Long executionId, Integer nodeIndex) {
+        log.warn("ACK超时兜底处理,executionId={}, nodeIndex={}", executionId, nodeIndex);
+
+        // 幂等性检查1:如果节点已推进,忽略超时
+        if (idempotencyManager.isProceed(executionId, nodeIndex)) {
+            log.info("节点已推进,忽略超时处理,executionId={}, nodeIndex={}",
+                    executionId, nodeIndex);
+            return;
+        }
+
+        // 幂等性检查2:如果超时已处理,忽略重复
+        // (已在Consumer中处理)
+
+        // 加载执行实例
+        TaskExecution execution = taskExecutionMapper.selectById(executionId);
+        if (execution == null) {
+            log.error("执行实例不存在,executionId={}", executionId);
+            return;
+        }
+
+        // 检查ACK状态
+        ExecutionNode node = execution.getExecutionPlan().getNode(nodeIndex);
+        if (node == null) {
+            log.error("节点不存在,executionId={}, nodeIndex={}", executionId, nodeIndex);
+            return;
+        }
+
+        Map<String, AckStatus> ackMap = ackManager.checkAckStatus(executionId, nodeIndex, node.getDevices());
+
+        // 如果全部成功,推进
+        if (ackManager.allSuccess(ackMap)) {
+            log.info("ACK超时检查发现全部成功,推进节点,executionId={}, nodeIndex={}",
+                    executionId, nodeIndex);
+            checkAckAndProceed(executionId, nodeIndex);
+            return;
+        }
+
+        // 仍有设备未响应或失败,触发节点失败
+        List<String> pendingDevices = ackMap.entrySet().stream()
+                .filter(e -> e.getValue().isPending())
+                .map(Map.Entry::getKey)
+                .collect(Collectors.toList());
+
+        List<String> failedDevices = ackManager.getFailedDevices(ackMap);
+
+        String failReason = String.format("ACK超时(30秒),pending设备: %s, failed设备: %s",
+                String.join(",", pendingDevices), String.join(",", failedDevices));
+
+        handleNodeFailure(executionId, nodeIndex, failReason);
+    }
+}

+ 165 - 0
src/main/java/cn/sciento/farm/automationv2/app/service/TaskTriggerService.java

@@ -0,0 +1,165 @@
+package cn.sciento.farm.automationv2.app.service;
+
+import cn.sciento.farm.automationv2.domain.entity.IrrigationGroup;
+import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
+import cn.sciento.farm.automationv2.domain.entity.TaskExecution;
+import cn.sciento.farm.automationv2.domain.entity.TaskGroupConfig;
+import cn.sciento.farm.automationv2.domain.enums.ExecutionStatus;
+import cn.sciento.farm.automationv2.domain.enums.TriggerType;
+import cn.sciento.farm.automationv2.domain.service.ExecutionPlanGenerator;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionPlan;
+import cn.sciento.farm.automationv2.domain.valueobject.ZoneConfigView;
+import cn.sciento.farm.automationv2.infra.mq.producer.FlowControlProducer;
+import cn.sciento.farm.automationv2.infra.repository.IrrigationGroupMapper;
+import cn.sciento.farm.automationv2.infra.repository.IrrigationTaskMapper;
+import cn.sciento.farm.automationv2.infra.repository.TaskExecutionMapper;
+import cn.sciento.farm.automationv2.infra.repository.TaskGroupConfigMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 任务触发服务
+ * 功能:触发任务执行,生成执行实例,发送启动消息
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class TaskTriggerService {
+
+    private final IrrigationTaskMapper irrigationTaskMapper;
+    private final IrrigationGroupMapper irrigationGroupMapper;
+    private final TaskGroupConfigMapper taskGroupConfigMapper;
+    private final TaskExecutionMapper taskExecutionMapper;
+    private final ExecutionPlanGenerator executionPlanGenerator;
+    private final FlowControlProducer flowControlProducer;
+
+    /**
+     * 触发任务执行
+     *
+     * @param taskId      任务ID
+     * @param triggerType 触发类型(SCHEDULED / LINKAGE / MANUAL)
+     * @return 执行实例ID
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Long triggerTask(Long taskId, TriggerType triggerType) {
+        log.info("触发任务,taskId={}, triggerType={}", taskId, triggerType);
+
+        // 加载任务
+        IrrigationTask task = irrigationTaskMapper.selectById(taskId);
+        if (task == null) {
+            log.error("任务不存在,taskId={}", taskId);
+            throw new IllegalArgumentException("任务不存在");
+        }
+
+        // 检查任务是否启用
+        if (!task.getEnabled()) {
+            log.warn("任务未启用,taskId={}", taskId);
+            throw new IllegalStateException("任务未启用,无法触发");
+        }
+
+        // 检查是否达到执行上限
+        if (task.reachedExecutionLimit()) {
+            log.warn("任务已达执行上限,taskId={}, executedCount={}, totalTimes={}",
+                    taskId, task.getExecutedCount(), task.getTotalTimes());
+            throw new IllegalStateException("任务已达执行上限");
+        }
+
+        // 加载任务灌区配置(灌区列表 + 灌溉时长)
+        List<TaskGroupConfig> groupConfigs = taskGroupConfigMapper.selectByTaskId(taskId);
+        if (groupConfigs == null || groupConfigs.isEmpty()) {
+            log.error("任务未配置灌区,taskId={}", taskId);
+            throw new IllegalStateException("任务未配置灌区");
+        }
+
+        // 查询灌溉组模板(设备信息)
+        List<Long> groupIds = groupConfigs.stream()
+                .map(TaskGroupConfig::getGroupId)
+                .collect(Collectors.toList());
+        List<IrrigationGroup> groups = irrigationGroupMapper.selectByIds(groupIds);
+
+        Map<Long, IrrigationGroup> groupMap = groups.stream()
+                .collect(Collectors.toMap(IrrigationGroup::getId, g -> g));
+
+        // 合并为 ZoneConfigView 列表(灌溉组模板 + 任务级配置)
+        List<ZoneConfigView> zones = new ArrayList<>();
+        for (TaskGroupConfig config : groupConfigs) {
+            IrrigationGroup group = groupMap.get(config.getGroupId());
+            if (group == null) {
+                log.error("灌溉组不存在,groupId={}", config.getGroupId());
+                throw new IllegalStateException("灌溉组不存在,groupId=" + config.getGroupId());
+            }
+            zones.add(ZoneConfigView.from(group, config));
+        }
+
+        // 生成执行计划
+        ExecutionPlan plan = executionPlanGenerator.generate(task, zones);
+
+        log.info("执行计划生成完成,taskId={}, totalNodes={}, expectedDurationMinutes={}",
+                taskId, plan.getNodes().size(), plan.calculateExpectedDurationMinutes());
+
+        // 创建执行实例
+        TaskExecution execution = TaskExecution.builder()
+                .taskId(taskId)
+                .taskName(task.getTaskName())
+                .triggerType(triggerType)
+                .executionPlan(plan)
+                .currentIndex(0)
+                .status(ExecutionStatus.PENDING)
+                .version(0)
+                .tenantId(task.getTenantId())
+                .createdAt(LocalDateTime.now())
+                .updatedAt(LocalDateTime.now())
+                .build();
+
+        // 计算预期完成时间
+        int expectedMinutes = plan.calculateExpectedDurationMinutes();
+        execution.setExpectedFinishAt(LocalDateTime.now().plusMinutes(expectedMinutes));
+
+        // 持久化执行实例
+        taskExecutionMapper.insert(execution);
+
+        log.info("执行实例创建成功,executionId={}, taskId={}, triggerType={}, expectedFinishAt={}",
+                execution.getId(), taskId, triggerType, execution.getExpectedFinishAt());
+
+        // 更新任务执行次数
+        task.incrementExecutedCount();
+        irrigationTaskMapper.updateById(task);
+
+        // 发送任务启动消息
+        flowControlProducer.sendTaskStart(execution.getId(), taskId, task.getTenantId());
+
+        log.info("任务触发成功,executionId={}, taskId={}, triggerType={}",
+                execution.getId(), taskId, triggerType);
+
+        return execution.getId();
+    }
+
+    /**
+     * 手动触发任务
+     */
+    public Long manualTrigger(Long taskId) {
+        return triggerTask(taskId, TriggerType.MANUAL);
+    }
+
+    /**
+     * 定时触发任务
+     */
+    public Long scheduledTrigger(Long taskId) {
+        return triggerTask(taskId, TriggerType.SCHEDULED);
+    }
+
+    /**
+     * 联动触发任务
+     */
+    public Long linkageTrigger(Long taskId) {
+        return triggerTask(taskId, TriggerType.LINKAGE);
+    }
+}

+ 146 - 0
src/main/java/cn/sciento/farm/automationv2/app/service/WatchdogService.java

@@ -0,0 +1,146 @@
+package cn.sciento.farm.automationv2.app.service;
+
+import cn.sciento.farm.automationv2.domain.entity.TaskExecution;
+import cn.sciento.farm.automationv2.domain.enums.ExecutionStatus;
+import cn.sciento.farm.automationv2.infra.mq.producer.FlowControlProducer;
+import cn.sciento.farm.automationv2.infra.repository.TaskExecutionMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 看门狗服务
+ * 功能:定期检查卡住的执行实例,自动恢复执行
+ *
+ * 卡住判断条件:
+ * - 状态为RUNNING
+ * - 最后心跳时间超过阈值(默认5分钟)
+ *
+ * 恢复策略:
+ * - 重新发送当前节点的CHECK_ACK消息
+ * - 更新心跳时间
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class WatchdogService {
+
+    private final TaskExecutionMapper taskExecutionMapper;
+    private final FlowControlProducer flowControlProducer;
+
+    /**
+     * 心跳超时阈值(分钟)
+     */
+    @Value("${watchdog.heartbeat-timeout-minutes:5}")
+    private Integer heartbeatTimeoutMinutes;
+
+    /**
+     * 是否启用看门狗
+     */
+    @Value("${watchdog.enabled:true}")
+    private boolean enabled;
+
+    /**
+     * 定期检查卡住的执行实例
+     * 每隔1分钟执行一次
+     */
+    @Scheduled(fixedDelay = 60000, initialDelay = 60000)
+    public void checkStuckExecutions() {
+        if (!enabled) {
+            return;
+        }
+
+        log.debug("看门狗开始检查卡住的执行实例,heartbeatTimeoutMinutes={}", heartbeatTimeoutMinutes);
+
+        try {
+            // 查询卡住的执行实例
+            List<TaskExecution> stuckExecutions = taskExecutionMapper.selectStuckExecutions(heartbeatTimeoutMinutes);
+
+            if (stuckExecutions.isEmpty()) {
+                log.debug("没有发现卡住的执行实例");
+                return;
+            }
+
+            log.warn("发现{}个卡住的执行实例", stuckExecutions.size());
+
+            // 逐个恢复
+            for (TaskExecution execution : stuckExecutions) {
+                try {
+                    recoverExecution(execution);
+                } catch (Exception e) {
+                    log.error("恢复执行实例异常,executionId={}", execution.getId(), e);
+                }
+            }
+
+        } catch (Exception e) {
+            log.error("看门狗检查异常", e);
+        }
+    }
+
+    /**
+     * 恢复卡住的执行实例
+     */
+    private void recoverExecution(TaskExecution execution) {
+        Long executionId = execution.getId();
+        Integer currentIndex = execution.getCurrentIndex();
+
+        log.info("恢复卡住的执行实例,executionId={}, currentIndex={}, lastHeartbeat={}",
+                executionId, currentIndex, execution.getLastHeartbeatAt());
+
+        // 检查当前节点
+        if (execution.getCurrentNode() == null) {
+            log.warn("当前节点不存在,无法恢复,executionId={}, currentIndex={}",
+                    executionId, currentIndex);
+            return;
+        }
+
+        // 更新心跳时间
+        execution.updateHeartbeat();
+        int updated = taskExecutionMapper.updateHeartbeat(executionId);
+
+        if (updated == 0) {
+            log.warn("更新心跳失败,可能已被其他节点处理,executionId={}", executionId);
+            return;
+        }
+
+        // 重新发送CHECK_ACK消息(延迟0秒,立即检查)
+        Integer retryCount = execution.getCurrentNode().getRetryCount();
+        flowControlProducer.sendCheckAck(executionId, currentIndex, retryCount != null ? retryCount : 0, 0);
+
+        log.info("已重新发送CHECK_ACK消息,executionId={}, currentIndex={}", executionId, currentIndex);
+    }
+
+    /**
+     * 手动触发看门狗检查(用于测试或管理)
+     */
+    public int manualCheck() {
+        log.info("手动触发看门狗检查");
+
+        List<TaskExecution> stuckExecutions = taskExecutionMapper.selectStuckExecutions(heartbeatTimeoutMinutes);
+
+        log.info("发现{}个卡住的执行实例", stuckExecutions.size());
+
+        for (TaskExecution execution : stuckExecutions) {
+            try {
+                recoverExecution(execution);
+            } catch (Exception e) {
+                log.error("恢复执行实例异常,executionId={}", execution.getId(), e);
+            }
+        }
+
+        return stuckExecutions.size();
+    }
+
+    /**
+     * 获取当前卡住的执行实例数量
+     */
+    public int getStuckExecutionCount() {
+        List<TaskExecution> stuckExecutions = taskExecutionMapper.selectStuckExecutions(heartbeatTimeoutMinutes);
+        return stuckExecutions.size();
+    }
+}

+ 15 - 0
src/main/java/cn/sciento/farm/automationv2/config/MyBatisConfig.java

@@ -0,0 +1,15 @@
+package cn.sciento.farm.automationv2.config;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * MyBatis 配置类
+ */
+@Configuration
+@MapperScan("cn.sciento.farm.automationv2.infra.repository")
+public class MyBatisConfig {
+
+    // MyBatis配置已在application.yml中配置
+    // 本类主要用于扫描Mapper接口
+}

+ 60 - 0
src/main/java/cn/sciento/farm/automationv2/config/QuartzConfig.java

@@ -0,0 +1,60 @@
+package cn.sciento.farm.automationv2.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.quartz.SchedulerFactoryBean;
+
+import javax.sql.DataSource;
+import java.util.Properties;
+
+/**
+ * Quartz调度器配置
+ * 功能:配置Quartz集群模式,使用JDBC持久化
+ */
+@Configuration
+public class QuartzConfig {
+
+    /**
+     * 配置Quartz调度器工厂
+     */
+    @Bean
+    public SchedulerFactoryBean schedulerFactoryBean(DataSource dataSource) {
+        SchedulerFactoryBean factory = new SchedulerFactoryBean();
+
+        // 使用应用的数据源
+        factory.setDataSource(dataSource);
+
+        // 覆盖已存在的Job
+        factory.setOverwriteExistingJobs(true);
+
+        // 延迟启动(等待Spring容器初始化完成)
+        factory.setStartupDelay(10);
+
+        // 自动启动调度器
+        factory.setAutoStartup(true);
+
+        // 设置Quartz属性
+        Properties properties = new Properties();
+
+        // 调度器实例名称
+        properties.setProperty("org.quartz.scheduler.instanceName", "IrrigationScheduler");
+        properties.setProperty("org.quartz.scheduler.instanceId", "AUTO");
+
+        // 线程池配置
+        properties.setProperty("org.quartz.threadPool.class", "org.quartz.simpl.SimpleThreadPool");
+        properties.setProperty("org.quartz.threadPool.threadCount", "10");
+        properties.setProperty("org.quartz.threadPool.threadPriority", "5");
+
+        // JobStore配置(JDBC持久化)
+        properties.setProperty("org.quartz.jobStore.class", "org.quartz.impl.jdbcjobstore.JobStoreTX");
+        properties.setProperty("org.quartz.jobStore.driverDelegateClass", "org.quartz.impl.jdbcjobstore.StdJDBCDelegate");
+        properties.setProperty("org.quartz.jobStore.tablePrefix", "QRTZ_");
+        properties.setProperty("org.quartz.jobStore.isClustered", "true");
+        properties.setProperty("org.quartz.jobStore.clusterCheckinInterval", "20000");
+        properties.setProperty("org.quartz.jobStore.misfireThreshold", "60000");
+
+        factory.setQuartzProperties(properties);
+
+        return factory;
+    }
+}

+ 168 - 0
src/main/java/cn/sciento/farm/automationv2/domain/entity/AlarmRecord.java

@@ -0,0 +1,168 @@
+package cn.sciento.farm.automationv2.domain.entity;
+
+import cn.sciento.farm.automationv2.domain.enums.AlarmType;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 报警记录实体
+ * 对应数据库表:alarm_record
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AlarmRecord {
+
+    /**
+     * 报警记录唯一ID
+     */
+    private Long id;
+
+    /**
+     * 关联的执行实例ID
+     */
+    private Long executionId;
+
+    /**
+     * 关联的任务ID
+     */
+    private Long taskId;
+
+    /**
+     * 任务名称快照
+     */
+    private String taskName;
+
+    /**
+     * 报警类型:DEVICE_FAIL / TIMEOUT / SAFE_CLOSE / SYSTEM_ERROR
+     */
+    private AlarmType alarmType;
+
+    /**
+     * 报警级别:INFO / WARN / ERROR / CRITICAL
+     */
+    private String alarmLevel;
+
+    /**
+     * 失败节点名称
+     */
+    private String failedNode;
+
+    /**
+     * 失败节点索引
+     */
+    private Integer failedNodeIndex;
+
+    /**
+     * 详细失败原因
+     */
+    private String failReason;
+
+    /**
+     * 失败设备列表(JSON格式)
+     */
+    private String failedDevices;
+
+    /**
+     * 成功设备列表(JSON格式)
+     */
+    private String successDevices;
+
+    /**
+     * 安全关闭状态:SUCCESS / PARTIAL / FAILED
+     */
+    private String safeCloseStatus;
+
+    /**
+     * 安全关闭详情(JSON格式)
+     */
+    private String safeCloseDetails;
+
+    /**
+     * 已通知渠道(SMS/APP/WEBSOCKET)
+     */
+    private String notifiedChannels;
+
+    /**
+     * 通知状态:PENDING / SUCCESS / FAILED
+     */
+    private String notifyStatus;
+
+    /**
+     * 通知失败原因
+     */
+    private String notifyError;
+
+    /**
+     * 是否已处理:0未处理 1已处理
+     */
+    private Boolean handled;
+
+    /**
+     * 处理人ID
+     */
+    private Long handledBy;
+
+    /**
+     * 处理时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime handledAt;
+
+    /**
+     * 处理备注
+     */
+    private String handleRemark;
+
+    /**
+     * 租户ID
+     */
+    private Long tenantId;
+
+    /**
+     * 报警时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime createdAt;
+
+    // ================== 业务方法 ==================
+
+    /**
+     * 是否为严重报警
+     */
+    public boolean isCritical() {
+        return "CRITICAL".equals(alarmLevel) || "ERROR".equals(alarmLevel);
+    }
+
+    /**
+     * 标记为已处理
+     */
+    public void markAsHandled(Long handlerId, String remark) {
+        this.handled = true;
+        this.handledBy = handlerId;
+        this.handledAt = LocalDateTime.now();
+        this.handleRemark = remark;
+    }
+
+    /**
+     * 标记通知成功
+     */
+    public void markNotifySuccess(String channels) {
+        this.notifyStatus = "SUCCESS";
+        this.notifiedChannels = channels;
+    }
+
+    /**
+     * 标记通知失败
+     */
+    public void markNotifyFailed(String error) {
+        this.notifyStatus = "FAILED";
+        this.notifyError = error;
+    }
+}

+ 86 - 0
src/main/java/cn/sciento/farm/automationv2/domain/entity/IrrigationGroup.java

@@ -0,0 +1,86 @@
+package cn.sciento.farm.automationv2.domain.entity;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.choerodon.mybatis.annotation.ModifyAudit;
+import io.choerodon.mybatis.annotation.VersionAudit;
+import io.choerodon.mybatis.domain.AuditDomain;
+import io.swagger.annotations.ApiModel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.GeneratedValue;
+import javax.persistence.Id;
+import javax.persistence.Table;
+import java.time.LocalDateTime;
+
+/**
+ * 灌溉组实体
+ * 对应数据库表:irrigation_group
+ */
+@Data
+@Table(name = "wfauto_v2_irrigation_group")
+@ApiModel("灌溉组(灌区)")
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@ModifyAudit
+public class IrrigationGroup extends AuditDomain {
+
+    /**
+     * 灌溉组ID
+     */
+    @Id
+    @GeneratedValue
+    private Long id;
+
+    /**
+     * 灌溉组名称
+     */
+    private String groupName;
+
+    /**
+     * 灌溉组编码
+     */
+    private String groupCode;
+
+    /**
+     * 分区压力(kPa)
+     * 仅在任务压力模式为 PUMP_ZONE 时生效,灌区切换时系统自动下发新压力值
+     */
+    private Integer zonePressureKpa;
+
+    /**
+     * 电磁阀列表(JSON格式)
+     * 结构:[{"deviceId":"valve-001", "deviceName":"1号电磁阀"}]
+     */
+    private String solenoidValves;
+
+    /**
+     * 球阀列表(JSON格式)
+     * 结构:[{"deviceId":ball-001, "deviceName":"1号球阀", "sw":1, "targetAngle":80, "targetPressureKpa":250}]
+     * targetPressureKpa 为可选字段,若球阀支持压力控制则配置
+     */
+    private String ballValves;
+
+    /**
+     * 是否启用:1启用 0禁用
+     */
+    private Integer enabled;
+
+    /**
+     * 租户ID
+     */
+    private Long tenantId;
+
+    /**
+     * 基地ID
+     */
+    private Long areaId;
+
+    /**
+     * 逻辑删除:0未删除 1已删除
+     */
+    private Integer deleted;
+}

+ 279 - 0
src/main/java/cn/sciento/farm/automationv2/domain/entity/IrrigationTask.java

@@ -0,0 +1,279 @@
+package cn.sciento.farm.automationv2.domain.entity;
+
+import cn.sciento.farm.automationv2.domain.enums.*;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 轮灌任务配置实体
+ * 对应数据库表:irrigation_task
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class IrrigationTask {
+
+    /**
+     * 任务ID
+     */
+    private Long id;
+
+    /**
+     * 任务名称
+     */
+    private String taskName;
+
+    /**
+     * 任务编码(唯一)
+     */
+    private String taskCode;
+
+    /**
+     * 触发类型:SCHEDULED / LINKAGE / MANUAL
+     */
+    private TriggerType triggerType;
+
+    // ================== 定时触发配置 ==================
+
+    /**
+     * 定时类型:CRON / SIMPLE(仅trigger_type=SCHEDULED时有效)
+     */
+    private ScheduleType scheduleType;
+
+    /**
+     * Cron表达式(schedule_type=CRON)
+     */
+    private String cronExpression;
+
+    /**
+     * 起始时间(schedule_type=SIMPLE)
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime startTime;
+
+    /**
+     * 执行间隔天数(schedule_type=SIMPLE)
+     */
+    private Integer intervalDays;
+
+    /**
+     * 执行总次数(schedule_type=SIMPLE)
+     */
+    private Integer totalTimes;
+
+    /**
+     * 已执行次数(schedule_type=SIMPLE)
+     */
+    private Integer executedCount;
+
+    // ================== 水泵配置 ==================
+
+    /**
+     * 水泵设备ID
+     */
+    private String pumpId;
+
+    /**
+     * 水泵压力模式:NONE / PUMP_UNIFIED / PUMP_ZONE
+     * NONE - 普通启停,不下发压力指令
+     * PUMP_UNIFIED - 统一压力,所有灌区使用同一压力值
+     * PUMP_ZONE - 分区压力,每个灌区可设置不同的水泵目标压力
+     */
+    private PressureMode pressureMode;
+
+    /**
+     * 统一目标压力值(kPa,仅 PUMP_UNIFIED 模式需配置)
+     */
+    private Integer targetPressureKpa;
+
+    // ================== 安全参数 ==================
+
+    /**
+     * 灌区切换稳压等待时间(秒)
+     * 用于平衡管路水压,防止压力突变损坏设备或爆管
+     * 默认值:5秒
+     */
+    private Integer switchStableSeconds;
+
+    // ================== 施肥机配置(可选) ==================
+
+    /**
+     * 施肥泵设备ID
+     */
+    private String fertilizerPumpId;
+
+    /**
+     * 搅拌电机设备ID
+     */
+    private String stirMotorId;
+
+    /**
+     * 施肥控制模式:TIME / VOLUME
+     */
+    private FertilizerControlMode fertilizerControlMode;
+
+    /**
+     * 施肥延迟时间(分钟)
+     * 灌区开始灌溉后,等待多少分钟再启动搅拌电机
+     */
+    private Integer fertDelayMinutes;
+
+    /**
+     * 搅拌提前时长(分钟)
+     * 搅拌电机比施肥泵提前多少分钟启动
+     */
+    private Integer preStirMinutes;
+
+    /**
+     * 施肥时长(分钟)
+     * 仅 TIME 模式使用,与 fertTargetLiters 二选一
+     */
+    private Integer fertDurationMinutes;
+
+    /**
+     * 施肥目标量(升)
+     * 仅 VOLUME 模式使用,与 fertDurationMinutes 二选一
+     */
+    private Integer fertTargetLiters;
+
+    // ================== 状态与控制 ==================
+
+    /**
+     * 任务状态:ENABLED / DISABLED / DELETED
+     */
+    private TaskStatus status;
+
+    /**
+     * 是否启用:1启用 0禁用
+     */
+    private Boolean enabled;
+
+    // ================== 多租户 ==================
+
+    /**
+     * 租户ID
+     */
+    private Long tenantId;
+
+    // ================== 审计字段 ==================
+
+    /**
+     * 创建人ID
+     */
+    private Long createdBy;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime createdAt;
+
+    /**
+     * 更新人ID
+     */
+    private Long updatedBy;
+
+    /**
+     * 更新时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime updatedAt;
+
+    /**
+     * 逻辑删除:0未删除 1已删除
+     */
+    private Boolean deleted;
+
+    // ================== 业务方法 ==================
+
+    /**
+     * 是否为定时触发
+     */
+    public boolean isScheduled() {
+        return TriggerType.SCHEDULED.equals(triggerType);
+    }
+
+    /**
+     * 是否为联动触发
+     */
+    public boolean isLinkage() {
+        return TriggerType.LINKAGE.equals(triggerType);
+    }
+
+    /**
+     * 是否配置了施肥机
+     */
+    public boolean hasFertilizer() {
+        return fertilizerPumpId != null && !fertilizerPumpId.isEmpty()
+                && stirMotorId != null && !stirMotorId.isEmpty();
+    }
+
+    /**
+     * 是否为统一压力模式
+     */
+    public boolean isPumpUnifiedMode() {
+        return PressureMode.PUMP_UNIFIED.equals(pressureMode);
+    }
+
+    /**
+     * 是否为分区压力模式
+     */
+    public boolean isPumpZoneMode() {
+        return PressureMode.PUMP_ZONE.equals(pressureMode);
+    }
+
+    /**
+     * 是否需要下发水泵压力指令
+     */
+    public boolean needsPumpPressure() {
+        return isPumpUnifiedMode() || isPumpZoneMode();
+    }
+
+    /**
+     * 是否为施肥时间模式
+     */
+    public boolean isFertTimeMode() {
+        return FertilizerControlMode.TIME.equals(fertilizerControlMode);
+    }
+
+    /**
+     * 是否为施肥容量模式
+     */
+    public boolean isFertVolumeMode() {
+        return FertilizerControlMode.VOLUME.equals(fertilizerControlMode);
+    }
+
+    /**
+     * Simple调度是否已达到执行次数上限
+     */
+    public boolean reachedExecutionLimit() {
+        return ScheduleType.SIMPLE.equals(scheduleType)
+                && executedCount != null
+                && totalTimes != null
+                && executedCount >= totalTimes;
+    }
+
+    /**
+     * 增加已执行次数
+     */
+    public void incrementExecutedCount() {
+        if (executedCount == null) {
+            executedCount = 0;
+        }
+        executedCount++;
+    }
+
+    /**
+     * 获取稳压等待时间(秒)
+     * 默认值:5秒
+     */
+    public int getSwitchStableSecondsOrDefault() {
+        return switchStableSeconds != null ? switchStableSeconds : 5;
+    }
+}

+ 165 - 0
src/main/java/cn/sciento/farm/automationv2/domain/entity/LinkageRule.java

@@ -0,0 +1,165 @@
+package cn.sciento.farm.automationv2.domain.entity;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 传感器联动规则实体
+ * 对应数据库表:linkage_rule
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class LinkageRule {
+
+    /**
+     * 规则ID
+     */
+    private Long id;
+
+    /**
+     * 规则名称
+     */
+    private String ruleName;
+
+    /**
+     * 规则编码(唯一)
+     */
+    private String ruleCode;
+
+    /**
+     * 关联的轮灌任务ID
+     */
+    private Long taskId;
+
+    /**
+     * 监听的传感器设备ID
+     */
+    private String sensorDeviceId;
+
+    /**
+     * 传感器设备名称
+     */
+    private String sensorDeviceName;
+
+    /**
+     * 传感器数据类型(温度、湿度、土壤湿度等)
+     */
+    private String sensorDataType;
+
+    /**
+     * 比较符:> / >= / < / <= / ==
+     */
+    private String operator;
+
+    /**
+     * 触发条件的数值边界
+     */
+    private BigDecimal threshold;
+
+    /**
+     * 冷却时间(分钟),触发后的静默时间窗口
+     */
+    private Integer cooldownMinutes;
+
+    /**
+     * 是否启用:1启用 0禁用
+     */
+    private Boolean enabled;
+
+    /**
+     * 已触发次数(统计)
+     */
+    private Integer triggerCount;
+
+    /**
+     * 最后一次触发时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime lastTriggerAt;
+
+    /**
+     * 租户ID
+     */
+    private Long tenantId;
+
+    /**
+     * 创建人ID
+     */
+    private Long createdBy;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime createdAt;
+
+    /**
+     * 更新人ID
+     */
+    private Long updatedBy;
+
+    /**
+     * 更新时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime updatedAt;
+
+    /**
+     * 逻辑删除:0未删除 1已删除
+     */
+    private Boolean deleted;
+
+    // ================== 业务方法 ==================
+
+    /**
+     * 判断传感器数据是否满足触发条件
+     */
+    public boolean matchCondition(Double value) {
+        if (value == null || threshold == null || operator == null) {
+            return false;
+        }
+
+        double thresholdValue = threshold.doubleValue();
+
+        switch (operator) {
+            case ">":
+                return value > thresholdValue;
+            case ">=":
+                return value >= thresholdValue;
+            case "<":
+                return value < thresholdValue;
+            case "<=":
+                return value <= thresholdValue;
+            case "==":
+                return Math.abs(value - thresholdValue) < 0.001; // 浮点数相等判断
+            default:
+                return false;
+        }
+    }
+
+    /**
+     * 获取冷却时间(秒)
+     */
+    public int getCooldownSeconds() {
+        return cooldownMinutes != null ? cooldownMinutes * 60 : 0;
+    }
+
+    /**
+     * 增加触发次数
+     */
+    public void incrementTriggerCount() {
+        if (triggerCount == null) {
+            triggerCount = 0;
+        }
+        triggerCount++;
+        lastTriggerAt = LocalDateTime.now();
+    }
+}

+ 205 - 0
src/main/java/cn/sciento/farm/automationv2/domain/entity/TaskExecution.java

@@ -0,0 +1,205 @@
+package cn.sciento.farm.automationv2.domain.entity;
+
+import cn.sciento.farm.automationv2.domain.enums.ExecutionStatus;
+import cn.sciento.farm.automationv2.domain.enums.TriggerType;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionNode;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionPlan;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 任务执行实例实体(核心表)
+ * 对应数据库表:task_execution
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TaskExecution {
+
+    /**
+     * 执行实例唯一ID
+     */
+    private Long id;
+
+    /**
+     * 关联的轮灌任务ID
+     */
+    private Long taskId;
+
+    /**
+     * 任务名称快照
+     */
+    private String taskName;
+
+    /**
+     * 触发类型:SCHEDULED / LINKAGE / MANUAL
+     */
+    private TriggerType triggerType;
+
+    /**
+     * 执行计划(核心字段:预生成的完整有序节点列表)
+     * 数据库中存储为JSON格式
+     */
+    private ExecutionPlan executionPlan;
+
+    /**
+     * 当前正在执行的节点索引(从0开始)
+     */
+    private Integer currentIndex;
+
+    /**
+     * 执行状态:PENDING / RUNNING / SUCCESS / FAILED / CANCELLED
+     */
+    private ExecutionStatus status;
+
+    /**
+     * 乐观锁版本号(防止并发更新冲突)
+     */
+    private Integer version;
+
+    /**
+     * 实际开始时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime startedAt;
+
+    /**
+     * 完成时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime finishedAt;
+
+    /**
+     * 预计完成时间(含重试顺延后更新,用于超时判断)
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime expectedFinishAt;
+
+    /**
+     * 最近一次心跳时间(看门狗用)
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime lastHeartbeatAt;
+
+    /**
+     * 失败原因描述
+     */
+    private String failReason;
+
+    /**
+     * 安全关闭状态:SUCCESS / PARTIAL / FAILED
+     */
+    private String safeCloseStatus;
+
+    /**
+     * 安全关闭详情(JSON格式,包含失败设备列表等)
+     */
+    private String safeCloseDetails;
+
+    /**
+     * 租户ID
+     */
+    private Long tenantId;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime createdAt;
+
+    /**
+     * 更新时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime updatedAt;
+
+    // ================== 业务方法 ==================
+
+    /**
+     * 获取当前执行的节点
+     */
+    public ExecutionNode getCurrentNode() {
+        if (executionPlan == null || currentIndex == null) {
+            return null;
+        }
+        return executionPlan.getNode(currentIndex);
+    }
+
+    /**
+     * 是否还有下一个节点
+     */
+    public boolean hasNextNode() {
+        return executionPlan != null && executionPlan.hasNext(currentIndex);
+    }
+
+    /**
+     * 推进到下一个节点(仅更新索引,不更新数据库)
+     */
+    public void proceedToNext() {
+        if (hasNextNode()) {
+            this.currentIndex++;
+        }
+    }
+
+    /**
+     * 是否为终态
+     */
+    public boolean isTerminal() {
+        return status != null && status.isTerminal();
+    }
+
+    /**
+     * 是否可以继续推进
+     */
+    public boolean canProceed() {
+        return status != null && status.canProceed();
+    }
+
+    /**
+     * 标记为运行中
+     */
+    public void markAsRunning() {
+        this.status = ExecutionStatus.RUNNING;
+        if (this.startedAt == null) {
+            this.startedAt = LocalDateTime.now();
+        }
+    }
+
+    /**
+     * 标记为成功
+     */
+    public void markAsSuccess() {
+        this.status = ExecutionStatus.SUCCESS;
+        this.finishedAt = LocalDateTime.now();
+    }
+
+    /**
+     * 标记为失败
+     */
+    public void markAsFailed(String reason) {
+        this.status = ExecutionStatus.FAILED;
+        this.finishedAt = LocalDateTime.now();
+        this.failReason = reason;
+    }
+
+    /**
+     * 标记为已取消
+     */
+    public void markAsCancelled() {
+        this.status = ExecutionStatus.CANCELLED;
+        this.finishedAt = LocalDateTime.now();
+    }
+
+    /**
+     * 更新心跳时间
+     */
+    public void updateHeartbeat() {
+        this.lastHeartbeatAt = LocalDateTime.now();
+    }
+}

+ 68 - 0
src/main/java/cn/sciento/farm/automationv2/domain/entity/TaskGroupConfig.java

@@ -0,0 +1,68 @@
+package cn.sciento.farm.automationv2.domain.entity;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 任务灌区配置实体
+ * 关联任务和灌溉组,存储任务级配置(灌溉时长、执行顺序)
+ * 对应数据库表:task_group_config
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TaskGroupConfig {
+
+    /**
+     * 配置ID
+     */
+    private Long id;
+
+    /**
+     * 任务ID
+     */
+    private Long taskId;
+
+    /**
+     * 灌溉组ID
+     */
+    private Long groupId;
+
+    /**
+     * 执行顺序(0开始,用户配置的灌溉意图顺序)
+     */
+    private Integer sortOrder;
+
+    /**
+     * 灌溉时长(分钟,任务级配置)
+     * 每个灌区可设置不同时长
+     */
+    private Integer irrigationDurationMinutes;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime createdAt;
+
+    /**
+     * 更新时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime updatedAt;
+
+    // ================== 业务方法 ==================
+
+    /**
+     * 获取灌溉时长(秒)
+     */
+    public int getIrrigationDurationSeconds() {
+        return irrigationDurationMinutes != null ? irrigationDurationMinutes * 60 : 0;
+    }
+}

+ 65 - 0
src/main/java/cn/sciento/farm/automationv2/domain/enums/AckStatus.java

@@ -0,0 +1,65 @@
+package cn.sciento.farm.automationv2.domain.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 设备ACK响应状态枚举
+ */
+@Getter
+@AllArgsConstructor
+public enum AckStatus {
+
+    /**
+     * 等待ACK响应
+     */
+    PENDING("PENDING", "等待响应"),
+
+    /**
+     * ACK成功
+     */
+    SUCCESS("SUCCESS", "成功"),
+
+    /**
+     * ACK失败
+     */
+    FAIL("FAIL", "失败"),
+
+    /**
+     * ACK超时
+     */
+    TIMEOUT("TIMEOUT", "超时");
+
+    private final String code;
+    private final String desc;
+
+    public static AckStatus fromCode(String code) {
+        for (AckStatus status : values()) {
+            if (status.getCode().equals(code)) {
+                return status;
+            }
+        }
+        throw new IllegalArgumentException("Unknown AckStatus code: " + code);
+    }
+
+    /**
+     * 是否为成功状态
+     */
+    public boolean isSuccess() {
+        return this == SUCCESS;
+    }
+
+    /**
+     * 是否为失败状态(包括FAIL和TIMEOUT)
+     */
+    public boolean isFailure() {
+        return this == FAIL || this == TIMEOUT;
+    }
+
+    /**
+     * 是否仍在等待
+     */
+    public boolean isPending() {
+        return this == PENDING;
+    }
+}

+ 64 - 0
src/main/java/cn/sciento/farm/automationv2/domain/enums/AlarmType.java

@@ -0,0 +1,64 @@
+package cn.sciento.farm.automationv2.domain.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 报警类型枚举
+ */
+@Getter
+@AllArgsConstructor
+public enum AlarmType {
+
+    /**
+     * 任务执行失败
+     */
+    TASK_FAILURE("TASK_FAILURE", "任务执行失败"),
+
+    /**
+     * 设备失败
+     */
+    DEVICE_FAILURE("DEVICE_FAILURE", "设备失败"),
+
+    /**
+     * 设备失败(兼容旧代码)
+     */
+    DEVICE_FAIL("DEVICE_FAIL", "设备失败"),
+
+    /**
+     * 设备超时
+     */
+    DEVICE_TIMEOUT("DEVICE_TIMEOUT", "设备超时"),
+
+    /**
+     * 超时(兼容旧代码)
+     */
+    TIMEOUT("TIMEOUT", "超时"),
+
+    /**
+     * 安全关闭失败
+     */
+    SAFE_CLOSE_FAILURE("SAFE_CLOSE_FAILURE", "安全关闭失败"),
+
+    /**
+     * 安全关闭异常(兼容旧代码)
+     */
+    SAFE_CLOSE("SAFE_CLOSE", "安全关闭异常"),
+
+    /**
+     * 系统错误
+     */
+    SYSTEM_ERROR("SYSTEM_ERROR", "系统错误");
+
+    private final String code;
+    private final String desc;
+
+    public static AlarmType fromCode(String code) {
+        for (AlarmType type : values()) {
+            if (type.getCode().equals(code)) {
+                return type;
+            }
+        }
+        throw new IllegalArgumentException("Unknown AlarmType code: " + code);
+    }
+}

+ 63 - 0
src/main/java/cn/sciento/farm/automationv2/domain/enums/ExecutionStatus.java

@@ -0,0 +1,63 @@
+package cn.sciento.farm.automationv2.domain.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 执行状态枚举
+ */
+@Getter
+@AllArgsConstructor
+public enum ExecutionStatus {
+
+    /**
+     * 待执行
+     */
+    PENDING("PENDING", "待执行"),
+
+    /**
+     * 执行中
+     */
+    RUNNING("RUNNING", "执行中"),
+
+    /**
+     * 执行成功
+     */
+    SUCCESS("SUCCESS", "执行成功"),
+
+    /**
+     * 执行失败
+     */
+    FAILED("FAILED", "执行失败"),
+
+    /**
+     * 已取消
+     */
+    CANCELLED("CANCELLED", "已取消");
+
+    private final String code;
+    private final String desc;
+
+    public static ExecutionStatus fromCode(String code) {
+        for (ExecutionStatus status : values()) {
+            if (status.getCode().equals(code)) {
+                return status;
+            }
+        }
+        throw new IllegalArgumentException("Unknown ExecutionStatus code: " + code);
+    }
+
+    /**
+     * 是否为终态
+     */
+    public boolean isTerminal() {
+        return this == SUCCESS || this == FAILED || this == CANCELLED;
+    }
+
+    /**
+     * 是否可以继续推进
+     */
+    public boolean canProceed() {
+        return this == PENDING || this == RUNNING;
+    }
+}

+ 28 - 0
src/main/java/cn/sciento/farm/automationv2/domain/enums/FertilizerControlMode.java

@@ -0,0 +1,28 @@
+package cn.sciento.farm.automationv2.domain.enums;
+
+import lombok.Getter;
+
+/**
+ * 施肥控制模式枚举
+ */
+@Getter
+public enum FertilizerControlMode {
+
+    /**
+     * 时间模式(配置施肥时长)
+     */
+    TIME("TIME", "时间模式"),
+
+    /**
+     * 容量模式(配置施肥量)
+     */
+    VOLUME("VOLUME", "容量模式");
+
+    private final String code;
+    private final String description;
+
+    FertilizerControlMode(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+}

+ 66 - 0
src/main/java/cn/sciento/farm/automationv2/domain/enums/NodeStatus.java

@@ -0,0 +1,66 @@
+package cn.sciento.farm.automationv2.domain.enums;
+
+/**
+ * 节点执行状态枚举
+ * 用于ExecutionNode的status字段
+ */
+public enum NodeStatus {
+
+    /**
+     * 等待执行
+     */
+    PENDING("待执行"),
+
+    /**
+     * 执行中
+     */
+    RUNNING("执行中"),
+
+    /**
+     * 执行成功
+     */
+    SUCCESS("成功"),
+
+    /**
+     * 执行失败
+     */
+    FAILED("失败");
+
+    private final String description;
+
+    NodeStatus(String description) {
+        this.description = description;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    /**
+     * 是否为完成状态(成功或失败)
+     */
+    public boolean isCompleted() {
+        return this == SUCCESS || this == FAILED;
+    }
+
+    /**
+     * 是否为成功状态
+     */
+    public boolean isSuccess() {
+        return this == SUCCESS;
+    }
+
+    /**
+     * 是否为失败状态
+     */
+    public boolean isFailed() {
+        return this == FAILED;
+    }
+
+    /**
+     * 是否为运行中
+     */
+    public boolean isRunning() {
+        return this == RUNNING;
+    }
+}

+ 76 - 0
src/main/java/cn/sciento/farm/automationv2/domain/enums/NodeType.java

@@ -0,0 +1,76 @@
+package cn.sciento.farm.automationv2.domain.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 节点类型枚举(责任链模式)
+ */
+@Getter
+@AllArgsConstructor
+public enum NodeType {
+
+    /**
+     * 开启灌溉组(电磁阀开 + 球阀到目标角度)
+     */
+    OPEN_GROUP("OPEN_GROUP", "开启灌溉组"),
+
+    /**
+     * 关闭灌溉组(电磁阀关 + 球阀归零)
+     */
+    CLOSE_GROUP("CLOSE_GROUP", "关闭灌溉组"),
+
+    /**
+     * 启动水泵
+     */
+    START_PUMP("START_PUMP", "启动水泵"),
+
+    /**
+     * 关闭水泵
+     */
+    STOP_PUMP("STOP_PUMP", "关闭水泵"),
+
+    /**
+     * 设置恒压水泵目标压力
+     */
+    SET_PUMP_PRESSURE("SET_PUMP_PRESSURE", "设置水泵压力"),
+
+    /**
+     * 启动施肥机(下发程序编号)
+     */
+    START_FERTILIZER("START_FERTILIZER", "启动施肥机"),
+
+    /**
+     * 关闭施肥机
+     */
+    STOP_FERTILIZER("STOP_FERTILIZER", "关闭施肥机"),
+
+    /**
+     * 等待指定时长(灌溉时长)
+     */
+    WAIT("WAIT", "等待"),
+
+    /**
+     * 灌区切换稳压等待(先开后关之间的等待,确保压力平稳)
+     */
+    ZONE_SWITCH_WAIT("ZONE_SWITCH_WAIT", "稳压等待");
+
+    private final String code;
+    private final String desc;
+
+    public static NodeType fromCode(String code) {
+        for (NodeType type : values()) {
+            if (type.getCode().equals(code)) {
+                return type;
+            }
+        }
+        throw new IllegalArgumentException("Unknown NodeType code: " + code);
+    }
+
+    /**
+     * 是否需要设备ACK确认
+     */
+    public boolean requiresAck() {
+        return this != WAIT && this != ZONE_SWITCH_WAIT;
+    }
+}

+ 33 - 0
src/main/java/cn/sciento/farm/automationv2/domain/enums/PressureMode.java

@@ -0,0 +1,33 @@
+package cn.sciento.farm.automationv2.domain.enums;
+
+import lombok.Getter;
+
+/**
+ * 水泵压力模式枚举
+ */
+@Getter
+public enum PressureMode {
+
+    /**
+     * 无压力控制(普通水泵启停)
+     */
+    NONE("NONE", "普通启停"),
+
+    /**
+     * 统一压力(所有灌区使用同一压力值)
+     */
+    PUMP_UNIFIED("PUMP_UNIFIED", "统一压力"),
+
+    /**
+     * 分区压力(每个灌区可设置不同的水泵目标压力)
+     */
+    PUMP_ZONE("PUMP_ZONE", "分区压力");
+
+    private final String code;
+    private final String description;
+
+    PressureMode(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+}

+ 41 - 0
src/main/java/cn/sciento/farm/automationv2/domain/enums/PumpType.java

@@ -0,0 +1,41 @@
+package cn.sciento.farm.automationv2.domain.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 水泵类型枚举
+ */
+@Getter
+@AllArgsConstructor
+public enum PumpType {
+
+    /**
+     * 普通水泵
+     */
+    NORMAL("NORMAL", "普通水泵"),
+
+    /**
+     * 恒压水泵(需配置目标压力)
+     */
+    PRESSURE("PRESSURE", "恒压水泵");
+
+    private final String code;
+    private final String desc;
+
+    public static PumpType fromCode(String code) {
+        for (PumpType type : values()) {
+            if (type.getCode().equals(code)) {
+                return type;
+            }
+        }
+        throw new IllegalArgumentException("Unknown PumpType code: " + code);
+    }
+
+    /**
+     * 是否需要设置压力
+     */
+    public boolean requiresPressure() {
+        return this == PRESSURE;
+    }
+}

+ 34 - 0
src/main/java/cn/sciento/farm/automationv2/domain/enums/ScheduleType.java

@@ -0,0 +1,34 @@
+package cn.sciento.farm.automationv2.domain.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 定时调度类型枚举
+ */
+@Getter
+@AllArgsConstructor
+public enum ScheduleType {
+
+    /**
+     * Cron表达式调度(按星期+时间重复)
+     */
+    CRON("CRON", "Cron表达式"),
+
+    /**
+     * Simple调度(启动时间+执行次数+执行周期)
+     */
+    SIMPLE("SIMPLE", "简单调度");
+
+    private final String code;
+    private final String desc;
+
+    public static ScheduleType fromCode(String code) {
+        for (ScheduleType type : values()) {
+            if (type.getCode().equals(code)) {
+                return type;
+            }
+        }
+        throw new IllegalArgumentException("Unknown ScheduleType code: " + code);
+    }
+}

+ 39 - 0
src/main/java/cn/sciento/farm/automationv2/domain/enums/TaskStatus.java

@@ -0,0 +1,39 @@
+package cn.sciento.farm.automationv2.domain.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 任务状态枚举
+ */
+@Getter
+@AllArgsConstructor
+public enum TaskStatus {
+
+    /**
+     * 启用
+     */
+    ENABLED("ENABLED", "启用"),
+
+    /**
+     * 禁用
+     */
+    DISABLED("DISABLED", "禁用"),
+
+    /**
+     * 已删除
+     */
+    DELETED("DELETED", "已删除");
+
+    private final String code;
+    private final String desc;
+
+    public static TaskStatus fromCode(String code) {
+        for (TaskStatus status : values()) {
+            if (status.getCode().equals(code)) {
+                return status;
+            }
+        }
+        throw new IllegalArgumentException("Unknown TaskStatus code: " + code);
+    }
+}

+ 39 - 0
src/main/java/cn/sciento/farm/automationv2/domain/enums/TriggerType.java

@@ -0,0 +1,39 @@
+package cn.sciento.farm.automationv2.domain.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 触发类型枚举
+ */
+@Getter
+@AllArgsConstructor
+public enum TriggerType {
+
+    /**
+     * 定时调度触发
+     */
+    SCHEDULED("SCHEDULED", "定时调度"),
+
+    /**
+     * 传感器联动触发
+     */
+    LINKAGE("LINKAGE", "传感器联动"),
+
+    /**
+     * 手动触发
+     */
+    MANUAL("MANUAL", "手动触发");
+
+    private final String code;
+    private final String desc;
+
+    public static TriggerType fromCode(String code) {
+        for (TriggerType type : values()) {
+            if (type.getCode().equals(code)) {
+                return type;
+            }
+        }
+        throw new IllegalArgumentException("Unknown TriggerType code: " + code);
+    }
+}

+ 352 - 0
src/main/java/cn/sciento/farm/automationv2/domain/service/ExecutionPlanGenerator.java

@@ -0,0 +1,352 @@
+package cn.sciento.farm.automationv2.domain.service;
+
+import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
+import cn.sciento.farm.automationv2.domain.enums.NodeType;
+import cn.sciento.farm.automationv2.domain.valueobject.DeviceInfo;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionNode;
+import cn.sciento.farm.automationv2.domain.valueobject.ExecutionPlan;
+import cn.sciento.farm.automationv2.domain.valueobject.ZoneConfigView;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 执行计划生成器(核心算法)
+ *
+ * 功能:根据安全规则自动生成完整的有序节点列表
+ *
+ * 安全规则:
+ * - S-01: 水泵必须在第一个灌区开启成功后才能启动
+ * - S-02: 灌区切换时,先开后关(先开下一个灌区,稳压等待,再关上一个灌区)
+ * - S-03: 最后一个灌区结束时,固定顺序:水泵→电磁阀/球阀
+ * - S-04: 开路优先原则(先开后关,全场景适用)
+ */
+@Slf4j
+@Service
+public class ExecutionPlanGenerator {
+
+    /**
+     * 生成执行计划
+     *
+     * @param task  轮灌任务
+     * @param zones 灌区配置列表(已合并灌溉组模板和任务级配置,按sortOrder排序)
+     * @return 执行计划
+     */
+    public ExecutionPlan generate(IrrigationTask task, List<ZoneConfigView> zones) {
+        if (zones == null || zones.isEmpty()) {
+            throw new IllegalArgumentException("灌区配置列表不能为空");
+        }
+
+        List<ExecutionNode> nodes = new ArrayList<>();
+        int nodeIndex = 0;
+
+        // 第一个灌区
+        ZoneConfigView firstZone = zones.get(0);
+
+        // 1. 开启第一个灌区(S-01前置:先开灌区)
+        nodeIndex = addOpenGroupNode(nodes, nodeIndex, firstZone);
+
+        // 2. 设置水泵压力(PUMP_UNIFIED/PUMP_ZONE模式)
+        if (task.needsPumpPressure()) {
+            Integer pressureKpa = task.isPumpUnifiedMode()
+                    ? task.getTargetPressureKpa()
+                    : firstZone.getZonePressureKpa();
+            if (pressureKpa != null) {
+                nodeIndex = addSetPumpPressureNode(nodes, nodeIndex, task.getPumpId(), pressureKpa);
+            }
+        }
+
+        // 3. 启动水泵(S-01:在第一个灌区开启后启动)
+        nodeIndex = addStartPumpNode(nodes, nodeIndex, task.getPumpId());
+
+        // 处理灌区切换(S-02:先开后关 + 稳压等待)
+        if (zones.size() == 1) {
+            // 只有一个灌区:直接等待后关闭
+            nodeIndex = addWaitNode(nodes, nodeIndex, firstZone.getIrrigationDurationMinutes(), "IRRIGATE");
+        } else {
+            // 多个灌区:循环处理灌区切换
+            for (int i = 1; i < zones.size(); i++) {
+                ZoneConfigView currentZone = zones.get(i);
+                ZoneConfigView previousZone = zones.get(i - 1);
+
+                // 等待上一个灌区的灌溉时长
+                nodeIndex = addWaitNode(nodes, nodeIndex, previousZone.getIrrigationDurationMinutes(), "IRRIGATE");
+
+                // 先开当前灌区(S-04:先开后关)
+                nodeIndex = addOpenGroupNode(nodes, nodeIndex, currentZone);
+
+                // PUMP_ZONE模式:切换时调整水泵压力
+                if (task.isPumpZoneMode() && currentZone.getZonePressureKpa() != null) {
+                    nodeIndex = addSetPumpPressureNode(nodes, nodeIndex, task.getPumpId(), currentZone.getZonePressureKpa());
+                }
+
+                // 稳压等待(S-02:确保压力平稳后再关闭上一个灌区)
+                int switchStableSeconds = task.getSwitchStableSecondsOrDefault();
+                nodeIndex = addZoneSwitchWaitNode(nodes, nodeIndex, switchStableSeconds);
+
+                // 再关上一个灌区(S-04:后关)
+                nodeIndex = addCloseGroupNode(nodes, nodeIndex, previousZone);
+            }
+
+            // 最后一个灌区的等待
+            ZoneConfigView lastZone = zones.get(zones.size() - 1);
+            nodeIndex = addWaitNode(nodes, nodeIndex, lastZone.getIrrigationDurationMinutes(), "IRRIGATE");
+        }
+
+        // 最后阶段:安全关闭(S-03:水泵→电磁阀)
+        ZoneConfigView lastZone = zones.get(zones.size() - 1);
+
+        // 4. 关闭水泵
+        nodeIndex = addStopPumpNode(nodes, nodeIndex, task.getPumpId());
+
+        // 5. 关闭最后一个灌区
+        nodeIndex = addCloseGroupNode(nodes, nodeIndex, lastZone);
+
+        ExecutionPlan plan = ExecutionPlan.builder().nodes(nodes).build();
+
+        log.info("生成执行计划成功,taskId={}, nodeCount={}", task.getId(), nodes.size());
+
+        return plan;
+    }
+
+    /**
+     * 添加开启灌区节点
+     */
+    private int addOpenGroupNode(List<ExecutionNode> nodes, int index, ZoneConfigView zone) {
+        List<DeviceInfo> devices = new ArrayList<>();
+
+        // 添加电磁阀设备
+        devices.addAll(parseDeviceList(zone.getSolenoidValves(), "SOLENOID_VALVE"));
+
+        // 添加球阀设备
+        devices.addAll(parseDeviceList(zone.getBallValves(), "BALL_VALVE"));
+
+        Map<String, Object> params = new HashMap<>();
+        // 如果球阀配置了目标角度和压力,从JSON中提取
+        if (zone.getBallValves() != null && !zone.getBallValves().isEmpty()) {
+            JSONArray ballValves = JSON.parseArray(zone.getBallValves());
+            if (!ballValves.isEmpty()) {
+                JSONObject firstBallValve = ballValves.getJSONObject(0);
+                if (firstBallValve.containsKey("targetAngle")) {
+                    params.put("targetAngle", firstBallValve.getInteger("targetAngle"));
+                }
+                // 球阀目标压力(可选,与水泵压力独立)
+                if (firstBallValve.containsKey("targetPressureKpa")) {
+                    params.put("targetPressureKpa", firstBallValve.getInteger("targetPressureKpa"));
+                }
+            }
+        }
+
+        ExecutionNode node = ExecutionNode.builder()
+                .index(index)
+                .nodeType(NodeType.OPEN_GROUP)
+                .nodeName("开启" + zone.getGroupName())
+                .refId(zone.getGroupId())
+                .params(params)
+                .status("PENDING")
+                .retryCount(0)
+                .maxRetry(3)
+                .devices(devices)
+                .build();
+
+        nodes.add(node);
+        return index + 1;
+    }
+
+    /**
+     * 添加关闭灌区节点
+     */
+    private int addCloseGroupNode(List<ExecutionNode> nodes, int index, ZoneConfigView zone) {
+        List<DeviceInfo> devices = new ArrayList<>();
+
+        // 添加电磁阀设备
+        devices.addAll(parseDeviceList(zone.getSolenoidValves(), "SOLENOID_VALVE"));
+
+        // 添加球阀设备
+        devices.addAll(parseDeviceList(zone.getBallValves(), "BALL_VALVE"));
+
+        ExecutionNode node = ExecutionNode.builder()
+                .index(index)
+                .nodeType(NodeType.CLOSE_GROUP)
+                .nodeName("关闭" + zone.getGroupName())
+                .refId(zone.getGroupId())
+                .params(new HashMap<>())
+                .status("PENDING")
+                .retryCount(0)
+                .maxRetry(3)
+                .devices(devices)
+                .build();
+
+        nodes.add(node);
+        return index + 1;
+    }
+
+    /**
+     * 添加设置水泵压力节点
+     */
+    private int addSetPumpPressureNode(List<ExecutionNode> nodes, int index, String pumpId, Integer pressureKpa) {
+        DeviceInfo pumpDevice = DeviceInfo.builder()
+                .deviceId(pumpId)
+                .deviceType("PUMP")
+                .deviceName("水泵")
+                .build();
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("pressureKpa", pressureKpa);
+
+        ExecutionNode node = ExecutionNode.builder()
+                .index(index)
+                .nodeType(NodeType.SET_PUMP_PRESSURE)
+                .nodeName("设置水泵压力")
+                .refId(null)
+                .params(params)
+                .status("PENDING")
+                .retryCount(0)
+                .maxRetry(3)
+                .devices(Collections.singletonList(pumpDevice))
+                .build();
+
+        nodes.add(node);
+        return index + 1;
+    }
+
+    /**
+     * 添加启动水泵节点
+     */
+    private int addStartPumpNode(List<ExecutionNode> nodes, int index, String pumpId) {
+        DeviceInfo pumpDevice = DeviceInfo.builder()
+                .deviceId(pumpId)
+                .deviceType("PUMP")
+                .deviceName("水泵")
+                .build();
+
+        ExecutionNode node = ExecutionNode.builder()
+                .index(index)
+                .nodeType(NodeType.START_PUMP)
+                .nodeName("启动水泵")
+                .refId(null)
+                .params(new HashMap<>())
+                .status("PENDING")
+                .retryCount(0)
+                .maxRetry(3)
+                .devices(Collections.singletonList(pumpDevice))
+                .build();
+
+        nodes.add(node);
+        return index + 1;
+    }
+
+    /**
+     * 添加关闭水泵节点
+     */
+    private int addStopPumpNode(List<ExecutionNode> nodes, int index, String pumpId) {
+        DeviceInfo pumpDevice = DeviceInfo.builder()
+                .deviceId(pumpId)
+                .deviceType("PUMP")
+                .deviceName("水泵")
+                .build();
+
+        ExecutionNode node = ExecutionNode.builder()
+                .index(index)
+                .nodeType(NodeType.STOP_PUMP)
+                .nodeName("关闭水泵")
+                .refId(null)
+                .params(new HashMap<>())
+                .status("PENDING")
+                .retryCount(0)
+                .maxRetry(3)
+                .devices(Collections.singletonList(pumpDevice))
+                .build();
+
+        nodes.add(node);
+        return index + 1;
+    }
+
+    /**
+     * 添加等待节点
+     *
+     * @param source 等待来源:IRRIGATE(灌溉等待)/ ZONE_SWITCH(稳压等待)
+     */
+    private int addWaitNode(List<ExecutionNode> nodes, int index, Integer minutes, String source) {
+        int seconds = minutes * 60;
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("seconds", seconds);
+        params.put("source", source);
+
+        ExecutionNode node = ExecutionNode.builder()
+                .index(index)
+                .nodeType(NodeType.WAIT)
+                .nodeName("等待" + minutes + "分钟")
+                .refId(null)
+                .params(params)
+                .status("PENDING")
+                .retryCount(0)
+                .maxRetry(0) // WAIT节点不需要重试
+                .devices(new ArrayList<>()) // WAIT节点没有设备
+                .build();
+
+        nodes.add(node);
+        return index + 1;
+    }
+
+    /**
+     * 添加灌区切换稳压等待节点
+     */
+    private int addZoneSwitchWaitNode(List<ExecutionNode> nodes, int index, int seconds) {
+        Map<String, Object> params = new HashMap<>();
+        params.put("seconds", seconds);
+        params.put("source", "ZONE_SWITCH");
+
+        ExecutionNode node = ExecutionNode.builder()
+                .index(index)
+                .nodeType(NodeType.ZONE_SWITCH_WAIT)
+                .nodeName("稳压等待" + seconds + "秒")
+                .refId(null)
+                .params(params)
+                .status("PENDING")
+                .retryCount(0)
+                .maxRetry(0) // 不需要重试
+                .devices(new ArrayList<>())
+                .build();
+
+        nodes.add(node);
+        return index + 1;
+    }
+
+    /**
+     * 解析设备列表JSON
+     */
+    private List<DeviceInfo> parseDeviceList(String devicesJson, String deviceType) {
+        List<DeviceInfo> devices = new ArrayList<>();
+
+        if (devicesJson == null || devicesJson.isEmpty()) {
+            return devices;
+        }
+
+        try {
+            JSONArray jsonArray = JSON.parseArray(devicesJson);
+            for (int i = 0; i < jsonArray.size(); i++) {
+                JSONObject json = jsonArray.getJSONObject(i);
+                DeviceInfo device = DeviceInfo.builder()
+                        .deviceId(json.getString("deviceId"))
+                        .deviceType(deviceType)
+                        .deviceName(json.getString("deviceName"))
+                        .build();
+                devices.add(device);
+            }
+        } catch (Exception e) {
+            log.error("解析设备列表JSON失败,devicesJson={}", devicesJson, e);
+        }
+
+        return devices;
+    }
+}

+ 60 - 0
src/main/java/cn/sciento/farm/automationv2/domain/valueobject/DeviceInfo.java

@@ -0,0 +1,60 @@
+package cn.sciento.farm.automationv2.domain.valueobject;
+
+import cn.sciento.farm.automationv2.domain.enums.AckStatus;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 设备信息值对象
+ * 用于 execution_plan JSON 中记录每个设备的状态
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DeviceInfo {
+
+    /**
+     * 设备ID
+     */
+    private String deviceId;
+
+    /**
+     * 设备类型(SOLENOID_VALVE / BALL_VALVE / PUMP / FERTILIZER)
+     */
+    private String deviceType;
+
+    /**
+     * 设备名称
+     */
+    private String deviceName;
+
+    /**
+     * ACK状态:PENDING / SUCCESS / FAIL / TIMEOUT
+     */
+    private AckStatus ackStatus;
+
+    /**
+     * 已重试次数
+     */
+    private Integer retryCount;
+
+    /**
+     * 失败原因
+     */
+    private String failReason;
+
+    /**
+     * ACK响应时间
+     */
+    private LocalDateTime ackAt;
+
+    /**
+     * 设备参数(如球阀的目标角度)
+     */
+    private Object params;
+}

+ 150 - 0
src/main/java/cn/sciento/farm/automationv2/domain/valueobject/ExecutionNode.java

@@ -0,0 +1,150 @@
+package cn.sciento.farm.automationv2.domain.valueobject;
+
+import cn.sciento.farm.automationv2.domain.enums.NodeType;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 执行节点值对象
+ * 代表 execution_plan 中的一个执行节点
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ExecutionNode {
+
+    /**
+     * 节点顺序索引(从0开始)
+     */
+    private Integer index;
+
+    /**
+     * 节点类型
+     */
+    private NodeType nodeType;
+
+    /**
+     * 可读的节点名称(用于日志和报警展示)
+     */
+    private String nodeName;
+
+    /**
+     * 关联实体ID(灌溉组ID / 水泵ID / 施肥机ID)
+     */
+    private Long refId;
+
+    /**
+     * 节点执行参数(如球阀角度、压力值、施肥程序编号、等待秒数等)
+     */
+    private Map<String, Object> params;
+
+    /**
+     * 节点状态:PENDING / RUNNING / SUCCESS / FAILED
+     */
+    private String status;
+
+    /**
+     * 节点级别已重试次数
+     */
+    private Integer retryCount;
+
+    /**
+     * 最大重试次数(默认3,WAIT节点为0)
+     */
+    private Integer maxRetry;
+
+    /**
+     * 节点开始执行时间
+     */
+    private LocalDateTime startedAt;
+
+    /**
+     * 节点完成时间
+     */
+    private LocalDateTime finishedAt;
+
+    /**
+     * 关联的设备列表
+     */
+    private List<DeviceInfo> devices;
+
+    /**
+     * 获取等待时长(秒)- 仅WAIT节点有效
+     */
+    public Integer getWaitSeconds() {
+        if (nodeType == NodeType.WAIT && params != null) {
+            Object seconds = params.get("seconds");
+            if (seconds instanceof Integer) {
+                return (Integer) seconds;
+            } else if (seconds instanceof String) {
+                return Integer.parseInt((String) seconds);
+            }
+        }
+        return 0;
+    }
+
+    /**
+     * 获取目标压力(kPa)- 仅SET_PUMP_PRESSURE节点有效
+     */
+    public Integer getTargetPressure() {
+        if (nodeType == NodeType.SET_PUMP_PRESSURE && params != null) {
+            Object pressure = params.get("pressureKpa");
+            if (pressure instanceof Integer) {
+                return (Integer) pressure;
+            } else if (pressure instanceof String) {
+                return Integer.parseInt((String) pressure);
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 获取球阀目标角度 - 仅OPEN_GROUP节点有效
+     */
+    public Integer getTargetAngle() {
+        if (nodeType == NodeType.OPEN_GROUP && params != null) {
+            Object angle = params.get("targetAngle");
+            if (angle instanceof Integer) {
+                return (Integer) angle;
+            } else if (angle instanceof String) {
+                return Integer.parseInt((String) angle);
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 是否已完成
+     */
+    public boolean isCompleted() {
+        return "SUCCESS".equals(status) || "FAILED".equals(status);
+    }
+
+    /**
+     * 是否成功
+     */
+    public boolean isSuccess() {
+        return "SUCCESS".equals(status);
+    }
+
+    /**
+     * 是否失败
+     */
+    public boolean isFailed() {
+        return "FAILED".equals(status);
+    }
+
+    /**
+     * 是否正在执行
+     */
+    public boolean isRunning() {
+        return "RUNNING".equals(status);
+    }
+}

+ 107 - 0
src/main/java/cn/sciento/farm/automationv2/domain/valueobject/ExecutionPlan.java

@@ -0,0 +1,107 @@
+package cn.sciento.farm.automationv2.domain.valueobject;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 执行计划值对象
+ * 封装完整的执行节点列表
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ExecutionPlan {
+
+    /**
+     * 有序节点列表
+     */
+    private List<ExecutionNode> nodes;
+
+    /**
+     * 获取指定索引的节点
+     */
+    public ExecutionNode getNode(int index) {
+        if (index < 0 || index >= nodes.size()) {
+            return null;
+        }
+        return nodes.get(index);
+    }
+
+    /**
+     * 获取节点总数
+     */
+    public int getNodeCount() {
+        return nodes != null ? nodes.size() : 0;
+    }
+
+    /**
+     * 是否还有下一个节点
+     */
+    public boolean hasNext(int currentIndex) {
+        return currentIndex + 1 < getNodeCount();
+    }
+
+    /**
+     * 获取下一个节点
+     */
+    public ExecutionNode getNextNode(int currentIndex) {
+        return getNode(currentIndex + 1);
+    }
+
+    /**
+     * 获取已完成的节点列表
+     */
+    public List<ExecutionNode> getCompletedNodes() {
+        if (nodes == null) {
+            return null;
+        }
+        return nodes.stream()
+                .filter(ExecutionNode::isCompleted)
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 获取已成功的节点列表
+     */
+    public List<ExecutionNode> getSuccessNodes() {
+        if (nodes == null) {
+            return null;
+        }
+        return nodes.stream()
+                .filter(ExecutionNode::isSuccess)
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 获取失败的节点列表
+     */
+    public List<ExecutionNode> getFailedNodes() {
+        if (nodes == null) {
+            return null;
+        }
+        return nodes.stream()
+                .filter(ExecutionNode::isFailed)
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 计算预计总时长(分钟)
+     */
+    public int calculateExpectedDurationMinutes() {
+        if (nodes == null) {
+            return 0;
+        }
+        // 累加所有WAIT节点的等待时长,并转换为分钟
+        int totalSeconds = nodes.stream()
+                .filter(node -> node.getNodeType() != null)
+                .mapToInt(ExecutionNode::getWaitSeconds)
+                .sum();
+        return (totalSeconds / 60) + 5; // 额外加5分钟作为设备响应和切换时间
+    }
+}

+ 76 - 0
src/main/java/cn/sciento/farm/automationv2/domain/valueobject/ZoneConfigView.java

@@ -0,0 +1,76 @@
+package cn.sciento.farm.automationv2.domain.valueobject;
+
+import cn.sciento.farm.automationv2.domain.entity.IrrigationGroup;
+import cn.sciento.farm.automationv2.domain.entity.TaskGroupConfig;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 灌区配置视图对象
+ * 合并灌溉组模板(设备列表)和任务级配置(灌溉时长)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ZoneConfigView {
+
+    /**
+     * 灌溉组ID
+     */
+    private Long groupId;
+
+    /**
+     * 灌溉组名称
+     */
+    private String groupName;
+
+    /**
+     * 执行顺序
+     */
+    private Integer sortOrder;
+
+    /**
+     * 分区压力(kPa)- 来自灌溉组模板
+     */
+    private Integer zonePressureKpa;
+
+    /**
+     * 电磁阀列表(JSON格式)- 来自灌溉组模板
+     */
+    private String solenoidValves;
+
+    /**
+     * 球阀列表(JSON格式)- 来自灌溉组模板
+     */
+    private String ballValves;
+
+    /**
+     * 灌溉时长(分钟)- 来自任务级配置
+     */
+    private Integer irrigationDurationMinutes;
+
+    /**
+     * 从灌溉组和任务配置合并创建
+     */
+    public static ZoneConfigView from(IrrigationGroup group, TaskGroupConfig config) {
+        return ZoneConfigView.builder()
+                .groupId(group.getId())
+                .groupName(group.getGroupName())
+                .sortOrder(config.getSortOrder())
+                .zonePressureKpa(group.getZonePressureKpa())
+                .solenoidValves(group.getSolenoidValves())
+                .ballValves(group.getBallValves())
+                .irrigationDurationMinutes(config.getIrrigationDurationMinutes())
+                .build();
+    }
+
+    /**
+     * 获取灌溉时长(秒)
+     */
+    public int getIrrigationDurationSeconds() {
+        return irrigationDurationMinutes != null ? irrigationDurationMinutes * 60 : 0;
+    }
+}

+ 103 - 0
src/main/java/cn/sciento/farm/automationv2/infra/feign/SmsServiceClient.java

@@ -0,0 +1,103 @@
+package cn.sciento.farm.automationv2.infra.feign;
+
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+
+import java.util.Map;
+
+/**
+ * 短信服务Feign客户端
+ * 功能:调用远程短信服务发送通知
+ */
+@FeignClient(name = "sms-service", url = "${sms.service.url:http://localhost:8081}")
+public interface SmsServiceClient {
+
+    /**
+     * 发送短信通知
+     *
+     * @param request 短信请求参数
+     * @return 发送结果
+     */
+    @PostMapping("/api/sms/send")
+    Map<String, Object> sendSms(@RequestBody SmsRequest request);
+
+    /**
+     * 短信请求参数
+     */
+    class SmsRequest {
+        /**
+         * 租户ID
+         */
+        private Long tenantId;
+
+        /**
+         * 接收手机号(多个用逗号分隔)
+         */
+        private String phoneNumbers;
+
+        /**
+         * 短信模板编码
+         */
+        private String templateCode;
+
+        /**
+         * 短信内容(如果不使用模板)
+         */
+        private String content;
+
+        /**
+         * 模板参数(JSON格式)
+         */
+        private Map<String, Object> templateParams;
+
+        public SmsRequest() {
+        }
+
+        public SmsRequest(Long tenantId, String phoneNumbers, String content) {
+            this.tenantId = tenantId;
+            this.phoneNumbers = phoneNumbers;
+            this.content = content;
+        }
+
+        public Long getTenantId() {
+            return tenantId;
+        }
+
+        public void setTenantId(Long tenantId) {
+            this.tenantId = tenantId;
+        }
+
+        public String getPhoneNumbers() {
+            return phoneNumbers;
+        }
+
+        public void setPhoneNumbers(String phoneNumbers) {
+            this.phoneNumbers = phoneNumbers;
+        }
+
+        public String getTemplateCode() {
+            return templateCode;
+        }
+
+        public void setTemplateCode(String templateCode) {
+            this.templateCode = templateCode;
+        }
+
+        public String getContent() {
+            return content;
+        }
+
+        public void setContent(String content) {
+            this.content = content;
+        }
+
+        public Map<String, Object> getTemplateParams() {
+            return templateParams;
+        }
+
+        public void setTemplateParams(Map<String, Object> templateParams) {
+            this.templateParams = templateParams;
+        }
+    }
+}

+ 73 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mq/consumer/AckTimeoutConsumer.java

@@ -0,0 +1,73 @@
+package cn.sciento.farm.automationv2.infra.mq.consumer;
+
+import cn.sciento.farm.automationv2.app.service.TaskExecutionEngine;
+import cn.sciento.farm.automationv2.infra.mq.message.AckTimeoutMessage;
+import cn.sciento.farm.automationv2.infra.redis.IdempotencyManager;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
+import org.apache.rocketmq.spring.core.RocketMQListener;
+import org.springframework.stereotype.Component;
+
+/**
+ * ACK超时兜底消息消费者
+ * 功能:作为最终超时保障,触发节点失败处理
+ *
+ * 处理逻辑:
+ * 1. 检查是否已推进或已处理超时(幂等)
+ * 2. 检查ACK状态,如果仍有设备未响应,触发节点失败
+ * 3. 标记超时已处理,防止重复处理
+ *
+ * 幂等性保障:
+ * - 使用Redis SET NX标记超时已处理
+ * - 如果节点已推进,不处理超时
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+@RocketMQMessageListener(
+        topic = "IRRIGATION_ACK_TIMEOUT",
+        consumerGroup = "irrigation-ack-timeout-consumer"
+)
+public class AckTimeoutConsumer implements RocketMQListener<AckTimeoutMessage> {
+
+    private final IdempotencyManager idempotencyManager;
+    private final TaskExecutionEngine taskExecutionEngine;
+
+    @Override
+    public void onMessage(AckTimeoutMessage message) {
+        Long executionId = message.getExecutionId();
+        Integer nodeIndex = message.getNodeIndex();
+
+        log.info("收到ACK超时兜底消息,executionId={}, nodeIndex={}, messageId={}",
+                executionId, nodeIndex, message.getMessageId());
+
+        // 幂等性检查1:如果节点已推进,忽略超时消息
+        if (idempotencyManager.isProceed(executionId, nodeIndex)) {
+            log.info("节点已推进,忽略超时消息,executionId={}, nodeIndex={}, messageId={}",
+                    executionId, nodeIndex, message.getMessageId());
+            return;
+        }
+
+        // 幂等性检查2:如果超时已处理,忽略重复消息
+        boolean canHandle = idempotencyManager.markTimeoutHandled(executionId, nodeIndex);
+        if (!canHandle) {
+            log.warn("超时已被处理,忽略重复消息,executionId={}, nodeIndex={}, messageId={}",
+                    executionId, nodeIndex, message.getMessageId());
+            return;
+        }
+
+        try {
+            taskExecutionEngine.handleAckTimeout(executionId, nodeIndex);
+
+            log.info("ACK超时处理完成,executionId={}, nodeIndex={}, messageId={}",
+                    executionId, nodeIndex, message.getMessageId());
+
+        } catch (Exception e) {
+            log.error("处理ACK超时消息失败,executionId={}, nodeIndex={}, messageId={}",
+                    executionId, nodeIndex, message.getMessageId(), e);
+            // 消息消费失败,RocketMQ会自动重试
+            throw new RuntimeException("处理ACK超时消息失败", e);
+        }
+    }
+}

+ 64 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mq/consumer/CheckAckConsumer.java

@@ -0,0 +1,64 @@
+package cn.sciento.farm.automationv2.infra.mq.consumer;
+
+import cn.sciento.farm.automationv2.app.service.TaskExecutionEngine;
+import cn.sciento.farm.automationv2.infra.mq.message.CheckAckMessage;
+import cn.sciento.farm.automationv2.infra.redis.IdempotencyManager;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
+import org.apache.rocketmq.spring.core.RocketMQListener;
+import org.springframework.stereotype.Component;
+
+/**
+ * 检查ACK状态消息消费者
+ * 功能:定期检查设备ACK状态,决定重试、推进或等待
+ *
+ * 处理逻辑:
+ * 1. 检查是否已推进(幂等)
+ * 2. 检查ACK状态(全成功/部分失败/全部PENDING)
+ * 3. 全成功 → 标记推进 + 发送NEXT_NODE消息
+ * 4. 部分失败 → 触发重试(最多3次)
+ * 5. 全部PENDING → 继续发送CHECK_ACK消息
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+@RocketMQMessageListener(
+        topic = "IRRIGATION_CHECK_ACK",
+        consumerGroup = "irrigation-check-ack-consumer"
+)
+public class CheckAckConsumer implements RocketMQListener<CheckAckMessage> {
+
+    private final IdempotencyManager idempotencyManager;
+    private final TaskExecutionEngine taskExecutionEngine;
+
+    @Override
+    public void onMessage(CheckAckMessage message) {
+        Long executionId = message.getExecutionId();
+        Integer nodeIndex = message.getNodeIndex();
+        Integer retryCount = message.getRetryCount();
+
+        log.info("收到检查ACK消息,executionId={}, nodeIndex={}, retryCount={}, messageId={}",
+                executionId, nodeIndex, retryCount, message.getMessageId());
+
+        // 幂等性检查:如果节点已推进,忽略CHECK_ACK消息
+        if (idempotencyManager.isProceed(executionId, nodeIndex)) {
+            log.info("节点已推进,忽略CHECK_ACK消息,executionId={}, nodeIndex={}, messageId={}",
+                    executionId, nodeIndex, message.getMessageId());
+            return; // 幂等,不重复处理
+        }
+
+        try {
+            taskExecutionEngine.checkAckAndProceed(executionId, nodeIndex);
+
+            log.info("检查ACK处理完成,executionId={}, nodeIndex={}, messageId={}",
+                    executionId, nodeIndex, message.getMessageId());
+
+        } catch (Exception e) {
+            log.error("处理检查ACK消息失败,executionId={}, nodeIndex={}, messageId={}",
+                    executionId, nodeIndex, message.getMessageId(), e);
+            // 消息消费失败,RocketMQ会自动重试
+            throw new RuntimeException("处理检查ACK消息失败", e);
+        }
+    }
+}

+ 74 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mq/consumer/DeviceAckConsumer.java

@@ -0,0 +1,74 @@
+package cn.sciento.farm.automationv2.infra.mq.consumer;
+
+import cn.sciento.farm.automationv2.domain.enums.AckStatus;
+import cn.sciento.farm.automationv2.infra.mq.message.DeviceAckMessage;
+import cn.sciento.farm.automationv2.infra.redis.AckManager;
+import com.alibaba.fastjson.JSON;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
+import org.apache.rocketmq.spring.core.RocketMQListener;
+import org.springframework.stereotype.Component;
+
+/**
+ * 设备ACK响应消息消费者
+ * 功能:消费设备工程推送的ACK响应消息,更新Redis中的ACK状态
+ *
+ * Topic: DEVICE_ACK
+ * 消息格式:DeviceAckMessage
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+@RocketMQMessageListener(
+    topic = "DEVICE_ACK",
+    consumerGroup = "irrigation-device-ack-consumer",
+    selectorExpression = "*" // 接收所有Tag的消息
+)
+public class DeviceAckConsumer implements RocketMQListener<DeviceAckMessage> {
+
+    private final AckManager ackManager;
+
+    @Override
+    public void onMessage(DeviceAckMessage message) {
+        try {
+            log.info("收到设备ACK响应,message={}", JSON.toJSONString(message));
+
+            // 参数校验
+            if (message == null || message.getExecutionId() == null
+                    || message.getNodeIndex() == null
+                    || message.getDeviceId() == null
+                    || message.getAckStatus() == null) {
+                log.warn("设备ACK响应消息参数不完整,忽略处理,message={}", JSON.toJSONString(message));
+                return;
+            }
+
+            // 解析ACK状态
+            AckStatus ackStatus;
+            try {
+                ackStatus = AckStatus.fromCode(message.getAckStatus());
+            } catch (Exception e) {
+                log.error("无效的ACK状态码:{}", message.getAckStatus());
+                ackStatus = AckStatus.FAIL;
+            }
+
+            // 更新Redis中的ACK状态
+            ackManager.updateAckStatus(
+                message.getExecutionId(),
+                message.getNodeIndex(),
+                message.getDeviceId(),
+                ackStatus,
+                message.getFailReason()
+            );
+
+            log.info("设备ACK状态更新成功,executionId={}, nodeIndex={}, deviceId={}, ackStatus={}",
+                    message.getExecutionId(), message.getNodeIndex(),
+                    message.getDeviceId(), ackStatus);
+
+        } catch (Exception e) {
+            log.error("处理设备ACK响应失败,message={}", JSON.toJSONString(message), e);
+            // 注意:不抛出异常,避免消息重复消费
+            // ACK超时机制会兜底处理
+        }
+    }
+}

+ 62 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mq/consumer/NextNodeConsumer.java

@@ -0,0 +1,62 @@
+package cn.sciento.farm.automationv2.infra.mq.consumer;
+
+import cn.sciento.farm.automationv2.app.service.TaskExecutionEngine;
+import cn.sciento.farm.automationv2.infra.mq.message.NextNodeMessage;
+import cn.sciento.farm.automationv2.infra.redis.IdempotencyManager;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
+import org.apache.rocketmq.spring.core.RocketMQListener;
+import org.springframework.stereotype.Component;
+
+/**
+ * 推进下一节点消息消费者
+ * 功能:接收推进消息,执行下一个节点
+ *
+ * 幂等性保障:
+ * 1. 使用Redis SET NX原子操作,确保只有一个消费者能成功标记推进
+ * 2. 推进前检查标记,已推进的节点不重复执行
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+@RocketMQMessageListener(
+        topic = "IRRIGATION_NEXT_NODE",
+        consumerGroup = "irrigation-next-node-consumer"
+)
+public class NextNodeConsumer implements RocketMQListener<NextNodeMessage> {
+
+    private final IdempotencyManager idempotencyManager;
+    private final TaskExecutionEngine taskExecutionEngine;
+
+    @Override
+    public void onMessage(NextNodeMessage message) {
+        Long executionId = message.getExecutionId();
+        Integer currentNodeIndex = message.getCurrentNodeIndex();
+
+        log.info("收到推进下一节点消息,executionId={}, currentNodeIndex={}, messageId={}",
+                executionId, currentNodeIndex, message.getMessageId());
+
+        // 幂等性检查:尝试标记节点已推进
+        boolean canProceed = idempotencyManager.markProceed(executionId, currentNodeIndex);
+
+        if (!canProceed) {
+            log.warn("节点已被推进,忽略重复消息,executionId={}, currentNodeIndex={}, messageId={}",
+                    executionId, currentNodeIndex, message.getMessageId());
+            return; // 幂等,不重复处理
+        }
+
+        try {
+            taskExecutionEngine.proceedToNextNode(executionId);
+
+            log.info("推进下一节点处理完成,executionId={}, currentNodeIndex={}, messageId={}",
+                    executionId, currentNodeIndex, message.getMessageId());
+
+        } catch (Exception e) {
+            log.error("处理推进下一节点消息失败,executionId={}, currentNodeIndex={}, messageId={}",
+                    executionId, currentNodeIndex, message.getMessageId(), e);
+            // 消息消费失败,RocketMQ会自动重试
+            throw new RuntimeException("处理推进下一节点消息失败", e);
+        }
+    }
+}

+ 44 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mq/consumer/TaskStartConsumer.java

@@ -0,0 +1,44 @@
+package cn.sciento.farm.automationv2.infra.mq.consumer;
+
+import cn.sciento.farm.automationv2.app.service.TaskExecutionEngine;
+import cn.sciento.farm.automationv2.infra.mq.message.TaskStartMessage;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
+import org.apache.rocketmq.spring.core.RocketMQListener;
+import org.springframework.stereotype.Component;
+
+/**
+ * 任务启动消息消费者
+ * 功能:接收任务启动消息,开始执行第一个节点
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+@RocketMQMessageListener(
+        topic = "IRRIGATION_TASK_START",
+        consumerGroup = "irrigation-task-start-consumer"
+)
+public class TaskStartConsumer implements RocketMQListener<TaskStartMessage> {
+
+    private final TaskExecutionEngine taskExecutionEngine;
+
+    @Override
+    public void onMessage(TaskStartMessage message) {
+        log.info("收到任务启动消息,executionId={}, taskId={}, messageId={}",
+                message.getExecutionId(), message.getTaskId(), message.getMessageId());
+
+        try {
+            taskExecutionEngine.executeCurrentNode(message.getExecutionId());
+
+            log.info("任务启动处理完成,executionId={}, messageId={}",
+                    message.getExecutionId(), message.getMessageId());
+
+        } catch (Exception e) {
+            log.error("处理任务启动消息失败,executionId={}, messageId={}",
+                    message.getExecutionId(), message.getMessageId(), e);
+            // 消息消费失败,RocketMQ会自动重试
+            throw new RuntimeException("处理任务启动消息失败", e);
+        }
+    }
+}

+ 41 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mq/message/AckTimeoutMessage.java

@@ -0,0 +1,41 @@
+package cn.sciento.farm.automationv2.infra.mq.message;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+
+/**
+ * ACK超时兜底消息
+ * 功能:作为最终超时保障,触发节点失败处理
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AckTimeoutMessage implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 执行实例ID
+     */
+    private Long executionId;
+
+    /**
+     * 节点索引
+     */
+    private Integer nodeIndex;
+
+    /**
+     * 消息ID(用于幂等)
+     */
+    private String messageId;
+
+    /**
+     * 消息发送时间戳
+     */
+    private Long timestamp;
+}

+ 46 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mq/message/CheckAckMessage.java

@@ -0,0 +1,46 @@
+package cn.sciento.farm.automationv2.infra.mq.message;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+
+/**
+ * 检查ACK状态消息
+ * 功能:定期检查设备ACK状态,决定重试、推进或等待
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CheckAckMessage implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 执行实例ID
+     */
+    private Long executionId;
+
+    /**
+     * 节点索引
+     */
+    private Integer nodeIndex;
+
+    /**
+     * 重试次数(用于计算下次延迟)
+     */
+    private Integer retryCount;
+
+    /**
+     * 消息ID(用于幂等)
+     */
+    private String messageId;
+
+    /**
+     * 消息发送时间戳
+     */
+    private Long timestamp;
+}

+ 66 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mq/message/DeviceAckMessage.java

@@ -0,0 +1,66 @@
+package cn.sciento.farm.automationv2.infra.mq.message;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+
+/**
+ * 设备ACK响应消息(设备工程推送)
+ * Topic: DEVICE_ACK
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DeviceAckMessage implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 执行实例ID
+     */
+    private Long executionId;
+
+    /**
+     * 节点索引
+     */
+    private Integer nodeIndex;
+
+    /**
+     * 设备ID
+     */
+    private String deviceId;
+
+    /**
+     * 设备类型
+     */
+    private String deviceType;
+
+    /**
+     * ACK状态:SUCCESS / FAIL / TIMEOUT
+     */
+    private String ackStatus;
+
+    /**
+     * 失败原因(仅当ackStatus=FAIL时有值)
+     */
+    private String failReason;
+
+    /**
+     * 租户ID
+     */
+    private Long tenantId;
+
+    /**
+     * 消息ID(对应指令消息的messageId)
+     */
+    private String messageId;
+
+    /**
+     * 响应时间戳
+     */
+    private Long timestamp;
+}

+ 67 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mq/message/DeviceCommandMessage.java

@@ -0,0 +1,67 @@
+package cn.sciento.farm.automationv2.infra.mq.message;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+import java.util.Map;
+
+/**
+ * 设备指令消息(发送给设备工程)
+ * Topic: DEVICE_COMMAND
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DeviceCommandMessage implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 执行实例ID
+     */
+    private Long executionId;
+
+    /**
+     * 节点索引
+     */
+    private Integer nodeIndex;
+
+    /**
+     * 设备ID
+     */
+    private String deviceId;
+
+    /**
+     * 设备类型:SOLENOID_VALVE / BALL_VALVE / PUMP / FERTILIZER
+     */
+    private String deviceType;
+
+    /**
+     * 指令类型:OPEN / CLOSE / START / STOP / SET_PRESSURE / START_FERTILIZER / STOP_FERTILIZER
+     */
+    private String commandType;
+
+    /**
+     * 指令参数(如球阀角度、压力值、施肥程序编号等)
+     */
+    private Map<String, Object> params;
+
+    /**
+     * 租户ID
+     */
+    private Long tenantId;
+
+    /**
+     * 消息ID(幂等用)
+     */
+    private String messageId;
+
+    /**
+     * 发送时间戳
+     */
+    private Long timestamp;
+}

+ 41 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mq/message/NextNodeMessage.java

@@ -0,0 +1,41 @@
+package cn.sciento.farm.automationv2.infra.mq.message;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+
+/**
+ * 推进到下一节点消息
+ * 功能:在等待时长结束后,推进到下一个节点执行
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class NextNodeMessage implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 执行实例ID
+     */
+    private Long executionId;
+
+    /**
+     * 当前节点索引
+     */
+    private Integer currentNodeIndex;
+
+    /**
+     * 消息ID(用于幂等)
+     */
+    private String messageId;
+
+    /**
+     * 消息发送时间戳
+     */
+    private Long timestamp;
+}

+ 46 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mq/message/TaskStartMessage.java

@@ -0,0 +1,46 @@
+package cn.sciento.farm.automationv2.infra.mq.message;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+
+/**
+ * 任务启动消息
+ * 功能:触发任务执行的第一个节点
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TaskStartMessage implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 执行实例ID
+     */
+    private Long executionId;
+
+    /**
+     * 任务ID
+     */
+    private Long taskId;
+
+    /**
+     * 租户ID
+     */
+    private Long tenantId;
+
+    /**
+     * 消息ID(用于幂等)
+     */
+    private String messageId;
+
+    /**
+     * 消息发送时间戳
+     */
+    private Long timestamp;
+}

+ 234 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mq/producer/DeviceCommandProducer.java

@@ -0,0 +1,234 @@
+package cn.sciento.farm.automationv2.infra.mq.producer;
+
+import cn.sciento.farm.automationv2.domain.valueobject.DeviceInfo;
+import cn.sciento.farm.automationv2.infra.mq.message.DeviceCommandMessage;
+import com.alibaba.fastjson.JSON;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.spring.core.RocketMQTemplate;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 设备指令消息生产者
+ * 功能:发送设备指令消息到RocketMQ,由设备工程消费并执行
+ *
+ * Topic: DEVICE_COMMAND
+ * 消息格式:DeviceCommandMessage
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DeviceCommandProducer {
+
+    private final RocketMQTemplate rocketMQTemplate;
+
+    /**
+     * 设备指令 Topic
+     */
+    private static final String DEVICE_COMMAND_TOPIC = "DEVICE_COMMAND";
+
+    /**
+     * 发送开启电磁阀指令
+     */
+    public void sendOpenSolenoidValveCommand(Long executionId, Integer nodeIndex,
+                                            DeviceInfo device, Long tenantId) {
+        DeviceCommandMessage message = DeviceCommandMessage.builder()
+                .executionId(executionId)
+                .nodeIndex(nodeIndex)
+                .deviceId(device.getDeviceId())
+                .deviceType(device.getDeviceType())
+                .commandType("OPEN")
+                .params(null)
+                .tenantId(tenantId)
+                .messageId(generateMessageId())
+                .timestamp(System.currentTimeMillis())
+                .build();
+
+        sendCommand(message);
+    }
+
+    /**
+     * 发送关闭电磁阀指令
+     */
+    public void sendCloseSolenoidValveCommand(Long executionId, Integer nodeIndex,
+                                             DeviceInfo device, Long tenantId) {
+        DeviceCommandMessage message = DeviceCommandMessage.builder()
+                .executionId(executionId)
+                .nodeIndex(nodeIndex)
+                .deviceId(device.getDeviceId())
+                .deviceType(device.getDeviceType())
+                .commandType("CLOSE")
+                .params(null)
+                .tenantId(tenantId)
+                .messageId(generateMessageId())
+                .timestamp(System.currentTimeMillis())
+                .build();
+
+        sendCommand(message);
+    }
+
+    /**
+     * 发送球阀到目标角度指令
+     */
+    public void sendBallValveAngleCommand(Long executionId, Integer nodeIndex,
+                                         DeviceInfo device, Integer targetAngle, Long tenantId) {
+        Map<String, Object> params = createSingleParamMap("targetAngle", targetAngle);
+
+        DeviceCommandMessage message = DeviceCommandMessage.builder()
+                .executionId(executionId)
+                .nodeIndex(nodeIndex)
+                .deviceId(device.getDeviceId())
+                .deviceType(device.getDeviceType())
+                .commandType("SET_ANGLE")
+                .params(params)
+                .tenantId(tenantId)
+                .messageId(generateMessageId())
+                .timestamp(System.currentTimeMillis())
+                .build();
+
+        sendCommand(message);
+    }
+
+    /**
+     * 发送启动水泵指令
+     */
+    public void sendStartPumpCommand(Long executionId, Integer nodeIndex,
+                                    String pumpId, Long tenantId) {
+        DeviceCommandMessage message = DeviceCommandMessage.builder()
+                .executionId(executionId)
+                .nodeIndex(nodeIndex)
+                .deviceId(pumpId)
+                .deviceType("PUMP")
+                .commandType("START")
+                .params(null)
+                .tenantId(tenantId)
+                .messageId(generateMessageId())
+                .timestamp(System.currentTimeMillis())
+                .build();
+
+        sendCommand(message);
+    }
+
+    /**
+     * 发送关闭水泵指令
+     */
+    public void sendStopPumpCommand(Long executionId, Integer nodeIndex,
+                                   String pumpId, Long tenantId) {
+        DeviceCommandMessage message = DeviceCommandMessage.builder()
+                .executionId(executionId)
+                .nodeIndex(nodeIndex)
+                .deviceId(pumpId)
+                .deviceType("PUMP")
+                .commandType("STOP")
+                .params(null)
+                .tenantId(tenantId)
+                .messageId(generateMessageId())
+                .timestamp(System.currentTimeMillis())
+                .build();
+
+        sendCommand(message);
+    }
+
+    /**
+     * 发送设置水泵压力指令
+     */
+    public void sendSetPumpPressureCommand(Long executionId, Integer nodeIndex,
+                                          String pumpId, Integer pressureKpa, Long tenantId) {
+        Map<String, Object> params = createSingleParamMap("pressureKpa", pressureKpa);
+
+        DeviceCommandMessage message = DeviceCommandMessage.builder()
+                .executionId(executionId)
+                .nodeIndex(nodeIndex)
+                .deviceId(pumpId)
+                .deviceType("PUMP")
+                .commandType("SET_PRESSURE")
+                .params(params)
+                .tenantId(tenantId)
+                .messageId(generateMessageId())
+                .timestamp(System.currentTimeMillis())
+                .build();
+
+        sendCommand(message);
+    }
+
+    /**
+     * 发送启动施肥机指令
+     */
+    public void sendStartFertilizerCommand(Long executionId, Integer nodeIndex,
+                                          String fertilizerId, Integer programNo, Long tenantId) {
+        Map<String, Object> params = createSingleParamMap("programNo", programNo);
+
+        DeviceCommandMessage message = DeviceCommandMessage.builder()
+                .executionId(executionId)
+                .nodeIndex(nodeIndex)
+                .deviceId(fertilizerId)
+                .deviceType("FERTILIZER")
+                .commandType("START")
+                .params(params)
+                .tenantId(tenantId)
+                .messageId(generateMessageId())
+                .timestamp(System.currentTimeMillis())
+                .build();
+
+        sendCommand(message);
+    }
+
+    /**
+     * 发送关闭施肥机指令
+     */
+    public void sendStopFertilizerCommand(Long executionId, Integer nodeIndex,
+                                         String fertilizerId, Long tenantId) {
+        DeviceCommandMessage message = DeviceCommandMessage.builder()
+                .executionId(executionId)
+                .nodeIndex(nodeIndex)
+                .deviceId(fertilizerId)
+                .deviceType("FERTILIZER")
+                .commandType("STOP")
+                .params(null)
+                .tenantId(tenantId)
+                .messageId(generateMessageId())
+                .timestamp(System.currentTimeMillis())
+                .build();
+
+        sendCommand(message);
+    }
+
+    /**
+     * 发送指令消息
+     */
+    private void sendCommand(DeviceCommandMessage message) {
+        try {
+            // Tag使用租户ID,方便设备工程按租户过滤
+            String tag = "TENANT_" + message.getTenantId();
+            String destination = DEVICE_COMMAND_TOPIC + ":" + tag;
+
+            rocketMQTemplate.syncSend(destination, message);
+
+            log.info("发送设备指令成功,executionId={}, nodeIndex={}, deviceId={}, commandType={}, messageId={}",
+                    message.getExecutionId(), message.getNodeIndex(), message.getDeviceId(),
+                    message.getCommandType(), message.getMessageId());
+        } catch (Exception e) {
+            log.error("发送设备指令失败,message={}", JSON.toJSONString(message), e);
+            throw new RuntimeException("发送设备指令失败", e);
+        }
+    }
+
+    /**
+     * 生成唯一消息ID
+     */
+    private String generateMessageId() {
+        return UUID.randomUUID().toString().replace("-", "");
+    }
+
+    /**
+     * 创建单参数Map(Java 8兼容)
+     */
+    private Map<String, Object> createSingleParamMap(String key, Object value) {
+        Map<String, Object> map = new java.util.HashMap<>();
+        map.put(key, value);
+        return map;
+    }
+}

+ 200 - 0
src/main/java/cn/sciento/farm/automationv2/infra/mq/producer/FlowControlProducer.java

@@ -0,0 +1,200 @@
+package cn.sciento.farm.automationv2.infra.mq.producer;
+
+import cn.sciento.farm.automationv2.infra.mq.message.AckTimeoutMessage;
+import cn.sciento.farm.automationv2.infra.mq.message.CheckAckMessage;
+import cn.sciento.farm.automationv2.infra.mq.message.NextNodeMessage;
+import cn.sciento.farm.automationv2.infra.mq.message.TaskStartMessage;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.client.producer.SendResult;
+import org.apache.rocketmq.spring.core.RocketMQTemplate;
+import org.springframework.messaging.Message;
+import org.springframework.messaging.support.MessageBuilder;
+import org.springframework.stereotype.Component;
+
+import java.util.UUID;
+
+/**
+ * 流程控制消息生产者
+ * 功能:发送任务执行流程控制的延迟消息
+ *
+ * 延迟级别说明(RocketMQ延迟级别):
+ * - Level 3 = 10秒(CHECK_ACK)
+ * - Level 4 = 30秒(ACK_TIMEOUT)
+ * - 自定义分钟级延迟(NEXT_NODE - WAIT节点)
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class FlowControlProducer {
+
+    private final RocketMQTemplate rocketMQTemplate;
+
+    /**
+     * Topic定义
+     */
+    private static final String TOPIC_TASK_START = "IRRIGATION_TASK_START";
+    private static final String TOPIC_NEXT_NODE = "IRRIGATION_NEXT_NODE";
+    private static final String TOPIC_CHECK_ACK = "IRRIGATION_CHECK_ACK";
+    private static final String TOPIC_ACK_TIMEOUT = "IRRIGATION_ACK_TIMEOUT";
+
+    /**
+     * 发送任务启动消息
+     * 说明:任务被触发时立即发送,不延迟
+     */
+    public void sendTaskStart(Long executionId, Long taskId, Long tenantId) {
+        TaskStartMessage message = TaskStartMessage.builder()
+                .executionId(executionId)
+                .taskId(taskId)
+                .tenantId(tenantId)
+                .messageId(UUID.randomUUID().toString())
+                .timestamp(System.currentTimeMillis())
+                .build();
+
+        String destination = TOPIC_TASK_START;
+
+        try {
+            SendResult sendResult = rocketMQTemplate.syncSend(destination, message);
+            log.info("发送任务启动消息成功,executionId={}, messageId={}, sendResult={}",
+                    executionId, message.getMessageId(), sendResult.getMsgId());
+        } catch (Exception e) {
+            log.error("发送任务启动消息失败,executionId={}, messageId={}",
+                    executionId, message.getMessageId(), e);
+            throw new RuntimeException("发送任务启动消息失败", e);
+        }
+    }
+
+    /**
+     * 发送推进到下一节点消息
+     * 说明:在WAIT节点等待时长后发送,支持分钟级延迟
+     *
+     * @param delayMinutes 延迟分钟数(0表示立即发送)
+     */
+    public void sendNextNode(Long executionId, Integer currentNodeIndex, int delayMinutes) {
+        NextNodeMessage message = NextNodeMessage.builder()
+                .executionId(executionId)
+                .currentNodeIndex(currentNodeIndex)
+                .messageId(UUID.randomUUID().toString())
+                .timestamp(System.currentTimeMillis())
+                .build();
+
+        String destination = TOPIC_NEXT_NODE;
+
+        try {
+            SendResult sendResult;
+            if (delayMinutes == 0) {
+                // 立即发送
+                sendResult = rocketMQTemplate.syncSend(destination, message);
+            } else {
+                // 延迟发送(分钟级)
+                // RocketMQ延迟级别最大18(2小时),超过需要使用自定义延迟
+                int delaySeconds = delayMinutes * 60;
+                int delayLevel = calculateDelayLevel(delaySeconds);
+
+                Message<NextNodeMessage> msg = MessageBuilder.withPayload(message).build();
+                sendResult = rocketMQTemplate.syncSend(destination, msg, 3000, delayLevel);
+            }
+
+            log.info("发送推进下一节点消息成功,executionId={}, currentNodeIndex={}, delayMinutes={}, messageId={}, sendResult={}",
+                    executionId, currentNodeIndex, delayMinutes, message.getMessageId(), sendResult.getMsgId());
+        } catch (Exception e) {
+            log.error("发送推进下一节点消息失败,executionId={}, currentNodeIndex={}, delayMinutes={}, messageId={}",
+                    executionId, currentNodeIndex, delayMinutes, message.getMessageId(), e);
+            throw new RuntimeException("发送推进下一节点消息失败", e);
+        }
+    }
+
+    /**
+     * 发送检查ACK状态消息
+     * 说明:节点执行后定期检查设备ACK状态
+     *
+     * @param delaySeconds 延迟秒数(0/5/10/15)
+     */
+    public void sendCheckAck(Long executionId, Integer nodeIndex, Integer retryCount, int delaySeconds) {
+        CheckAckMessage message = CheckAckMessage.builder()
+                .executionId(executionId)
+                .nodeIndex(nodeIndex)
+                .retryCount(retryCount)
+                .messageId(UUID.randomUUID().toString())
+                .timestamp(System.currentTimeMillis())
+                .build();
+
+        String destination = TOPIC_CHECK_ACK;
+
+        try {
+            SendResult sendResult;
+            if (delaySeconds == 0) {
+                // 立即发送
+                sendResult = rocketMQTemplate.syncSend(destination, message);
+            } else {
+                // 延迟发送
+                int delayLevel = calculateDelayLevel(delaySeconds);
+                Message<CheckAckMessage> msg = MessageBuilder.withPayload(message).build();
+                sendResult = rocketMQTemplate.syncSend(destination, msg, 3000, delayLevel);
+            }
+
+            log.info("发送检查ACK消息成功,executionId={}, nodeIndex={}, delaySeconds={}, messageId={}, sendResult={}",
+                    executionId, nodeIndex, delaySeconds, message.getMessageId(), sendResult.getMsgId());
+        } catch (Exception e) {
+            log.error("发送检查ACK消息失败,executionId={}, nodeIndex={}, delaySeconds={}, messageId={}",
+                    executionId, nodeIndex, delaySeconds, message.getMessageId(), e);
+            throw new RuntimeException("发送检查ACK消息失败", e);
+        }
+    }
+
+    /**
+     * 发送ACK超时兜底消息
+     * 说明:作为最终超时保障,固定30秒延迟
+     */
+    public void sendAckTimeout(Long executionId, Integer nodeIndex) {
+        AckTimeoutMessage message = AckTimeoutMessage.builder()
+                .executionId(executionId)
+                .nodeIndex(nodeIndex)
+                .messageId(UUID.randomUUID().toString())
+                .timestamp(System.currentTimeMillis())
+                .build();
+
+        String destination = TOPIC_ACK_TIMEOUT;
+
+        try {
+            // 固定30秒延迟(Level 4)
+            Message<AckTimeoutMessage> msg = MessageBuilder.withPayload(message).build();
+            SendResult sendResult = rocketMQTemplate.syncSend(destination, msg, 3000, 4);
+
+            log.info("发送ACK超时兜底消息成功,executionId={}, nodeIndex={}, messageId={}, sendResult={}",
+                    executionId, nodeIndex, message.getMessageId(), sendResult.getMsgId());
+        } catch (Exception e) {
+            log.error("发送ACK超时兜底消息失败,executionId={}, nodeIndex={}, messageId={}",
+                    executionId, nodeIndex, message.getMessageId(), e);
+            throw new RuntimeException("发送ACK超时兜底消息失败", e);
+        }
+    }
+
+    /**
+     * 计算RocketMQ延迟级别
+     *
+     * RocketMQ延迟级别对照表:
+     * 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
+     * 1  2  3   4   5  6  7  8  9  10 11 12 13 14  15  16  17 18
+     */
+    private int calculateDelayLevel(int delaySeconds) {
+        if (delaySeconds <= 1) return 1;       // 1s
+        if (delaySeconds <= 5) return 2;       // 5s
+        if (delaySeconds <= 10) return 3;      // 10s
+        if (delaySeconds <= 30) return 4;      // 30s
+        if (delaySeconds <= 60) return 5;      // 1m
+        if (delaySeconds <= 120) return 6;     // 2m
+        if (delaySeconds <= 180) return 7;     // 3m
+        if (delaySeconds <= 240) return 8;     // 4m
+        if (delaySeconds <= 300) return 9;     // 5m
+        if (delaySeconds <= 360) return 10;    // 6m
+        if (delaySeconds <= 420) return 11;    // 7m
+        if (delaySeconds <= 480) return 12;    // 8m
+        if (delaySeconds <= 540) return 13;    // 9m
+        if (delaySeconds <= 600) return 14;    // 10m
+        if (delaySeconds <= 1200) return 15;   // 20m
+        if (delaySeconds <= 1800) return 16;   // 30m
+        if (delaySeconds <= 3600) return 17;   // 1h
+        return 18;                             // 2h (最大延迟级别)
+    }
+}

+ 185 - 0
src/main/java/cn/sciento/farm/automationv2/infra/redis/AckManager.java

@@ -0,0 +1,185 @@
+package cn.sciento.farm.automationv2.infra.redis;
+
+import cn.sciento.farm.automationv2.domain.enums.AckStatus;
+import cn.sciento.farm.automationv2.domain.valueobject.DeviceInfo;
+import com.alibaba.fastjson.JSON;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * ACK 管理器(核心组件)
+ * 功能:管理设备ACK状态,存储在Redis中
+ *
+ * Redis Key设计:
+ * - ack:{execution_id}:{node_index}:{device_id} → AckStatus枚举值
+ * - TTL = 5分钟(自动清理过期数据)
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AckManager {
+
+    private final RedisTemplate<String, String> redisTemplate;
+
+    /**
+     * ACK Key 前缀
+     */
+    private static final String ACK_KEY_PREFIX = "ack:";
+
+    /**
+     * ACK 超时时间(秒)
+     */
+    private static final long ACK_TIMEOUT_SECONDS = 300; // 5分钟
+
+    /**
+     * 注册设备ACK期望(初始状态为PENDING)
+     *
+     * @param executionId 执行实例ID
+     * @param nodeIndex   节点索引
+     * @param devices     设备列表
+     */
+    public void registerAck(Long executionId, Integer nodeIndex, List<DeviceInfo> devices) {
+        if (devices == null || devices.isEmpty()) {
+            return;
+        }
+
+        for (DeviceInfo device : devices) {
+            String key = buildAckKey(executionId, nodeIndex, device.getDeviceId());
+            // 设置初始状态为PENDING,并设置过期时间
+            redisTemplate.opsForValue().set(
+                key,
+                AckStatus.PENDING.getCode(),
+                ACK_TIMEOUT_SECONDS,
+                TimeUnit.SECONDS
+            );
+        }
+
+        log.info("注册ACK期望,executionId={}, nodeIndex={}, deviceCount={}",
+                executionId, nodeIndex, devices.size());
+    }
+
+    /**
+     * 更新设备ACK状态
+     *
+     * @param executionId 执行实例ID
+     * @param nodeIndex   节点索引
+     * @param deviceId    设备ID
+     * @param ackStatus   ACK状态
+     * @param failReason  失败原因(可选)
+     */
+    public void updateAckStatus(Long executionId, Integer nodeIndex, String deviceId,
+                               AckStatus ackStatus, String failReason) {
+        String key = buildAckKey(executionId, nodeIndex, deviceId);
+
+        // 检查Key是否存在(防止无效更新)
+        Boolean exists = redisTemplate.hasKey(key);
+        if (Boolean.FALSE.equals(exists)) {
+            log.warn("ACK Key不存在,可能已过期,executionId={}, nodeIndex={}, deviceId={}",
+                    executionId, nodeIndex, deviceId);
+            return;
+        }
+
+        // 更新状态
+        redisTemplate.opsForValue().set(
+            key,
+            ackStatus.getCode(),
+            ACK_TIMEOUT_SECONDS,
+            TimeUnit.SECONDS
+        );
+
+        log.info("更新ACK状态,executionId={}, nodeIndex={}, deviceId={}, status={}, failReason={}",
+                executionId, nodeIndex, deviceId, ackStatus, failReason);
+    }
+
+    /**
+     * 检查所有设备的ACK状态
+     *
+     * @param executionId 执行实例ID
+     * @param nodeIndex   节点索引
+     * @param devices     设备列表
+     * @return Map<deviceId, AckStatus>
+     */
+    public Map<String, AckStatus> checkAckStatus(Long executionId, Integer nodeIndex, List<DeviceInfo> devices) {
+        Map<String, AckStatus> statusMap = new HashMap<>();
+
+        if (devices == null || devices.isEmpty()) {
+            return statusMap;
+        }
+
+        for (DeviceInfo device : devices) {
+            String key = buildAckKey(executionId, nodeIndex, device.getDeviceId());
+            String statusCode = redisTemplate.opsForValue().get(key);
+
+            if (statusCode == null) {
+                // Key不存在,可能已过期,视为TIMEOUT
+                statusMap.put(device.getDeviceId(), AckStatus.TIMEOUT);
+            } else {
+                statusMap.put(device.getDeviceId(), AckStatus.fromCode(statusCode));
+            }
+        }
+
+        return statusMap;
+    }
+
+    /**
+     * 判断是否所有设备ACK都成功
+     */
+    public boolean allSuccess(Map<String, AckStatus> statusMap) {
+        return statusMap.values().stream().allMatch(AckStatus::isSuccess);
+    }
+
+    /**
+     * 判断是否有设备ACK失败(包括FAIL和TIMEOUT)
+     */
+    public boolean hasFailure(Map<String, AckStatus> statusMap) {
+        return statusMap.values().stream().anyMatch(AckStatus::isFailure);
+    }
+
+    /**
+     * 判断是否仍有设备ACK在等待
+     */
+    public boolean hasPending(Map<String, AckStatus> statusMap) {
+        return statusMap.values().stream().anyMatch(AckStatus::isPending);
+    }
+
+    /**
+     * 获取失败的设备列表
+     */
+    public List<String> getFailedDevices(Map<String, AckStatus> statusMap) {
+        return statusMap.entrySet().stream()
+                .filter(entry -> entry.getValue().isFailure())
+                .map(Map.Entry::getKey)
+                .collect(java.util.stream.Collectors.toList());
+    }
+
+    /**
+     * 清除节点的所有ACK状态(节点执行完成后清理)
+     */
+    public void clearAck(Long executionId, Integer nodeIndex, List<DeviceInfo> devices) {
+        if (devices == null || devices.isEmpty()) {
+            return;
+        }
+
+        for (DeviceInfo device : devices) {
+            String key = buildAckKey(executionId, nodeIndex, device.getDeviceId());
+            redisTemplate.delete(key);
+        }
+
+        log.info("清除ACK状态,executionId={}, nodeIndex={}, deviceCount={}",
+                executionId, nodeIndex, devices.size());
+    }
+
+    /**
+     * 构建ACK Redis Key
+     */
+    private String buildAckKey(Long executionId, Integer nodeIndex, String deviceId) {
+        return ACK_KEY_PREFIX + executionId + ":" + nodeIndex + ":" + deviceId;
+    }
+}

+ 128 - 0
src/main/java/cn/sciento/farm/automationv2/infra/redis/IdempotencyManager.java

@@ -0,0 +1,128 @@
+package cn.sciento.farm.automationv2.infra.redis;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 幂等性管理器
+ * 功能:防止消息重复消费导致节点推进重复执行
+ *
+ * Redis Key设计:
+ * - proceed:{execution_id}:{node_index} - 标记节点已推进(TTL=5分钟)
+ * - timeout_handled:{execution_id}:{node_index} - 标记超时已处理(TTL=5分钟)
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class IdempotencyManager {
+
+    private final StringRedisTemplate stringRedisTemplate;
+
+    /**
+     * Key前缀
+     */
+    private static final String PREFIX_PROCEED = "proceed:";
+    private static final String PREFIX_TIMEOUT_HANDLED = "timeout_handled:";
+
+    /**
+     * TTL(秒)
+     */
+    private static final int TTL_SECONDS = 300; // 5分钟
+
+    /**
+     * 尝试标记节点已推进
+     *
+     * @return true-首次标记成功(允许推进),false-已被标记(不允许重复推进)
+     */
+    public boolean markProceed(Long executionId, Integer nodeIndex) {
+        String key = buildProceedKey(executionId, nodeIndex);
+
+        // SET NX 原子操作:key不存在时才设置成功
+        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", TTL_SECONDS, TimeUnit.SECONDS);
+
+        if (Boolean.TRUE.equals(success)) {
+            log.info("标记节点推进成功,executionId={}, nodeIndex={}", executionId, nodeIndex);
+            return true;
+        } else {
+            log.warn("节点已被推进,忽略重复消息,executionId={}, nodeIndex={}", executionId, nodeIndex);
+            return false;
+        }
+    }
+
+    /**
+     * 检查节点是否已推进
+     *
+     * @return true-已推进,false-未推进
+     */
+    public boolean isProceed(Long executionId, Integer nodeIndex) {
+        String key = buildProceedKey(executionId, nodeIndex);
+        Boolean exists = stringRedisTemplate.hasKey(key);
+        return Boolean.TRUE.equals(exists);
+    }
+
+    /**
+     * 尝试标记超时已处理
+     *
+     * @return true-首次标记成功(允许处理),false-已被标记(不允许重复处理)
+     */
+    public boolean markTimeoutHandled(Long executionId, Integer nodeIndex) {
+        String key = buildTimeoutHandledKey(executionId, nodeIndex);
+
+        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", TTL_SECONDS, TimeUnit.SECONDS);
+
+        if (Boolean.TRUE.equals(success)) {
+            log.info("标记超时处理成功,executionId={}, nodeIndex={}", executionId, nodeIndex);
+            return true;
+        } else {
+            log.warn("超时已被处理,忽略重复消息,executionId={}, nodeIndex={}", executionId, nodeIndex);
+            return false;
+        }
+    }
+
+    /**
+     * 检查超时是否已处理
+     *
+     * @return true-已处理,false-未处理
+     */
+    public boolean isTimeoutHandled(Long executionId, Integer nodeIndex) {
+        String key = buildTimeoutHandledKey(executionId, nodeIndex);
+        Boolean exists = stringRedisTemplate.hasKey(key);
+        return Boolean.TRUE.equals(exists);
+    }
+
+    /**
+     * 清理节点推进标记(用于测试或异常恢复)
+     */
+    public void clearProceedMark(Long executionId, Integer nodeIndex) {
+        String key = buildProceedKey(executionId, nodeIndex);
+        stringRedisTemplate.delete(key);
+        log.info("清理节点推进标记,executionId={}, nodeIndex={}", executionId, nodeIndex);
+    }
+
+    /**
+     * 清理超时处理标记(用于测试或异常恢复)
+     */
+    public void clearTimeoutHandledMark(Long executionId, Integer nodeIndex) {
+        String key = buildTimeoutHandledKey(executionId, nodeIndex);
+        stringRedisTemplate.delete(key);
+        log.info("清理超时处理标记,executionId={}, nodeIndex={}", executionId, nodeIndex);
+    }
+
+    /**
+     * 构建推进标记Key
+     */
+    private String buildProceedKey(Long executionId, Integer nodeIndex) {
+        return PREFIX_PROCEED + executionId + ":" + nodeIndex;
+    }
+
+    /**
+     * 构建超时处理标记Key
+     */
+    private String buildTimeoutHandledKey(Long executionId, Integer nodeIndex) {
+        return PREFIX_TIMEOUT_HANDLED + executionId + ":" + nodeIndex;
+    }
+}

+ 78 - 0
src/main/java/cn/sciento/farm/automationv2/infra/repository/AlarmRecordMapper.java

@@ -0,0 +1,78 @@
+package cn.sciento.farm.automationv2.infra.repository;
+
+import cn.sciento.farm.automationv2.domain.entity.AlarmRecord;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 报警记录 Mapper
+ */
+@Mapper
+public interface AlarmRecordMapper {
+
+    /**
+     * 插入报警记录
+     */
+    int insert(AlarmRecord alarm);
+
+    /**
+     * 根据ID查询
+     */
+    AlarmRecord selectById(@Param("id") Long id);
+
+    /**
+     * 根据执行实例ID查询
+     */
+    AlarmRecord selectByExecutionId(@Param("executionId") Long executionId);
+
+    /**
+     * 根据任务ID查询报警记录
+     */
+    List<AlarmRecord> selectByTaskId(@Param("taskId") Long taskId,
+                                     @Param("offset") Integer offset,
+                                     @Param("limit") Integer limit);
+
+    /**
+     * 查询未处理的报警
+     */
+    List<AlarmRecord> selectUnhandledAlarms(@Param("tenantId") Long tenantId,
+                                           @Param("limit") Integer limit);
+
+    /**
+     * 更新报警记录
+     */
+    int updateById(AlarmRecord alarm);
+
+    /**
+     * 标记为已处理
+     */
+    int markAsHandled(@Param("id") Long id,
+                     @Param("handledBy") Long handledBy,
+                     @Param("handleRemark") String handleRemark);
+
+    /**
+     * 更新通知状态
+     */
+    int updateNotifyStatus(@Param("id") Long id,
+                          @Param("notifyStatus") String notifyStatus,
+                          @Param("notifiedChannels") String notifiedChannels);
+
+    /**
+     * 分页查询报警记录
+     */
+    List<AlarmRecord> selectByTenant(@Param("tenantId") Long tenantId,
+                                     @Param("offset") Integer offset,
+                                     @Param("limit") Integer limit);
+
+    /**
+     * 统计未处理报警数
+     */
+    int countUnhandled(@Param("tenantId") Long tenantId);
+
+    /**
+     * 删除报警记录
+     */
+    int deleteById(@Param("id") Long id);
+}

+ 66 - 0
src/main/java/cn/sciento/farm/automationv2/infra/repository/IrrigationGroupMapper.java

@@ -0,0 +1,66 @@
+package cn.sciento.farm.automationv2.infra.repository;
+
+import cn.sciento.farm.automationv2.domain.entity.IrrigationGroup;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 灌溉组 Mapper
+ */
+@Mapper
+public interface IrrigationGroupMapper {
+
+    /**
+     * 插入灌溉组
+     */
+    int insert(IrrigationGroup group);
+
+    /**
+     * 批量插入
+     */
+    int batchInsert(@Param("groups") List<IrrigationGroup> groups);
+
+    /**
+     * 根据ID查询
+     */
+    IrrigationGroup selectById(@Param("id") Long id);
+
+    /**
+     * 根据ID列表批量查询
+     */
+    List<IrrigationGroup> selectByIds(@Param("ids") List<Long> ids);
+
+    /**
+     * 根据任务ID查询所有灌溉组(按sortOrder排序)
+     * @deprecated 灌溉组已独立于任务,使用 TaskGroupConfigMapper + selectByIds 代替
+     */
+    @Deprecated
+    List<IrrigationGroup> selectByTaskId(@Param("taskId") Long taskId);
+
+    /**
+     * 根据任务ID查询启用的灌溉组(按sortOrder排序)
+     */
+    List<IrrigationGroup> selectEnabledByTaskId(@Param("taskId") Long taskId);
+
+    /**
+     * 更新灌溉组
+     */
+    int updateById(IrrigationGroup group);
+
+    /**
+     * 删除灌溉组
+     */
+    int deleteById(@Param("id") Long id);
+
+    /**
+     * 根据任务ID删除所有灌溉组
+     */
+    int deleteByTaskId(@Param("taskId") Long taskId);
+
+    /**
+     * 统计任务的灌溉组数量
+     */
+    int countByTaskId(@Param("taskId") Long taskId);
+}

+ 68 - 0
src/main/java/cn/sciento/farm/automationv2/infra/repository/IrrigationTaskMapper.java

@@ -0,0 +1,68 @@
+package cn.sciento.farm.automationv2.infra.repository;
+
+import cn.sciento.farm.automationv2.domain.entity.IrrigationTask;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 轮灌任务 Mapper
+ */
+@Mapper
+public interface IrrigationTaskMapper {
+
+    /**
+     * 插入任务
+     */
+    int insert(IrrigationTask task);
+
+    /**
+     * 根据ID查询
+     */
+    IrrigationTask selectById(@Param("id") Long id);
+
+    /**
+     * 根据任务编码查询
+     */
+    IrrigationTask selectByCode(@Param("taskCode") String taskCode);
+
+    /**
+     * 查询所有启用的任务
+     */
+    List<IrrigationTask> selectEnabledTasks(@Param("tenantId") Long tenantId);
+
+    /**
+     * 分页查询任务列表
+     */
+    List<IrrigationTask> selectByTenant(@Param("tenantId") Long tenantId,
+                                        @Param("offset") Integer offset,
+                                        @Param("limit") Integer limit);
+
+    /**
+     * 更新任务
+     */
+    int updateById(IrrigationTask task);
+
+    /**
+     * 更新已执行次数(Simple调度用)
+     */
+    int updateExecutedCount(@Param("id") Long id,
+                           @Param("executedCount") Integer executedCount);
+
+    /**
+     * 更新任务状态
+     */
+    int updateStatus(@Param("id") Long id,
+                    @Param("status") String status);
+
+    /**
+     * 逻辑删除
+     */
+    int deleteById(@Param("id") Long id);
+
+    /**
+     * 统计租户的任务数量
+     */
+    int countByTenant(@Param("tenantId") Long tenantId);
+}

+ 72 - 0
src/main/java/cn/sciento/farm/automationv2/infra/repository/LinkageRuleMapper.java

@@ -0,0 +1,72 @@
+package cn.sciento.farm.automationv2.infra.repository;
+
+import cn.sciento.farm.automationv2.domain.entity.LinkageRule;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 联动规则 Mapper
+ */
+@Mapper
+public interface LinkageRuleMapper {
+
+    /**
+     * 插入规则
+     */
+    int insert(LinkageRule rule);
+
+    /**
+     * 根据ID查询
+     */
+    LinkageRule selectById(@Param("id") Long id);
+
+    /**
+     * 根据规则编码查询
+     */
+    LinkageRule selectByCode(@Param("ruleCode") String ruleCode);
+
+    /**
+     * 根据传感器设备ID查询所有启用的规则
+     */
+    List<LinkageRule> selectBySensorId(@Param("sensorDeviceId") String sensorDeviceId);
+
+    /**
+     * 根据传感器设备ID查询所有启用的规则
+     */
+    List<LinkageRule> selectEnabledBySensorId(@Param("sensorDeviceId") String sensorDeviceId);
+
+    /**
+     * 根据任务ID查询规则
+     */
+    List<LinkageRule> selectByTaskId(@Param("taskId") Long taskId);
+
+    /**
+     * 查询所有启用的规则
+     */
+    List<LinkageRule> selectEnabledRules(@Param("tenantId") Long tenantId);
+
+    /**
+     * 更新规则
+     */
+    int updateById(LinkageRule rule);
+
+    /**
+     * 更新触发次数
+     */
+    int updateTriggerCount(@Param("id") Long id,
+                          @Param("triggerCount") Integer triggerCount);
+
+    /**
+     * 删除规则
+     */
+    int deleteById(@Param("id") Long id);
+
+    /**
+     * 分页查询
+     */
+    List<LinkageRule> selectByTenant(@Param("tenantId") Long tenantId,
+                                     @Param("offset") Integer offset,
+                                     @Param("limit") Integer limit);
+}

+ 105 - 0
src/main/java/cn/sciento/farm/automationv2/infra/repository/TaskExecutionMapper.java

@@ -0,0 +1,105 @@
+package cn.sciento.farm.automationv2.infra.repository;
+
+import cn.sciento.farm.automationv2.domain.entity.TaskExecution;
+import cn.sciento.farm.automationv2.domain.enums.ExecutionStatus;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 任务执行实例 Mapper
+ */
+@Mapper
+public interface TaskExecutionMapper {
+
+    /**
+     * 插入执行实例
+     */
+    int insert(TaskExecution execution);
+
+    /**
+     * 根据ID查询
+     */
+    TaskExecution selectById(@Param("id") Long id);
+
+    /**
+     * 根据任务ID查询执行历史(分页)
+     */
+    List<TaskExecution> selectByTaskId(@Param("taskId") Long taskId,
+                                       @Param("offset") Integer offset,
+                                       @Param("limit") Integer limit);
+
+    /**
+     * 根据状态查询
+     */
+    List<TaskExecution> selectByStatus(@Param("status") ExecutionStatus status,
+                                       @Param("limit") Integer limit);
+
+    /**
+     * 根据ID更新(普通更新)
+     */
+    int updateById(TaskExecution execution);
+
+    /**
+     * 乐观锁更新(用于并发控制)
+     * 返回影响行数:1表示成功,0表示版本冲突
+     */
+    int updateByVersion(TaskExecution execution);
+
+    /**
+     * 更新当前执行节点索引(乐观锁)
+     */
+    int updateCurrentIndex(@Param("id") Long id,
+                          @Param("currentIndex") Integer currentIndex,
+                          @Param("version") Integer version);
+
+    /**
+     * 更新execution_plan(乐观锁)
+     */
+    int updateExecutionPlan(@Param("id") Long id,
+                           @Param("executionPlan") String executionPlanJson,
+                           @Param("version") Integer version);
+
+    /**
+     * 更新心跳时间
+     */
+    int updateHeartbeat(@Param("id") Long id);
+
+    /**
+     * 查询卡住的执行实例(看门狗用)
+     * 条件:status=RUNNING 且 last_heartbeat_at 超过阈值时间
+     */
+    List<TaskExecution> selectStuckExecutions(@Param("thresholdMinutes") Integer thresholdMinutes);
+
+    /**
+     * 根据租户ID和状态查询
+     */
+    List<TaskExecution> selectByTenantAndStatus(@Param("tenantId") Long tenantId,
+                                                @Param("status") ExecutionStatus status,
+                                                @Param("offset") Integer offset,
+                                                @Param("limit") Integer limit);
+
+    /**
+     * 根据租户ID查询正在执行的任务
+     */
+    List<TaskExecution> selectRunningByTenant(@Param("tenantId") Long tenantId);
+
+    /**
+     * 根据租户ID分页查询执行历史
+     */
+    List<TaskExecution> selectByTenant(@Param("tenantId") Long tenantId,
+                                       @Param("offset") Integer offset,
+                                       @Param("limit") Integer limit);
+
+    /**
+     * 统计任务的执行次数
+     */
+    int countByTaskId(@Param("taskId") Long taskId);
+
+    /**
+     * 删除(逻辑删除或物理删除根据需求)
+     */
+    int deleteById(@Param("id") Long id);
+}

+ 44 - 0
src/main/java/cn/sciento/farm/automationv2/infra/repository/TaskGroupConfigMapper.java

@@ -0,0 +1,44 @@
+package cn.sciento.farm.automationv2.infra.repository;
+
+import cn.sciento.farm.automationv2.domain.entity.TaskGroupConfig;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 任务灌区配置 Mapper
+ */
+@Mapper
+public interface TaskGroupConfigMapper {
+
+    /**
+     * 插入任务灌区配置
+     */
+    int insert(TaskGroupConfig config);
+
+    /**
+     * 批量插入任务灌区配置
+     */
+    int batchInsert(@Param("configs") List<TaskGroupConfig> configs);
+
+    /**
+     * 根据ID查询
+     */
+    TaskGroupConfig selectById(@Param("id") Long id);
+
+    /**
+     * 根据任务ID查询所有灌区配置(按 sortOrder 排序)
+     */
+    List<TaskGroupConfig> selectByTaskId(@Param("taskId") Long taskId);
+
+    /**
+     * 根据任务ID删除所有配置
+     */
+    int deleteByTaskId(@Param("taskId") Long taskId);
+
+    /**
+     * 更新
+     */
+    int updateById(TaskGroupConfig config);
+}

+ 173 - 0
src/main/resources/application.yml

@@ -0,0 +1,173 @@
+# 应用基本配置
+spring:
+  application:
+    name: wf-equipment-automation-V2
+
+  profiles:
+    active: dev
+
+  # 数据源配置
+  datasource:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    url: jdbc:mysql://localhost:3306/irrigation_automation?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
+    username: root
+    password: root
+    type: com.alibaba.druid.pool.DruidDataSource
+    druid:
+      initial-size: 5
+      min-idle: 5
+      max-active: 20
+      max-wait: 60000
+      validation-query: SELECT 1
+      test-while-idle: true
+      test-on-borrow: false
+      test-on-return: false
+
+  # Redis 配置
+  redis:
+    host: localhost
+    port: 6379
+    password:
+    database: 0
+    timeout: 5000
+    jedis:
+      pool:
+        max-active: 20
+        max-idle: 10
+        min-idle: 5
+        max-wait: 2000
+
+  # Liquibase 数据库迁移
+  liquibase:
+    enabled: true
+    change-log: classpath:db/changelog/db.changelog-master.xml
+    drop-first: false
+
+  # Quartz 调度器配置(集群模式)
+  quartz:
+    job-store-type: jdbc
+    jdbc:
+      initialize-schema: never
+    properties:
+      org:
+        quartz:
+          scheduler:
+            instanceName: IrrigationScheduler
+            instanceId: AUTO
+          jobStore:
+            class: org.quartz.impl.jdbcjobstore.JobStoreTX
+            driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
+            tablePrefix: QRTZ_
+            isClustered: true
+            clusterCheckinInterval: 10000
+            useProperties: false
+          threadPool:
+            class: org.quartz.simpl.SimpleThreadPool
+            threadCount: 20
+            threadPriority: 5
+            threadsInheritContextClassLoaderOfInitializingThread: true
+
+# RocketMQ 配置
+rocketmq:
+  name-server: localhost:9876
+  producer:
+    group: irrigation-producer-group
+    send-message-timeout: 3000
+    compress-message-body-threshold: 4096
+    max-message-size: 4194304
+    retry-times-when-send-failed: 2
+    retry-times-when-send-async-failed: 2
+    retry-next-server: true
+  consumer:
+    group: irrigation-consumer-group
+    consume-thread-min: 10
+    consume-thread-max: 20
+    consume-timeout: 15
+
+# MyBatis 配置
+mybatis:
+  mapper-locations: classpath:mapper/*.xml
+  type-aliases-package: cn.sciento.farm.automationv2.domain.entity
+  configuration:
+    map-underscore-to-camel-case: true
+    cache-enabled: true
+    lazy-loading-enabled: true
+    aggressive-lazy-loading: false
+    log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
+
+# Nacos 服务注册与发现
+cloud:
+  nacos:
+    discovery:
+      server-addr: localhost:8848
+      namespace: public
+      group: DEFAULT_GROUP
+      enabled: true
+      register-enabled: true
+
+# 服务端口
+server:
+  port: 8080
+  servlet:
+    context-path: /irrigation
+
+# 日志配置
+logging:
+  level:
+    root: INFO
+    cn.sciento.farm.automationv2: DEBUG
+    org.apache.rocketmq: WARN
+    org.quartz: WARN
+  pattern:
+    console: '%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{50} - %msg%n'
+
+# 业务配置
+irrigation:
+  # 执行引擎配置
+  execution:
+    ack-timeout-seconds: 30
+    check-ack-delay-seconds: 10
+    max-retry-count: 3
+    retry-delays: 0,5,15
+    heartbeat-interval-seconds: 60
+    stuck-threshold-minutes: 5
+
+  # 安全关闭配置
+  safe-shutdown:
+    device-close-timeout-seconds: 30
+    force-close-on-timeout: false
+
+  # 报警配置
+  alarm:
+    enabled: true
+    channels: SMS
+    sms-enabled: true
+    push-enabled: false
+    websocket-enabled: false
+
+# 短信服务配置
+sms:
+  service:
+    url: http://localhost:8081
+
+# 报警通知配置
+alarm:
+  notification:
+    # 报警接收手机号(多个用逗号分隔)
+    phone-numbers: 13800138000,13900139000
+    sms:
+      enabled: true
+
+# Actuator 监控端点
+management:
+  endpoints:
+    web:
+      exposure:
+        include: health,info,metrics,prometheus
+  endpoint:
+    health:
+      show-details: always
+  metrics:
+    export:
+      prometheus:
+        enabled: true

+ 31 - 0
src/main/resources/db/changelog/db.changelog-master.xml

@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<databaseChangeLog
+        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
+        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
+
+    <!-- Quartz 集群表 -->
+    <include file="db/changelog/v1.0/001-quartz-tables.xml"/>
+
+    <!-- 轮灌任务表 -->
+    <include file="db/changelog/v1.0/002-irrigation-task.xml"/>
+
+    <!-- 灌溉组表 -->
+    <include file="db/changelog/v1.0/003-irrigation-group.xml"/>
+
+    <!-- 任务执行实例表(核心) -->
+    <include file="db/changelog/v1.0/004-task-execution.xml"/>
+
+    <!-- 传感器联动规则表 -->
+    <include file="db/changelog/v1.0/005-linkage-rule.xml"/>
+
+    <!-- 报警记录表 -->
+    <include file="db/changelog/v1.0/006-alarm-record.xml"/>
+
+    <!-- v1.1: 灌溉组独立化改造 -->
+    <include file="db/changelog/v1.1/007-update-irrigation-task.xml"/>
+    <include file="db/changelog/v1.1/008-update-irrigation-group.xml"/>
+    <include file="db/changelog/v1.1/009-create-task-group-config.xml"/>
+
+</databaseChangeLog>

+ 363 - 0
src/main/resources/db/changelog/v1.0/001-quartz-tables.xml

@@ -0,0 +1,363 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<databaseChangeLog
+        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
+        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
+
+    <changeSet id="001-create-quartz-tables" author="system">
+        <comment>创建 Quartz 集群所需的 JDBC 存储表</comment>
+
+        <!-- QRTZ_JOB_DETAILS -->
+        <createTable tableName="QRTZ_JOB_DETAILS">
+            <column name="SCHED_NAME" type="VARCHAR(120)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="JOB_NAME" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="JOB_GROUP" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="DESCRIPTION" type="VARCHAR(250)"/>
+            <column name="JOB_CLASS_NAME" type="VARCHAR(250)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="IS_DURABLE" type="VARCHAR(1)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="IS_NONCONCURRENT" type="VARCHAR(1)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="IS_UPDATE_DATA" type="VARCHAR(1)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="REQUESTS_RECOVERY" type="VARCHAR(1)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="JOB_DATA" type="BLOB"/>
+        </createTable>
+        <addPrimaryKey tableName="QRTZ_JOB_DETAILS" columnNames="SCHED_NAME,JOB_NAME,JOB_GROUP"/>
+
+        <!-- QRTZ_TRIGGERS -->
+        <createTable tableName="QRTZ_TRIGGERS">
+            <column name="SCHED_NAME" type="VARCHAR(120)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TRIGGER_NAME" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TRIGGER_GROUP" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="JOB_NAME" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="JOB_GROUP" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="DESCRIPTION" type="VARCHAR(250)"/>
+            <column name="NEXT_FIRE_TIME" type="BIGINT"/>
+            <column name="PREV_FIRE_TIME" type="BIGINT"/>
+            <column name="PRIORITY" type="INT"/>
+            <column name="TRIGGER_STATE" type="VARCHAR(16)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TRIGGER_TYPE" type="VARCHAR(8)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="START_TIME" type="BIGINT">
+                <constraints nullable="false"/>
+            </column>
+            <column name="END_TIME" type="BIGINT"/>
+            <column name="CALENDAR_NAME" type="VARCHAR(200)"/>
+            <column name="MISFIRE_INSTR" type="SMALLINT"/>
+            <column name="JOB_DATA" type="BLOB"/>
+        </createTable>
+        <addPrimaryKey tableName="QRTZ_TRIGGERS" columnNames="SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP"/>
+        <addForeignKeyConstraint baseTableName="QRTZ_TRIGGERS" baseColumnNames="SCHED_NAME,JOB_NAME,JOB_GROUP"
+                                 constraintName="FK_QRTZ_TRIGGERS_JOB"
+                                 referencedTableName="QRTZ_JOB_DETAILS"
+                                 referencedColumnNames="SCHED_NAME,JOB_NAME,JOB_GROUP"/>
+
+        <!-- QRTZ_SIMPLE_TRIGGERS -->
+        <createTable tableName="QRTZ_SIMPLE_TRIGGERS">
+            <column name="SCHED_NAME" type="VARCHAR(120)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TRIGGER_NAME" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TRIGGER_GROUP" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="REPEAT_COUNT" type="BIGINT">
+                <constraints nullable="false"/>
+            </column>
+            <column name="REPEAT_INTERVAL" type="BIGINT">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TIMES_TRIGGERED" type="BIGINT">
+                <constraints nullable="false"/>
+            </column>
+        </createTable>
+        <addPrimaryKey tableName="QRTZ_SIMPLE_TRIGGERS" columnNames="SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP"/>
+        <addForeignKeyConstraint baseTableName="QRTZ_SIMPLE_TRIGGERS"
+                                 baseColumnNames="SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP"
+                                 constraintName="FK_QRTZ_SIMPLE_TRIGGERS"
+                                 referencedTableName="QRTZ_TRIGGERS"
+                                 referencedColumnNames="SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP"/>
+
+        <!-- QRTZ_CRON_TRIGGERS -->
+        <createTable tableName="QRTZ_CRON_TRIGGERS">
+            <column name="SCHED_NAME" type="VARCHAR(120)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TRIGGER_NAME" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TRIGGER_GROUP" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="CRON_EXPRESSION" type="VARCHAR(120)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TIME_ZONE_ID" type="VARCHAR(80)"/>
+        </createTable>
+        <addPrimaryKey tableName="QRTZ_CRON_TRIGGERS" columnNames="SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP"/>
+        <addForeignKeyConstraint baseTableName="QRTZ_CRON_TRIGGERS"
+                                 baseColumnNames="SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP"
+                                 constraintName="FK_QRTZ_CRON_TRIGGERS"
+                                 referencedTableName="QRTZ_TRIGGERS"
+                                 referencedColumnNames="SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP"/>
+
+        <!-- QRTZ_SIMPROP_TRIGGERS -->
+        <createTable tableName="QRTZ_SIMPROP_TRIGGERS">
+            <column name="SCHED_NAME" type="VARCHAR(120)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TRIGGER_NAME" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TRIGGER_GROUP" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="STR_PROP_1" type="VARCHAR(512)"/>
+            <column name="STR_PROP_2" type="VARCHAR(512)"/>
+            <column name="STR_PROP_3" type="VARCHAR(512)"/>
+            <column name="INT_PROP_1" type="INT"/>
+            <column name="INT_PROP_2" type="INT"/>
+            <column name="LONG_PROP_1" type="BIGINT"/>
+            <column name="LONG_PROP_2" type="BIGINT"/>
+            <column name="DEC_PROP_1" type="NUMERIC(13,4)"/>
+            <column name="DEC_PROP_2" type="NUMERIC(13,4)"/>
+            <column name="BOOL_PROP_1" type="VARCHAR(1)"/>
+            <column name="BOOL_PROP_2" type="VARCHAR(1)"/>
+        </createTable>
+        <addPrimaryKey tableName="QRTZ_SIMPROP_TRIGGERS" columnNames="SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP"/>
+        <addForeignKeyConstraint baseTableName="QRTZ_SIMPROP_TRIGGERS"
+                                 baseColumnNames="SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP"
+                                 constraintName="FK_QRTZ_SIMPROP_TRIGGERS"
+                                 referencedTableName="QRTZ_TRIGGERS"
+                                 referencedColumnNames="SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP"/>
+
+        <!-- QRTZ_BLOB_TRIGGERS -->
+        <createTable tableName="QRTZ_BLOB_TRIGGERS">
+            <column name="SCHED_NAME" type="VARCHAR(120)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TRIGGER_NAME" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TRIGGER_GROUP" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="BLOB_DATA" type="BLOB"/>
+        </createTable>
+        <addPrimaryKey tableName="QRTZ_BLOB_TRIGGERS" columnNames="SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP"/>
+        <addForeignKeyConstraint baseTableName="QRTZ_BLOB_TRIGGERS"
+                                 baseColumnNames="SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP"
+                                 constraintName="FK_QRTZ_BLOB_TRIGGERS"
+                                 referencedTableName="QRTZ_TRIGGERS"
+                                 referencedColumnNames="SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP"/>
+
+        <!-- QRTZ_CALENDARS -->
+        <createTable tableName="QRTZ_CALENDARS">
+            <column name="SCHED_NAME" type="VARCHAR(120)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="CALENDAR_NAME" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="CALENDAR" type="BLOB">
+                <constraints nullable="false"/>
+            </column>
+        </createTable>
+        <addPrimaryKey tableName="QRTZ_CALENDARS" columnNames="SCHED_NAME,CALENDAR_NAME"/>
+
+        <!-- QRTZ_PAUSED_TRIGGER_GRPS -->
+        <createTable tableName="QRTZ_PAUSED_TRIGGER_GRPS">
+            <column name="SCHED_NAME" type="VARCHAR(120)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TRIGGER_GROUP" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+        </createTable>
+        <addPrimaryKey tableName="QRTZ_PAUSED_TRIGGER_GRPS" columnNames="SCHED_NAME,TRIGGER_GROUP"/>
+
+        <!-- QRTZ_FIRED_TRIGGERS -->
+        <createTable tableName="QRTZ_FIRED_TRIGGERS">
+            <column name="SCHED_NAME" type="VARCHAR(120)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="ENTRY_ID" type="VARCHAR(95)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TRIGGER_NAME" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="TRIGGER_GROUP" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="INSTANCE_NAME" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="FIRED_TIME" type="BIGINT">
+                <constraints nullable="false"/>
+            </column>
+            <column name="SCHED_TIME" type="BIGINT">
+                <constraints nullable="false"/>
+            </column>
+            <column name="PRIORITY" type="INT">
+                <constraints nullable="false"/>
+            </column>
+            <column name="STATE" type="VARCHAR(16)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="JOB_NAME" type="VARCHAR(200)"/>
+            <column name="JOB_GROUP" type="VARCHAR(200)"/>
+            <column name="IS_NONCONCURRENT" type="VARCHAR(1)"/>
+            <column name="REQUESTS_RECOVERY" type="VARCHAR(1)"/>
+        </createTable>
+        <addPrimaryKey tableName="QRTZ_FIRED_TRIGGERS" columnNames="SCHED_NAME,ENTRY_ID"/>
+        <createIndex tableName="QRTZ_FIRED_TRIGGERS" indexName="IDX_QRTZ_FT_TRIG_INST_NAME">
+            <column name="SCHED_NAME"/>
+            <column name="INSTANCE_NAME"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_FIRED_TRIGGERS" indexName="IDX_QRTZ_FT_INST_JOB_REQ_RCVRY">
+            <column name="SCHED_NAME"/>
+            <column name="INSTANCE_NAME"/>
+            <column name="REQUESTS_RECOVERY"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_FIRED_TRIGGERS" indexName="IDX_QRTZ_FT_J_G">
+            <column name="SCHED_NAME"/>
+            <column name="JOB_NAME"/>
+            <column name="JOB_GROUP"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_FIRED_TRIGGERS" indexName="IDX_QRTZ_FT_JG">
+            <column name="SCHED_NAME"/>
+            <column name="JOB_GROUP"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_FIRED_TRIGGERS" indexName="IDX_QRTZ_FT_T_G">
+            <column name="SCHED_NAME"/>
+            <column name="TRIGGER_NAME"/>
+            <column name="TRIGGER_GROUP"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_FIRED_TRIGGERS" indexName="IDX_QRTZ_FT_TG">
+            <column name="SCHED_NAME"/>
+            <column name="TRIGGER_GROUP"/>
+        </createIndex>
+
+        <!-- QRTZ_SCHEDULER_STATE -->
+        <createTable tableName="QRTZ_SCHEDULER_STATE">
+            <column name="SCHED_NAME" type="VARCHAR(120)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="INSTANCE_NAME" type="VARCHAR(200)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="LAST_CHECKIN_TIME" type="BIGINT">
+                <constraints nullable="false"/>
+            </column>
+            <column name="CHECKIN_INTERVAL" type="BIGINT">
+                <constraints nullable="false"/>
+            </column>
+        </createTable>
+        <addPrimaryKey tableName="QRTZ_SCHEDULER_STATE" columnNames="SCHED_NAME,INSTANCE_NAME"/>
+
+        <!-- QRTZ_LOCKS -->
+        <createTable tableName="QRTZ_LOCKS">
+            <column name="SCHED_NAME" type="VARCHAR(120)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="LOCK_NAME" type="VARCHAR(40)">
+                <constraints nullable="false"/>
+            </column>
+        </createTable>
+        <addPrimaryKey tableName="QRTZ_LOCKS" columnNames="SCHED_NAME,LOCK_NAME"/>
+
+        <!-- 索引 -->
+        <createIndex tableName="QRTZ_TRIGGERS" indexName="IDX_QRTZ_T_J">
+            <column name="SCHED_NAME"/>
+            <column name="JOB_NAME"/>
+            <column name="JOB_GROUP"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_TRIGGERS" indexName="IDX_QRTZ_T_JG">
+            <column name="SCHED_NAME"/>
+            <column name="JOB_GROUP"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_TRIGGERS" indexName="IDX_QRTZ_T_C">
+            <column name="SCHED_NAME"/>
+            <column name="CALENDAR_NAME"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_TRIGGERS" indexName="IDX_QRTZ_T_G">
+            <column name="SCHED_NAME"/>
+            <column name="TRIGGER_GROUP"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_TRIGGERS" indexName="IDX_QRTZ_T_STATE">
+            <column name="SCHED_NAME"/>
+            <column name="TRIGGER_STATE"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_TRIGGERS" indexName="IDX_QRTZ_T_N_STATE">
+            <column name="SCHED_NAME"/>
+            <column name="TRIGGER_NAME"/>
+            <column name="TRIGGER_GROUP"/>
+            <column name="TRIGGER_STATE"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_TRIGGERS" indexName="IDX_QRTZ_T_N_G_STATE">
+            <column name="SCHED_NAME"/>
+            <column name="TRIGGER_GROUP"/>
+            <column name="TRIGGER_STATE"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_TRIGGERS" indexName="IDX_QRTZ_T_NEXT_FIRE_TIME">
+            <column name="SCHED_NAME"/>
+            <column name="NEXT_FIRE_TIME"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_TRIGGERS" indexName="IDX_QRTZ_T_NFT_ST">
+            <column name="SCHED_NAME"/>
+            <column name="TRIGGER_STATE"/>
+            <column name="NEXT_FIRE_TIME"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_TRIGGERS" indexName="IDX_QRTZ_T_NFT_MISFIRE">
+            <column name="SCHED_NAME"/>
+            <column name="MISFIRE_INSTR"/>
+            <column name="NEXT_FIRE_TIME"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_TRIGGERS" indexName="IDX_QRTZ_T_NFT_ST_MISFIRE">
+            <column name="SCHED_NAME"/>
+            <column name="MISFIRE_INSTR"/>
+            <column name="NEXT_FIRE_TIME"/>
+            <column name="TRIGGER_STATE"/>
+        </createIndex>
+        <createIndex tableName="QRTZ_TRIGGERS" indexName="IDX_QRTZ_T_NFT_ST_MISFIRE_GRP">
+            <column name="SCHED_NAME"/>
+            <column name="MISFIRE_INSTR"/>
+            <column name="NEXT_FIRE_TIME"/>
+            <column name="TRIGGER_GROUP"/>
+            <column name="TRIGGER_STATE"/>
+        </createIndex>
+
+    </changeSet>
+
+</databaseChangeLog>

+ 92 - 0
src/main/resources/db/changelog/v1.0/002-irrigation-task.xml

@@ -0,0 +1,92 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<databaseChangeLog
+        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
+        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
+
+    <changeSet id="002-create-irrigation-task-table" author="system">
+        <comment>创建轮灌任务配置表</comment>
+
+        <createTable tableName="irrigation_task" remarks="轮灌任务配置表">
+            <column name="id" type="BIGINT" autoIncrement="true" remarks="任务ID">
+                <constraints primaryKey="true" nullable="false"/>
+            </column>
+            <column name="task_name" type="VARCHAR(100)" remarks="任务名称">
+                <constraints nullable="false"/>
+            </column>
+            <column name="task_code" type="VARCHAR(50)" remarks="任务编码(唯一)">
+                <constraints nullable="false" unique="true"/>
+            </column>
+            <column name="trigger_type" type="VARCHAR(20)" remarks="触发类型:SCHEDULED/LINKAGE/MANUAL">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 定时触发配置 -->
+            <column name="schedule_type" type="VARCHAR(20)" remarks="定时类型:CRON/SIMPLE(仅trigger_type=SCHEDULED时有效)"/>
+            <column name="cron_expression" type="VARCHAR(100)" remarks="Cron表达式(schedule_type=CRON)"/>
+            <column name="start_time" type="DATETIME" remarks="起始时间(schedule_type=SIMPLE)"/>
+            <column name="interval_days" type="INT" remarks="执行间隔天数(schedule_type=SIMPLE)"/>
+            <column name="total_times" type="INT" remarks="执行总次数(schedule_type=SIMPLE)"/>
+            <column name="executed_count" type="INT" defaultValue="0" remarks="已执行次数(schedule_type=SIMPLE)"/>
+
+            <!-- 水泵配置 -->
+            <column name="pump_id" type="VARCHAR(50)" remarks="水泵设备ID">
+                <constraints nullable="false"/>
+            </column>
+            <column name="pump_type" type="VARCHAR(20)" remarks="水泵类型:NORMAL/PRESSURE(普通/恒压)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="target_pressure" type="INT" remarks="目标压力值(kPa,仅恒压水泵需配置)"/>
+
+            <!-- 施肥机配置(可选) -->
+            <column name="fertilizer_id" type="VARCHAR(50)" remarks="施肥机设备ID"/>
+            <column name="fertilizer_program_no" type="INT" remarks="施肥机程序编号"/>
+
+            <!-- 状态与控制 -->
+            <column name="status" type="VARCHAR(20)" defaultValue="ENABLED" remarks="任务状态:ENABLED/DISABLED/DELETED">
+                <constraints nullable="false"/>
+            </column>
+            <column name="enabled" type="TINYINT" defaultValue="1" remarks="是否启用:1启用 0禁用">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 多租户 -->
+            <column name="tenant_id" type="BIGINT" remarks="租户ID">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 审计字段 -->
+            <column name="created_by" type="BIGINT" remarks="创建人ID"/>
+            <column name="created_at" type="DATETIME" defaultValueComputed="CURRENT_TIMESTAMP" remarks="创建时间">
+                <constraints nullable="false"/>
+            </column>
+            <column name="updated_by" type="BIGINT" remarks="更新人ID"/>
+            <column name="updated_at" type="DATETIME" defaultValueComputed="CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" remarks="更新时间">
+                <constraints nullable="false"/>
+            </column>
+            <column name="deleted" type="TINYINT" defaultValue="0" remarks="逻辑删除:0未删除 1已删除">
+                <constraints nullable="false"/>
+            </column>
+        </createTable>
+
+        <!-- 索引 -->
+        <createIndex tableName="irrigation_task" indexName="idx_tenant_id">
+            <column name="tenant_id"/>
+        </createIndex>
+        <createIndex tableName="irrigation_task" indexName="idx_trigger_type">
+            <column name="trigger_type"/>
+        </createIndex>
+        <createIndex tableName="irrigation_task" indexName="idx_status">
+            <column name="status"/>
+        </createIndex>
+        <createIndex tableName="irrigation_task" indexName="idx_enabled">
+            <column name="enabled"/>
+        </createIndex>
+        <createIndex tableName="irrigation_task" indexName="idx_deleted">
+            <column name="deleted"/>
+        </createIndex>
+
+    </changeSet>
+
+</databaseChangeLog>

+ 90 - 0
src/main/resources/db/changelog/v1.0/003-irrigation-group.xml

@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<databaseChangeLog
+        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
+        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
+
+    <changeSet id="003-create-irrigation-group-table" author="system">
+        <comment>创建灌溉组表</comment>
+
+        <createTable tableName="irrigation_group" remarks="灌溉组表">
+            <column name="id" type="BIGINT" autoIncrement="true" remarks="灌溉组ID">
+                <constraints primaryKey="true" nullable="false"/>
+            </column>
+            <column name="task_id" type="BIGINT" remarks="关联的轮灌任务ID">
+                <constraints nullable="false"/>
+            </column>
+            <column name="group_name" type="VARCHAR(100)" remarks="灌溉组名称">
+                <constraints nullable="false"/>
+            </column>
+            <column name="group_code" type="VARCHAR(50)" remarks="灌溉组编码">
+                <constraints nullable="false"/>
+            </column>
+            <column name="sort_order" type="INT" remarks="执行顺序(0开始)">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 设备配置(JSON格式存储设备列表) -->
+            <column name="solenoid_valves" type="JSON" remarks="电磁阀列表(JSON数组):[{deviceId, deviceName}]">
+                <constraints nullable="false"/>
+            </column>
+            <column name="ball_valves" type="JSON" remarks="球阀列表(JSON数组):[{deviceId, deviceName, targetAngle}]">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 灌溉参数 -->
+            <column name="irrigation_duration_minutes" type="INT" remarks="灌溉时长(分钟)">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 状态与控制 -->
+            <column name="enabled" type="TINYINT" defaultValue="1" remarks="是否启用:1启用 0禁用">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 多租户 -->
+            <column name="tenant_id" type="BIGINT" remarks="租户ID">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 审计字段 -->
+            <column name="created_by" type="BIGINT" remarks="创建人ID"/>
+            <column name="created_at" type="DATETIME" defaultValueComputed="CURRENT_TIMESTAMP" remarks="创建时间">
+                <constraints nullable="false"/>
+            </column>
+            <column name="updated_by" type="BIGINT" remarks="更新人ID"/>
+            <column name="updated_at" type="DATETIME" defaultValueComputed="CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" remarks="更新时间">
+                <constraints nullable="false"/>
+            </column>
+            <column name="deleted" type="TINYINT" defaultValue="0" remarks="逻辑删除:0未删除 1已删除">
+                <constraints nullable="false"/>
+            </column>
+        </createTable>
+
+        <!-- 外键 -->
+        <addForeignKeyConstraint baseTableName="irrigation_group"
+                                 baseColumnNames="task_id"
+                                 constraintName="fk_irrigation_group_task"
+                                 referencedTableName="irrigation_task"
+                                 referencedColumnNames="id"
+                                 onDelete="CASCADE"/>
+
+        <!-- 索引 -->
+        <createIndex tableName="irrigation_group" indexName="idx_task_id">
+            <column name="task_id"/>
+        </createIndex>
+        <createIndex tableName="irrigation_group" indexName="idx_task_sort">
+            <column name="task_id"/>
+            <column name="sort_order"/>
+        </createIndex>
+        <createIndex tableName="irrigation_group" indexName="idx_tenant_id">
+            <column name="tenant_id"/>
+        </createIndex>
+        <createIndex tableName="irrigation_group" indexName="idx_deleted">
+            <column name="deleted"/>
+        </createIndex>
+
+    </changeSet>
+
+</databaseChangeLog>

+ 100 - 0
src/main/resources/db/changelog/v1.0/004-task-execution.xml

@@ -0,0 +1,100 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<databaseChangeLog
+        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
+        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
+
+    <changeSet id="004-create-task-execution-table" author="system">
+        <comment>创建任务执行实例表(核心表,含 execution_plan JSON)</comment>
+
+        <createTable tableName="task_execution" remarks="任务执行实例表">
+            <column name="id" type="BIGINT" autoIncrement="true" remarks="执行实例唯一ID">
+                <constraints primaryKey="true" nullable="false"/>
+            </column>
+            <column name="task_id" type="BIGINT" remarks="关联的轮灌任务ID">
+                <constraints nullable="false"/>
+            </column>
+            <column name="task_name" type="VARCHAR(100)" remarks="任务名称快照">
+                <constraints nullable="false"/>
+            </column>
+            <column name="trigger_type" type="VARCHAR(20)" remarks="触发类型:SCHEDULED/LINKAGE/MANUAL">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 执行计划(核心字段) -->
+            <column name="execution_plan" type="JSON" remarks="完整有序节点列表(预生成的execution_plan JSON)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="current_index" type="INT" defaultValue="0" remarks="当前正在执行的节点索引">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 执行状态 -->
+            <column name="status" type="VARCHAR(20)" remarks="执行状态:PENDING/RUNNING/SUCCESS/FAILED/CANCELLED">
+                <constraints nullable="false"/>
+            </column>
+            <column name="version" type="INT" defaultValue="0" remarks="乐观锁版本号,防止并发更新冲突">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 时间字段 -->
+            <column name="started_at" type="DATETIME" remarks="实际开始时间"/>
+            <column name="finished_at" type="DATETIME" remarks="完成时间"/>
+            <column name="expected_finish_at" type="DATETIME" remarks="预计完成时间(含重试顺延后更新,超时判断用)"/>
+            <column name="last_heartbeat_at" type="DATETIME" remarks="最近一次心跳时间(看门狗用)"/>
+
+            <!-- 失败信息 -->
+            <column name="fail_reason" type="TEXT" remarks="失败原因描述"/>
+            <column name="safe_close_status" type="VARCHAR(20)" remarks="安全关闭状态:SUCCESS/PARTIAL/FAILED"/>
+            <column name="safe_close_details" type="JSON" remarks="安全关闭详情(失败设备列表等)"/>
+
+            <!-- 多租户 -->
+            <column name="tenant_id" type="BIGINT" remarks="租户ID">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 审计字段 -->
+            <column name="created_at" type="DATETIME" defaultValueComputed="CURRENT_TIMESTAMP" remarks="创建时间">
+                <constraints nullable="false"/>
+            </column>
+            <column name="updated_at" type="DATETIME" defaultValueComputed="CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" remarks="更新时间">
+                <constraints nullable="false"/>
+            </column>
+        </createTable>
+
+        <!-- 外键 -->
+        <addForeignKeyConstraint baseTableName="task_execution"
+                                 baseColumnNames="task_id"
+                                 constraintName="fk_task_execution_task"
+                                 referencedTableName="irrigation_task"
+                                 referencedColumnNames="id"/>
+
+        <!-- 索引 -->
+        <createIndex tableName="task_execution" indexName="idx_task_id">
+            <column name="task_id"/>
+        </createIndex>
+        <createIndex tableName="task_execution" indexName="idx_status">
+            <column name="status"/>
+        </createIndex>
+        <createIndex tableName="task_execution" indexName="idx_trigger_type">
+            <column name="trigger_type"/>
+        </createIndex>
+        <createIndex tableName="task_execution" indexName="idx_tenant_id">
+            <column name="tenant_id"/>
+        </createIndex>
+        <createIndex tableName="task_execution" indexName="idx_started_at">
+            <column name="started_at"/>
+        </createIndex>
+        <createIndex tableName="task_execution" indexName="idx_heartbeat">
+            <column name="status"/>
+            <column name="last_heartbeat_at"/>
+        </createIndex>
+        <createIndex tableName="task_execution" indexName="idx_execution_node">
+            <column name="id"/>
+            <column name="current_index"/>
+        </createIndex>
+
+    </changeSet>
+
+</databaseChangeLog>

+ 99 - 0
src/main/resources/db/changelog/v1.0/005-linkage-rule.xml

@@ -0,0 +1,99 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<databaseChangeLog
+        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
+        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
+
+    <changeSet id="005-create-linkage-rule-table" author="system">
+        <comment>创建传感器联动规则表</comment>
+
+        <createTable tableName="linkage_rule" remarks="传感器联动规则表">
+            <column name="id" type="BIGINT" autoIncrement="true" remarks="规则ID">
+                <constraints primaryKey="true" nullable="false"/>
+            </column>
+            <column name="rule_name" type="VARCHAR(100)" remarks="规则名称">
+                <constraints nullable="false"/>
+            </column>
+            <column name="rule_code" type="VARCHAR(50)" remarks="规则编码(唯一)">
+                <constraints nullable="false" unique="true"/>
+            </column>
+
+            <!-- 关联任务 -->
+            <column name="task_id" type="BIGINT" remarks="关联的轮灌任务ID">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 传感器条件 -->
+            <column name="sensor_device_id" type="VARCHAR(50)" remarks="监听的传感器设备ID">
+                <constraints nullable="false"/>
+            </column>
+            <column name="sensor_device_name" type="VARCHAR(100)" remarks="传感器设备名称"/>
+            <column name="sensor_data_type" type="VARCHAR(50)" remarks="传感器数据类型(温度、湿度、土壤湿度等)"/>
+            <column name="operator" type="VARCHAR(10)" remarks="比较符:> / >= / < / <= / ==">
+                <constraints nullable="false"/>
+            </column>
+            <column name="threshold" type="DECIMAL(10,2)" remarks="触发条件的数值边界">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 冷却时间 -->
+            <column name="cooldown_minutes" type="INT" remarks="冷却时间(分钟),触发后的静默时间窗口">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 状态与控制 -->
+            <column name="enabled" type="TINYINT" defaultValue="1" remarks="是否启用:1启用 0禁用">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 统计信息 -->
+            <column name="trigger_count" type="INT" defaultValue="0" remarks="已触发次数"/>
+            <column name="last_trigger_at" type="DATETIME" remarks="最后一次触发时间"/>
+
+            <!-- 多租户 -->
+            <column name="tenant_id" type="BIGINT" remarks="租户ID">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 审计字段 -->
+            <column name="created_by" type="BIGINT" remarks="创建人ID"/>
+            <column name="created_at" type="DATETIME" defaultValueComputed="CURRENT_TIMESTAMP" remarks="创建时间">
+                <constraints nullable="false"/>
+            </column>
+            <column name="updated_by" type="BIGINT" remarks="更新人ID"/>
+            <column name="updated_at" type="DATETIME" defaultValueComputed="CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" remarks="更新时间">
+                <constraints nullable="false"/>
+            </column>
+            <column name="deleted" type="TINYINT" defaultValue="0" remarks="逻辑删除:0未删除 1已删除">
+                <constraints nullable="false"/>
+            </column>
+        </createTable>
+
+        <!-- 外键 -->
+        <addForeignKeyConstraint baseTableName="linkage_rule"
+                                 baseColumnNames="task_id"
+                                 constraintName="fk_linkage_rule_task"
+                                 referencedTableName="irrigation_task"
+                                 referencedColumnNames="id"/>
+
+        <!-- 索引 -->
+        <createIndex tableName="linkage_rule" indexName="idx_task_id">
+            <column name="task_id"/>
+        </createIndex>
+        <createIndex tableName="linkage_rule" indexName="idx_sensor_device">
+            <column name="sensor_device_id"/>
+        </createIndex>
+        <createIndex tableName="linkage_rule" indexName="idx_enabled">
+            <column name="enabled"/>
+        </createIndex>
+        <createIndex tableName="linkage_rule" indexName="idx_tenant_id">
+            <column name="tenant_id"/>
+        </createIndex>
+        <createIndex tableName="linkage_rule" indexName="idx_deleted">
+            <column name="deleted"/>
+        </createIndex>
+
+    </changeSet>
+
+</databaseChangeLog>

+ 105 - 0
src/main/resources/db/changelog/v1.0/006-alarm-record.xml

@@ -0,0 +1,105 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<databaseChangeLog
+        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
+        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
+
+    <changeSet id="006-create-alarm-record-table" author="system">
+        <comment>创建报警记录表</comment>
+
+        <createTable tableName="alarm_record" remarks="报警记录表">
+            <column name="id" type="BIGINT" autoIncrement="true" remarks="报警记录唯一ID">
+                <constraints primaryKey="true" nullable="false"/>
+            </column>
+
+            <!-- 关联信息 -->
+            <column name="execution_id" type="BIGINT" remarks="关联的执行实例ID">
+                <constraints nullable="false"/>
+            </column>
+            <column name="task_id" type="BIGINT" remarks="关联的任务ID">
+                <constraints nullable="false"/>
+            </column>
+            <column name="task_name" type="VARCHAR(100)" remarks="任务名称快照"/>
+
+            <!-- 报警信息 -->
+            <column name="alarm_type" type="VARCHAR(50)" remarks="报警类型:DEVICE_FAIL/TIMEOUT/SAFE_CLOSE/SYSTEM_ERROR">
+                <constraints nullable="false"/>
+            </column>
+            <column name="alarm_level" type="VARCHAR(20)" defaultValue="ERROR" remarks="报警级别:INFO/WARN/ERROR/CRITICAL">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 失败详情 -->
+            <column name="failed_node" type="VARCHAR(100)" remarks="失败节点名称"/>
+            <column name="failed_node_index" type="INT" remarks="失败节点索引"/>
+            <column name="fail_reason" type="TEXT" remarks="详细失败原因"/>
+            <column name="failed_devices" type="JSON" remarks="失败设备列表(JSON数组)"/>
+            <column name="success_devices" type="JSON" remarks="成功设备列表(JSON数组)"/>
+
+            <!-- 安全关闭状态 -->
+            <column name="safe_close_status" type="VARCHAR(20)" remarks="安全关闭结果:SUCCESS/PARTIAL/FAILED"/>
+            <column name="safe_close_details" type="JSON" remarks="安全关闭详情"/>
+
+            <!-- 通知状态 -->
+            <column name="notified_channels" type="VARCHAR(100)" remarks="已通知渠道(SMS/APP/WEBSOCKET)"/>
+            <column name="notify_status" type="VARCHAR(20)" defaultValue="PENDING" remarks="通知状态:PENDING/SUCCESS/FAILED"/>
+            <column name="notify_error" type="TEXT" remarks="通知失败原因"/>
+
+            <!-- 处理状态 -->
+            <column name="handled" type="TINYINT" defaultValue="0" remarks="是否已处理:0未处理 1已处理">
+                <constraints nullable="false"/>
+            </column>
+            <column name="handled_by" type="BIGINT" remarks="处理人ID"/>
+            <column name="handled_at" type="DATETIME" remarks="处理时间"/>
+            <column name="handle_remark" type="TEXT" remarks="处理备注"/>
+
+            <!-- 多租户 -->
+            <column name="tenant_id" type="BIGINT" remarks="租户ID">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 审计字段 -->
+            <column name="created_at" type="DATETIME" defaultValueComputed="CURRENT_TIMESTAMP" remarks="报警时间">
+                <constraints nullable="false"/>
+            </column>
+        </createTable>
+
+        <!-- 外键 -->
+        <addForeignKeyConstraint baseTableName="alarm_record"
+                                 baseColumnNames="execution_id"
+                                 constraintName="fk_alarm_record_execution"
+                                 referencedTableName="task_execution"
+                                 referencedColumnNames="id"/>
+        <addForeignKeyConstraint baseTableName="alarm_record"
+                                 baseColumnNames="task_id"
+                                 constraintName="fk_alarm_record_task"
+                                 referencedTableName="irrigation_task"
+                                 referencedColumnNames="id"/>
+
+        <!-- 索引 -->
+        <createIndex tableName="alarm_record" indexName="idx_execution_id">
+            <column name="execution_id"/>
+        </createIndex>
+        <createIndex tableName="alarm_record" indexName="idx_task_id">
+            <column name="task_id"/>
+        </createIndex>
+        <createIndex tableName="alarm_record" indexName="idx_alarm_type">
+            <column name="alarm_type"/>
+        </createIndex>
+        <createIndex tableName="alarm_record" indexName="idx_alarm_level">
+            <column name="alarm_level"/>
+        </createIndex>
+        <createIndex tableName="alarm_record" indexName="idx_handled">
+            <column name="handled"/>
+        </createIndex>
+        <createIndex tableName="alarm_record" indexName="idx_tenant_id">
+            <column name="tenant_id"/>
+        </createIndex>
+        <createIndex tableName="alarm_record" indexName="idx_created_at">
+            <column name="created_at"/>
+        </createIndex>
+
+    </changeSet>
+
+</databaseChangeLog>

+ 47 - 0
src/main/resources/db/changelog/v1.1/007-update-irrigation-task.xml

@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<databaseChangeLog
+        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
+        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
+
+    <changeSet id="007-update-irrigation-task-for-group-independence" author="system">
+        <comment>更新irrigation_task表以支持灌溉组独立存在</comment>
+
+        <!-- 删除旧的水泵字段 -->
+        <dropColumn tableName="irrigation_task" columnName="pump_type"/>
+        <dropColumn tableName="irrigation_task" columnName="target_pressure"/>
+
+        <!-- 删除旧的施肥机字段 -->
+        <dropColumn tableName="irrigation_task" columnName="fertilizer_id"/>
+        <dropColumn tableName="irrigation_task" columnName="fertilizer_program_no"/>
+
+        <!-- 添加新的水泵压力模式字段 -->
+        <addColumn tableName="irrigation_task">
+            <column name="pressure_mode" type="VARCHAR(20)" defaultValue="NONE" remarks="水泵压力模式:NONE/PUMP_UNIFIED/PUMP_ZONE">
+                <constraints nullable="false"/>
+            </column>
+            <column name="target_pressure_kpa" type="INT" remarks="统一目标压力值(kPa,仅PUMP_UNIFIED模式需配置)"/>
+        </addColumn>
+
+        <!-- 添加安全参数字段 -->
+        <addColumn tableName="irrigation_task">
+            <column name="switch_stable_seconds" type="INT" defaultValue="5" remarks="灌区切换稳压等待时间(秒)">
+                <constraints nullable="false"/>
+            </column>
+        </addColumn>
+
+        <!-- 添加施肥机配置字段 -->
+        <addColumn tableName="irrigation_task">
+            <column name="fertilizer_pump_id" type="VARCHAR(50)" remarks="施肥泵设备ID"/>
+            <column name="stir_motor_id" type="VARCHAR(50)" remarks="搅拌电机设备ID"/>
+            <column name="fertilizer_control_mode" type="VARCHAR(20)" remarks="施肥控制模式:TIME/VOLUME"/>
+            <column name="fert_delay_minutes" type="INT" remarks="施肥延迟时间(分钟)"/>
+            <column name="pre_stir_minutes" type="INT" remarks="搅拌提前时长(分钟)"/>
+            <column name="fert_duration_minutes" type="INT" remarks="施肥时长(分钟,TIME模式)"/>
+            <column name="fert_target_liters" type="INT" remarks="施肥目标量(升,VOLUME模式)"/>
+        </addColumn>
+
+    </changeSet>
+
+</databaseChangeLog>

+ 41 - 0
src/main/resources/db/changelog/v1.1/008-update-irrigation-group.xml

@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<databaseChangeLog
+        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
+        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
+
+    <changeSet id="008-update-irrigation-group-for-independence" author="system">
+        <comment>更新irrigation_group表为独立的灌区预配置模板</comment>
+
+        <!-- 删除外键约束 -->
+        <dropForeignKeyConstraint baseTableName="irrigation_group" constraintName="fk_irrigation_group_task"/>
+
+        <!-- 删除与任务关联的字段 -->
+        <dropColumn tableName="irrigation_group" columnName="task_id"/>
+        <dropColumn tableName="irrigation_group" columnName="sort_order"/>
+        <dropColumn tableName="irrigation_group" columnName="irrigation_duration_minutes"/>
+
+        <!-- 添加分区压力字段 -->
+        <addColumn tableName="irrigation_group">
+            <column name="zone_pressure_kpa" type="INT" remarks="本灌区的水泵目标压力(kPa),仅PUMP_ZONE模式生效"/>
+        </addColumn>
+
+        <!-- 更新球阀JSON结构注释(添加targetPressureKpa说明) -->
+        <modifyDataType tableName="irrigation_group"
+                        columnName="ball_valves"
+                        newDataType="JSON"/>
+        <addNotNullConstraint tableName="irrigation_group" columnName="ball_valves"/>
+
+        <!-- 删除旧的task_id索引 -->
+        <dropIndex tableName="irrigation_group" indexName="idx_task_id"/>
+        <dropIndex tableName="irrigation_group" indexName="idx_task_sort"/>
+
+        <!-- 添加新的索引 -->
+        <createIndex tableName="irrigation_group" indexName="idx_group_code">
+            <column name="group_code"/>
+        </createIndex>
+
+    </changeSet>
+
+</databaseChangeLog>

+ 71 - 0
src/main/resources/db/changelog/v1.1/009-create-task-group-config.xml

@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<databaseChangeLog
+        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
+        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
+
+    <changeSet id="009-create-task-group-config-table" author="system">
+        <comment>创建任务灌区配置表(关联任务和灌溉组)</comment>
+
+        <createTable tableName="task_group_config" remarks="任务灌区配置表">
+            <column name="id" type="BIGINT" autoIncrement="true" remarks="配置ID">
+                <constraints primaryKey="true" nullable="false"/>
+            </column>
+            <column name="task_id" type="BIGINT" remarks="任务ID">
+                <constraints nullable="false"/>
+            </column>
+            <column name="group_id" type="BIGINT" remarks="灌溉组ID">
+                <constraints nullable="false"/>
+            </column>
+            <column name="sort_order" type="INT" remarks="执行顺序(0开始,用户配置的灌溉意图顺序)">
+                <constraints nullable="false"/>
+            </column>
+            <column name="irrigation_duration_minutes" type="INT" remarks="灌溉时长(分钟,任务级配置)">
+                <constraints nullable="false"/>
+            </column>
+
+            <!-- 审计字段 -->
+            <column name="created_at" type="DATETIME" defaultValueComputed="CURRENT_TIMESTAMP" remarks="创建时间">
+                <constraints nullable="false"/>
+            </column>
+            <column name="updated_at" type="DATETIME" defaultValueComputed="CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" remarks="更新时间">
+                <constraints nullable="false"/>
+            </column>
+        </createTable>
+
+        <!-- 外键 -->
+        <addForeignKeyConstraint baseTableName="task_group_config"
+                                 baseColumnNames="task_id"
+                                 constraintName="fk_task_group_config_task"
+                                 referencedTableName="irrigation_task"
+                                 referencedColumnNames="id"
+                                 onDelete="CASCADE"/>
+
+        <addForeignKeyConstraint baseTableName="task_group_config"
+                                 baseColumnNames="group_id"
+                                 constraintName="fk_task_group_config_group"
+                                 referencedTableName="irrigation_group"
+                                 referencedColumnNames="id"
+                                 onDelete="RESTRICT"/>
+
+        <!-- 索引 -->
+        <createIndex tableName="task_group_config" indexName="idx_task_id">
+            <column name="task_id"/>
+        </createIndex>
+        <createIndex tableName="task_group_config" indexName="idx_group_id">
+            <column name="group_id"/>
+        </createIndex>
+        <createIndex tableName="task_group_config" indexName="idx_task_sort">
+            <column name="task_id"/>
+            <column name="sort_order"/>
+        </createIndex>
+
+        <!-- 唯一约束:同一任务内的灌区不能重复 -->
+        <addUniqueConstraint tableName="task_group_config"
+                             columnNames="task_id,group_id"
+                             constraintName="uk_task_group"/>
+
+    </changeSet>
+
+</databaseChangeLog>

+ 114 - 0
src/main/resources/mapper/AlarmRecordMapper.xml

@@ -0,0 +1,114 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="cn.sciento.farm.automationv2.infra.repository.AlarmRecordMapper">
+
+    <resultMap id="BaseResultMap" type="cn.sciento.farm.automationv2.domain.entity.AlarmRecord">
+        <id column="id" property="id"/>
+        <result column="execution_id" property="executionId"/>
+        <result column="task_id" property="taskId"/>
+        <result column="task_name" property="taskName"/>
+        <result column="alarm_type" property="alarmType" typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>
+        <result column="alarm_level" property="alarmLevel"/>
+        <result column="failed_node" property="failedNode"/>
+        <result column="failed_node_index" property="failedNodeIndex"/>
+        <result column="fail_reason" property="failReason"/>
+        <result column="failed_devices" property="failedDevices"/>
+        <result column="success_devices" property="successDevices"/>
+        <result column="safe_close_status" property="safeCloseStatus"/>
+        <result column="safe_close_details" property="safeCloseDetails"/>
+        <result column="notified_channels" property="notifiedChannels"/>
+        <result column="notify_status" property="notifyStatus"/>
+        <result column="notify_error" property="notifyError"/>
+        <result column="handled" property="handled"/>
+        <result column="handled_by" property="handledBy"/>
+        <result column="handled_at" property="handledAt"/>
+        <result column="handle_remark" property="handleRemark"/>
+        <result column="tenant_id" property="tenantId"/>
+        <result column="created_at" property="createdAt"/>
+    </resultMap>
+
+    <insert id="insert" parameterType="cn.sciento.farm.automationv2.domain.entity.AlarmRecord" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO alarm_record (
+            execution_id, task_id, task_name, alarm_type, alarm_level,
+            failed_node, failed_node_index, fail_reason, failed_devices, success_devices,
+            safe_close_status, safe_close_details, notified_channels, notify_status,
+            tenant_id, created_at
+        ) VALUES (
+            #{executionId}, #{taskId}, #{taskName}, #{alarmType}, #{alarmLevel},
+            #{failedNode}, #{failedNodeIndex}, #{failReason}, #{failedDevices}, #{successDevices},
+            #{safeCloseStatus}, #{safeCloseDetails}, #{notifiedChannels}, #{notifyStatus},
+            #{tenantId}, NOW()
+        )
+    </insert>
+
+    <select id="selectById" resultMap="BaseResultMap">
+        SELECT * FROM alarm_record WHERE id = #{id}
+    </select>
+
+    <select id="selectByExecutionId" resultMap="BaseResultMap">
+        SELECT * FROM alarm_record WHERE execution_id = #{executionId} LIMIT 1
+    </select>
+
+    <select id="selectByTaskId" resultMap="BaseResultMap">
+        SELECT * FROM alarm_record
+        WHERE task_id = #{taskId}
+        ORDER BY created_at DESC
+        LIMIT #{offset}, #{limit}
+    </select>
+
+    <select id="selectUnhandledAlarms" resultMap="BaseResultMap">
+        SELECT * FROM alarm_record
+        WHERE tenant_id = #{tenantId} AND handled = 0
+        ORDER BY created_at DESC
+        <if test="limit != null">
+            LIMIT #{limit}
+        </if>
+    </select>
+
+    <update id="updateById">
+        UPDATE alarm_record
+        <set>
+            <if test="notifiedChannels != null">notified_channels = #{notifiedChannels},</if>
+            <if test="notifyStatus != null">notify_status = #{notifyStatus},</if>
+            <if test="notifyError != null">notify_error = #{notifyError},</if>
+            <if test="handled != null">handled = #{handled},</if>
+            <if test="handledBy != null">handled_by = #{handledBy},</if>
+            <if test="handledAt != null">handled_at = #{handledAt},</if>
+            <if test="handleRemark != null">handle_remark = #{handleRemark},</if>
+        </set>
+        WHERE id = #{id}
+    </update>
+
+    <update id="markAsHandled">
+        UPDATE alarm_record
+        SET handled = 1,
+            handled_by = #{handledBy},
+            handled_at = NOW(),
+            handle_remark = #{handleRemark}
+        WHERE id = #{id}
+    </update>
+
+    <update id="updateNotifyStatus">
+        UPDATE alarm_record
+        SET notify_status = #{notifyStatus},
+            notified_channels = #{notifiedChannels}
+        WHERE id = #{id}
+    </update>
+
+    <select id="selectByTenant" resultMap="BaseResultMap">
+        SELECT * FROM alarm_record
+        WHERE tenant_id = #{tenantId}
+        ORDER BY created_at DESC
+        LIMIT #{offset}, #{limit}
+    </select>
+
+    <select id="countUnhandled" resultType="int">
+        SELECT COUNT(*) FROM alarm_record
+        WHERE tenant_id = #{tenantId} AND handled = 0
+    </select>
+
+    <delete id="deleteById">
+        DELETE FROM alarm_record WHERE id = #{id}
+    </delete>
+
+</mapper>

+ 94 - 0
src/main/resources/mapper/IrrigationGroupMapper.xml

@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="cn.sciento.farm.automationv2.infra.repository.IrrigationGroupMapper">
+
+    <resultMap id="BaseResultMap" type="cn.sciento.farm.automationv2.domain.entity.IrrigationGroup">
+        <id column="id" property="id"/>
+        <result column="group_name" property="groupName"/>
+        <result column="group_code" property="groupCode"/>
+        <result column="zone_pressure_kpa" property="zonePressureKpa"/>
+        <result column="solenoid_valves" property="solenoidValves"/>
+        <result column="ball_valves" property="ballValves"/>
+        <result column="enabled" property="enabled"/>
+        <result column="tenant_id" property="tenantId"/>
+        <result column="created_by" property="createdBy"/>
+        <result column="created_at" property="createdAt"/>
+        <result column="updated_by" property="updatedBy"/>
+        <result column="updated_at" property="updatedAt"/>
+        <result column="deleted" property="deleted"/>
+    </resultMap>
+
+    <insert id="insert" parameterType="cn.sciento.farm.automationv2.domain.entity.IrrigationGroup" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO irrigation_group (
+            group_name, group_code, zone_pressure_kpa, solenoid_valves,
+            ball_valves, enabled, tenant_id, created_by, created_at
+        ) VALUES (
+            #{groupName}, #{groupCode}, #{zonePressureKpa}, #{solenoidValves},
+            #{ballValves}, #{enabled}, #{tenantId}, #{createdBy}, NOW()
+        )
+    </insert>
+
+    <insert id="batchInsert">
+        INSERT INTO irrigation_group (
+            group_name, group_code, zone_pressure_kpa, solenoid_valves,
+            ball_valves, enabled, tenant_id, created_by, created_at
+        ) VALUES
+        <foreach collection="groups" item="group" separator=",">
+            (#{group.groupName}, #{group.groupCode}, #{group.zonePressureKpa}, #{group.solenoidValves},
+             #{group.ballValves}, #{group.enabled}, #{group.tenantId}, #{group.createdBy}, NOW())
+        </foreach>
+    </insert>
+
+    <select id="selectById" resultMap="BaseResultMap">
+        SELECT * FROM irrigation_group WHERE id = #{id} AND deleted = 0
+    </select>
+
+    <select id="selectByIds" resultMap="BaseResultMap">
+        SELECT * FROM irrigation_group
+        WHERE id IN
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+        AND deleted = 0
+    </select>
+
+    <!-- 以下方法已过时,灌溉组已独立于任务 -->
+    <select id="selectByTaskId" resultMap="BaseResultMap">
+        SELECT * FROM irrigation_group
+        WHERE task_id = #{taskId} AND deleted = 0
+        ORDER BY sort_order ASC
+    </select>
+
+    <select id="selectEnabledByTaskId" resultMap="BaseResultMap">
+        SELECT * FROM irrigation_group
+        WHERE task_id = #{taskId} AND enabled = 1 AND deleted = 0
+        ORDER BY sort_order ASC
+    </select>
+
+    <update id="updateById">
+        UPDATE irrigation_group
+        <set>
+            <if test="groupName != null">group_name = #{groupName},</if>
+            <if test="zonePressureKpa != null">zone_pressure_kpa = #{zonePressureKpa},</if>
+            <if test="solenoidValves != null">solenoid_valves = #{solenoidValves},</if>
+            <if test="ballValves != null">ball_valves = #{ballValves},</if>
+            <if test="enabled != null">enabled = #{enabled},</if>
+            updated_by = #{updatedBy},
+            updated_at = NOW()
+        </set>
+        WHERE id = #{id}
+    </update>
+
+    <update id="deleteById">
+        UPDATE irrigation_group SET deleted = 1, updated_at = NOW() WHERE id = #{id}
+    </update>
+
+    <update id="deleteByTaskId">
+        UPDATE irrigation_group SET deleted = 1, updated_at = NOW() WHERE task_id = #{taskId}
+    </update>
+
+    <select id="countByTaskId" resultType="int">
+        SELECT COUNT(*) FROM irrigation_group WHERE task_id = #{taskId} AND deleted = 0
+    </select>
+
+</mapper>

+ 130 - 0
src/main/resources/mapper/IrrigationTaskMapper.xml

@@ -0,0 +1,130 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="cn.sciento.farm.automationv2.infra.repository.IrrigationTaskMapper">
+
+    <!-- ResultMap -->
+    <resultMap id="BaseResultMap" type="cn.sciento.farm.automationv2.domain.entity.IrrigationTask">
+        <id column="id" property="id"/>
+        <result column="task_name" property="taskName"/>
+        <result column="task_code" property="taskCode"/>
+        <result column="trigger_type" property="triggerType" typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>
+        <result column="schedule_type" property="scheduleType" typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>
+        <result column="cron_expression" property="cronExpression"/>
+        <result column="start_time" property="startTime"/>
+        <result column="interval_days" property="intervalDays"/>
+        <result column="total_times" property="totalTimes"/>
+        <result column="executed_count" property="executedCount"/>
+        <result column="pump_id" property="pumpId"/>
+        <result column="pump_type" property="pumpType" typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>
+        <result column="target_pressure" property="targetPressure"/>
+        <result column="fertilizer_id" property="fertilizerId"/>
+        <result column="fertilizer_program_no" property="fertilizerProgramNo"/>
+        <result column="status" property="status" typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>
+        <result column="enabled" property="enabled"/>
+        <result column="tenant_id" property="tenantId"/>
+        <result column="created_by" property="createdBy"/>
+        <result column="created_at" property="createdAt"/>
+        <result column="updated_by" property="updatedBy"/>
+        <result column="updated_at" property="updatedAt"/>
+        <result column="deleted" property="deleted"/>
+    </resultMap>
+
+    <!-- 插入 -->
+    <insert id="insert" parameterType="cn.sciento.farm.automationv2.domain.entity.IrrigationTask" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO irrigation_task (
+            task_name, task_code, trigger_type, schedule_type, cron_expression,
+            start_time, interval_days, total_times, executed_count,
+            pump_id, pump_type, target_pressure, fertilizer_id, fertilizer_program_no,
+            status, enabled, tenant_id, created_by, created_at
+        ) VALUES (
+            #{taskName}, #{taskCode}, #{triggerType}, #{scheduleType}, #{cronExpression},
+            #{startTime}, #{intervalDays}, #{totalTimes}, #{executedCount},
+            #{pumpId}, #{pumpType}, #{targetPressure}, #{fertilizerId}, #{fertilizerProgramNo},
+            #{status}, #{enabled}, #{tenantId}, #{createdBy}, NOW()
+        )
+    </insert>
+
+    <!-- 根据ID查询 -->
+    <select id="selectById" resultMap="BaseResultMap">
+        SELECT * FROM irrigation_task WHERE id = #{id} AND deleted = 0
+    </select>
+
+    <!-- 根据任务编码查询 -->
+    <select id="selectByCode" resultMap="BaseResultMap">
+        SELECT * FROM irrigation_task WHERE task_code = #{taskCode} AND deleted = 0
+    </select>
+
+    <!-- 查询所有启用的任务 -->
+    <select id="selectEnabledTasks" resultMap="BaseResultMap">
+        SELECT * FROM irrigation_task
+        WHERE tenant_id = #{tenantId}
+          AND enabled = 1
+          AND status = 'ENABLED'
+          AND deleted = 0
+        ORDER BY created_at DESC
+    </select>
+
+    <!-- 分页查询 -->
+    <select id="selectByTenant" resultMap="BaseResultMap">
+        SELECT * FROM irrigation_task
+        WHERE tenant_id = #{tenantId} AND deleted = 0
+        ORDER BY created_at DESC
+        LIMIT #{offset}, #{limit}
+    </select>
+
+    <!-- 更新任务 -->
+    <update id="updateById">
+        UPDATE irrigation_task
+        <set>
+            <if test="taskName != null">task_name = #{taskName},</if>
+            <if test="triggerType != null">trigger_type = #{triggerType},</if>
+            <if test="scheduleType != null">schedule_type = #{scheduleType},</if>
+            <if test="cronExpression != null">cron_expression = #{cronExpression},</if>
+            <if test="startTime != null">start_time = #{startTime},</if>
+            <if test="intervalDays != null">interval_days = #{intervalDays},</if>
+            <if test="totalTimes != null">total_times = #{totalTimes},</if>
+            <if test="executedCount != null">executed_count = #{executedCount},</if>
+            <if test="pumpId != null">pump_id = #{pumpId},</if>
+            <if test="pumpType != null">pump_type = #{pumpType},</if>
+            <if test="targetPressure != null">target_pressure = #{targetPressure},</if>
+            <if test="fertilizerId != null">fertilizer_id = #{fertilizerId},</if>
+            <if test="fertilizerProgramNo != null">fertilizer_program_no = #{fertilizerProgramNo},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="enabled != null">enabled = #{enabled},</if>
+            updated_by = #{updatedBy},
+            updated_at = NOW()
+        </set>
+        WHERE id = #{id}
+    </update>
+
+    <!-- 更新已执行次数 -->
+    <update id="updateExecutedCount">
+        UPDATE irrigation_task
+        SET executed_count = #{executedCount},
+            updated_at = NOW()
+        WHERE id = #{id}
+    </update>
+
+    <!-- 更新任务状态 -->
+    <update id="updateStatus">
+        UPDATE irrigation_task
+        SET status = #{status},
+            updated_at = NOW()
+        WHERE id = #{id}
+    </update>
+
+    <!-- 逻辑删除 -->
+    <update id="deleteById">
+        UPDATE irrigation_task
+        SET deleted = 1,
+            updated_at = NOW()
+        WHERE id = #{id}
+    </update>
+
+    <!-- 统计租户的任务数量 -->
+    <select id="countByTenant" resultType="int">
+        SELECT COUNT(*) FROM irrigation_task
+        WHERE tenant_id = #{tenantId} AND deleted = 0
+    </select>
+
+</mapper>

+ 106 - 0
src/main/resources/mapper/LinkageRuleMapper.xml

@@ -0,0 +1,106 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="cn.sciento.farm.automationv2.infra.repository.LinkageRuleMapper">
+
+    <resultMap id="BaseResultMap" type="cn.sciento.farm.automationv2.domain.entity.LinkageRule">
+        <id column="id" property="id"/>
+        <result column="rule_name" property="ruleName"/>
+        <result column="rule_code" property="ruleCode"/>
+        <result column="task_id" property="taskId"/>
+        <result column="sensor_device_id" property="sensorDeviceId"/>
+        <result column="sensor_device_name" property="sensorDeviceName"/>
+        <result column="sensor_data_type" property="sensorDataType"/>
+        <result column="operator" property="operator"/>
+        <result column="threshold" property="threshold"/>
+        <result column="cooldown_minutes" property="cooldownMinutes"/>
+        <result column="enabled" property="enabled"/>
+        <result column="trigger_count" property="triggerCount"/>
+        <result column="last_trigger_at" property="lastTriggerAt"/>
+        <result column="tenant_id" property="tenantId"/>
+        <result column="created_by" property="createdBy"/>
+        <result column="created_at" property="createdAt"/>
+        <result column="updated_by" property="updatedBy"/>
+        <result column="updated_at" property="updatedAt"/>
+        <result column="deleted" property="deleted"/>
+    </resultMap>
+
+    <insert id="insert" parameterType="cn.sciento.farm.automationv2.domain.entity.LinkageRule" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO linkage_rule (
+            rule_name, rule_code, task_id, sensor_device_id, sensor_device_name,
+            sensor_data_type, operator, threshold, cooldown_minutes, enabled,
+            tenant_id, created_by, created_at
+        ) VALUES (
+            #{ruleName}, #{ruleCode}, #{taskId}, #{sensorDeviceId}, #{sensorDeviceName},
+            #{sensorDataType}, #{operator}, #{threshold}, #{cooldownMinutes}, #{enabled},
+            #{tenantId}, #{createdBy}, NOW()
+        )
+    </insert>
+
+    <select id="selectById" resultMap="BaseResultMap">
+        SELECT * FROM linkage_rule WHERE id = #{id} AND deleted = 0
+    </select>
+
+    <select id="selectByCode" resultMap="BaseResultMap">
+        SELECT * FROM linkage_rule WHERE rule_code = #{ruleCode} AND deleted = 0
+    </select>
+
+    <select id="selectBySensorId" resultMap="BaseResultMap">
+        SELECT * FROM linkage_rule
+        WHERE sensor_device_id = #{sensorDeviceId}
+          AND enabled = 1
+          AND deleted = 0
+    </select>
+
+    <select id="selectEnabledBySensorId" resultMap="BaseResultMap">
+        SELECT * FROM linkage_rule
+        WHERE sensor_device_id = #{sensorDeviceId}
+          AND enabled = 1
+          AND deleted = 0
+        ORDER BY created_at ASC
+    </select>
+
+    <select id="selectByTaskId" resultMap="BaseResultMap">
+        SELECT * FROM linkage_rule WHERE task_id = #{taskId} AND deleted = 0
+    </select>
+
+    <select id="selectEnabledRules" resultMap="BaseResultMap">
+        SELECT * FROM linkage_rule
+        WHERE tenant_id = #{tenantId} AND enabled = 1 AND deleted = 0
+        ORDER BY created_at DESC
+    </select>
+
+    <update id="updateById">
+        UPDATE linkage_rule
+        <set>
+            <if test="ruleName != null">rule_name = #{ruleName},</if>
+            <if test="sensorDeviceId != null">sensor_device_id = #{sensorDeviceId},</if>
+            <if test="sensorDeviceName != null">sensor_device_name = #{sensorDeviceName},</if>
+            <if test="operator != null">operator = #{operator},</if>
+            <if test="threshold != null">threshold = #{threshold},</if>
+            <if test="cooldownMinutes != null">cooldown_minutes = #{cooldownMinutes},</if>
+            <if test="enabled != null">enabled = #{enabled},</if>
+            updated_by = #{updatedBy},
+            updated_at = NOW()
+        </set>
+        WHERE id = #{id}
+    </update>
+
+    <update id="updateTriggerCount">
+        UPDATE linkage_rule
+        SET trigger_count = #{triggerCount},
+            last_trigger_at = NOW()
+        WHERE id = #{id}
+    </update>
+
+    <update id="deleteById">
+        UPDATE linkage_rule SET deleted = 1, updated_at = NOW() WHERE id = #{id}
+    </update>
+
+    <select id="selectByTenant" resultMap="BaseResultMap">
+        SELECT * FROM linkage_rule
+        WHERE tenant_id = #{tenantId} AND deleted = 0
+        ORDER BY created_at DESC
+        LIMIT #{offset}, #{limit}
+    </select>
+
+</mapper>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff