From 8669dcd9b0b94e46f83f8c38420055af8fd45d78 Mon Sep 17 00:00:00 2001 From: huang <1724659546@qq.com> Date: Mon, 3 Nov 2025 16:29:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BB=BB=E5=8A=A1=E5=A2=9E?= =?UTF-8?q?=E5=88=A0=E6=94=B9=E6=9F=A5=E6=97=B6=E5=AF=B9=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=85=B3=E8=81=94=E8=A1=A8=E7=9A=84=E7=BB=B4?= =?UTF-8?q?=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../device_task_association_maintenance.md | 111 ++++++++++++++ .../index.md | 2 +- internal/core/component_initializers.go | 9 +- internal/domain/plan/device_id_extractor.go | 1 + internal/domain/plan/plan_service.go | 83 ++++++++-- internal/domain/plan/task.go | 2 + internal/domain/task/task.go | 26 ++++ internal/infra/models/models.go | 1 + .../infra/repository/device_repository.go | 19 ++- internal/infra/repository/plan_repository.go | 143 +++++++++--------- 10 files changed, 312 insertions(+), 85 deletions(-) create mode 100644 design/verification-before-device-deletion/device_task_association_maintenance.md create mode 100644 internal/domain/plan/device_id_extractor.go diff --git a/design/verification-before-device-deletion/device_task_association_maintenance.md b/design/verification-before-device-deletion/device_task_association_maintenance.md new file mode 100644 index 0000000..5c197d9 --- /dev/null +++ b/design/verification-before-device-deletion/device_task_association_maintenance.md @@ -0,0 +1,111 @@ +# 方案:维护设备与任务的关联关系 + +## 1. 目标 + +在对计划(Plan)及其包含的任务(Task)进行创建、更新、删除(CRUD)操作时,同步维护 `device_tasks` 这张多对多关联表。 + +这是实现“删除设备前检查其是否被任务使用”这一需求的基础。 + +## 2. 核心挑战 + +1. **参数结构异构性**:不同类型的任务(`TaskType`),其设备 ID 存储在 `Parameters` (JSON) 字段中的 `key` 和数据结构(单个 ID + 或 ID 数组)各不相同。 +2. **分层架构原则**:解析 `Parameters` 以提取设备 ID 的逻辑属于 **业务规则**,需要找到一个合适的位置来封装它,以维持各层职责的清晰。 + +## 3. 方案设计 + +本方案旨在最大化地复用现有领域模型和逻辑,通过扩展 `TaskFactory` 来实现设备ID的解析,从而保持了各领域模块的高内聚和低耦合。 + +### 3.1. 核心思路:复用领域对象与工厂 + +我们不移动任何结构体,也不在 `plan` 包中引入任何具体任务的实现细节。取而代之,我们利用现有的 `TaskFactory` +和各个任务领域对象自身的能力来解析参数。 + +每个具体的任务领域对象(如 `ReleaseFeedWeightTask`)最了解如何解析自己的 `Parameters`。因此,我们将解析设备ID的责任完全交还给它们。 + +### 3.2. 扩展 `TaskFactory` + +- **动作**:在 `plan.TaskFactory` 接口中增加一个新方法 `CreateTaskFromModel(*models.Task) (TaskDeviceIDResolver, error)`。 +- **目的**:此方法允许我们在非任务执行的场景下(例如,在增删改查计划时),仅根据数据库模型 `models.Task` 来创建一个临时的、轻量级的任务领域对象。 +- **实现**:在 `internal/domain/task/task.go` 的 `taskFactory` 中实现此方法。它会根据传入的 `taskModel.Type`,`switch-case` + 来调用相应的构造函数(如 `NewReleaseFeedWeightTask`)创建实例。 +- **实现**: +- **优势**: + - **高内聚,低耦合**:`plan` 包保持通用,无需了解任何具体任务的参数细节。参数定义和解析逻辑都保留在各自的 `task` 包内。 + - **逻辑复用**:完美复用了您已在 `ReleaseFeedWeightTask` 中实现的 `ResolveDeviceIDs` 方法,避免了重复代码。 + +### 3.3. 调整领域服务层 (`PlanService`) + +`PlanService` 将作为此业务用例的核心编排者。借助 `UnitOfWork` 模式,它可以在单个事务中协调多个仓库,完成数据准备和持久化。 + +- **职责**:在创建或更新计划的业务流程中,负责解析任务参数、准备设备关联数据,并调用仓库层完成持久化。 +- **实现**: + - 向 `planServiceImpl` 注入 `repository.UnitOfWork` 和 `plan.TaskFactory`。 + - 在 `CreatePlan` 和 `UpdatePlan` 方法中,使用 `unitOfWork.ExecuteInTransaction` 来包裹整个操作。 + - 在事务闭包内,遍历计划中的所有任务 (`models.Task`): + 1. 调用 `taskFactory.CreateTaskFromModel(taskModel)` 创建一个临时的任务领域对象。 + 2. 调用该领域对象的 `ResolveDeviceIDs()` 方法获取设备ID列表。 + 3. 使用事务性的 `DeviceRepository` 查询出设备实体。 + 4. 将查询到的设备实体列表填充到 `taskModel.Devices` 字段中。 + - 最后,将填充好关联数据的 `plan` 对象传递给事务性的 `PlanRepository` 进行创建或更新。 +- **优势**: + - **职责清晰**:`PlanService` 完整地拥有了“创建/更新计划”的业务逻辑,而仓库层则回归到纯粹的数据访问职责。 + - **数据一致性**:`UnitOfWork` 确保了从准备数据(查询设备)到最终持久化(创建计划和关联)的所有数据库操作都在一个原子事务中完成。 + +### 3.4. 调整仓库层 (`PlanRepository`) + +仓库层被简化,回归其作为数据持久化网关的纯粹角色。 + +- **职责**:负责 `Plan` 及其直接子对象(`Task`, `SubPlan`)的 CRUD 操作。 +- **实现**: + - `CreatePlan` 和 `UpdatePlanMetadataAndStructure` 方法将被简化。它们不再需要任何特殊的关联处理逻辑(如 `Association().Replace()`)。 + - 只需接收一个由 `PlanService` 准备好的、`task.Devices` 字段已被填充的 `plan` 对象。 + - 在 `CreatePlan` 中,调用 `tx.Create(plan)` 时,GORM 会自动级联创建 `Plan`、`Task` 以及 `device_tasks` 中的关联记录。 + - 在 `UpdatePlanMetadataAndStructure` 的 `reconcileTasks` 逻辑中,对于新创建的任务,GORM 的 `tx.Create(task)` 同样会自动处理其设备关联。 + +### 3.5. 整体流程 + +以 **创建计划** 为例: + +1. `PlanController` 调用 `PlanService.CreatePlan(plan)`。 +2. `PlanService` 调用 `unitOfWork.ExecuteInTransaction` 启动一个数据库事务。 +3. 在事务闭包内,`PlanService` 遍历 `plan` 对象中的所有 `task`。 +4. 对于每一个 `task` 模型,调用 `taskFactory.CreateTaskFromModel(task)` 创建一个临时的领域对象。 +5. 调用该领域对象的 `ResolveDeviceIDs()` 方法,获取其使用的设备 ID 列表。 +6. 如果返回了设备 ID 列表,则使用事务性的 `DeviceRepository` 查询出 `[]models.Device` 实体。 +7. 所有 `task` 的关联数据准备好后,调用事务性的 `PlanRepository.CreatePlan(plan)`。GORM 在创建 `plan` 和 `task` 的同时,会自动创建 + `device_tasks` 表中的关联记录。 +8. `UnitOfWork` 提交事务。 + +**更新计划** 的流程与创建类似,在 `UpdatePlanMetadataAndStructure` 方法中,由于会先删除旧任务再创建新任务,因此在创建新任务后执行相同的设备关联步骤。 + +**删除计划** 时,由于 `Task` 模型上配置了 `OnDelete:CASCADE`,GORM 会自动删除关联的 `Task` 记录。同时,GORM 的多对多删除逻辑会自动清理 +`device_tasks` 表中与被删除任务相关的记录。因此 `DeletePlan` 方法无需修改。 + +## 4. 实施步骤 + +1. **扩展 `TaskFactory` 接口** + - 在 `internal/domain/plan/task.go` 文件中,为 `TaskFactory` 接口添加 + `CreateTaskFromModel(*models.Task) (TaskDeviceIDResolver, error)` 方法。 + +2. **实现 `TaskFactory` 新方法** + - 在 `internal/domain/task/task.go` 文件中,为 `taskFactory` 结构体实现 `CreateTaskFromModel` 方法。 + +3. **修改 `PlanService`** + - 在 `internal/domain/plan/plan_service.go` 中: + - 修改 `planServiceImpl` 结构体,增加 `unitOfWork repository.UnitOfWork` 和 `taskFactory TaskFactory` 字段。 + - 修改 `NewPlanService` 构造函数,接收并注入这些新依赖。 + - 重构 `CreatePlan` 和 `UpdatePlan` 方法,使用 `UnitOfWork` 包裹事务,并在其中实现数据准备和关联逻辑。 + +4. **修改 `PlanRepository`** + - 在 `internal/infra/repository/plan_repository.go` 中: + - **简化 `CreatePlan` 和 `UpdatePlanMetadataAndStructure` 方法**。移除所有手动处理设备关联的代码(例如,如果之前有 `Association("Devices").Replace()` 等调用,则应删除)。 + - 确保这两个方法的核心逻辑就是调用 GORM 的 `Create` 或 `Updates`,信任 GORM 会根据传入模型中已填充的 `Devices` 字段来自动维护多对多关联。 + +5. **修改依赖注入** + - 在 `internal/core/component_initializers.go` (或类似的依赖注入入口文件) 中: + - 将 `unitOfWork` 和 `taskFactory` 实例传递给 `plan.NewPlanService` 的构造函数。 + +## 5. 结论 + +此方案通过复用现有的领域对象和工厂模式,优雅地解决了设备关联维护的问题。它保持了清晰的架构分层和模块职责,在实现功能的同时,为项目未来的扩展和维护奠定了坚实、可扩展的基础。 \ No newline at end of file diff --git a/design/verification-before-device-deletion/index.md b/design/verification-before-device-deletion/index.md index 40215ca..218d354 100644 --- a/design/verification-before-device-deletion/index.md +++ b/design/verification-before-device-deletion/index.md @@ -18,6 +18,6 @@ http://git.huangwc.com/pig/pig-farm-controller/issues/50 2. [让任务可以提供自身使用设备](./add_get_device_id_configs_to_task.md) 3. [现有计划管理逻辑迁移](./plan_service_refactor_to_domain.md) 4. [增加设备任务关联表](./device_task_many_to_many_design.md) -5. [增加设备增删改查时对设备任务关联表的维护]() +5. [增加任务增删改查时对设备任务关联表的维护](./device_task_association_maintenance.md) 6. [删除设备模板时检查]() 7. [删除区域主控时检查]() \ No newline at end of file diff --git a/internal/core/component_initializers.go b/internal/core/component_initializers.go index 2489d18..a04a258 100644 --- a/internal/core/component_initializers.go +++ b/internal/core/component_initializers.go @@ -170,7 +170,14 @@ func initDomainServices(cfg *config.Config, infra *Infrastructure, logger *logs. ) // 计划管理器 - planService := plan.NewPlanService(planExecutionManager, analysisPlanTaskManager, infra.repos.planRepo, logger) + planService := plan.NewPlanService( + planExecutionManager, + analysisPlanTaskManager, + infra.repos.planRepo, + infra.repos.deviceRepo, + infra.repos.unitOfWork, + taskFactory, + logger) return &DomainServices{ pigPenTransferManager: pigPenTransferManager, diff --git a/internal/domain/plan/device_id_extractor.go b/internal/domain/plan/device_id_extractor.go new file mode 100644 index 0000000..ec2d2c6 --- /dev/null +++ b/internal/domain/plan/device_id_extractor.go @@ -0,0 +1 @@ +package plan diff --git a/internal/domain/plan/plan_service.go b/internal/domain/plan/plan_service.go index eeae571..78a310e 100644 --- a/internal/domain/plan/plan_service.go +++ b/internal/domain/plan/plan_service.go @@ -2,6 +2,7 @@ package plan import ( "errors" + "fmt" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" @@ -55,25 +56,31 @@ type Service interface { type planServiceImpl struct { executionManager ExecutionManager taskManager AnalysisPlanTaskManager - planRepo repository.PlanRepository // 新增 - // deviceRepo repository.DeviceRepository // 如果需要,新增 - logger *logs.Logger + planRepo repository.PlanRepository + deviceRepo repository.DeviceRepository + unitOfWork repository.UnitOfWork + taskFactory TaskFactory + logger *logs.Logger } // NewPlanService 创建一个新的 Service 实例。 func NewPlanService( executionManager ExecutionManager, taskManager AnalysisPlanTaskManager, - planRepo repository.PlanRepository, // 新增 - // deviceRepo repository.DeviceRepository, // 如果需要,新增 + planRepo repository.PlanRepository, + deviceRepo repository.DeviceRepository, + unitOfWork repository.UnitOfWork, + taskFactory TaskFactory, logger *logs.Logger, ) Service { return &planServiceImpl{ executionManager: executionManager, taskManager: taskManager, - planRepo: planRepo, // 注入 - // deviceRepo: deviceRepo, // 注入 - logger: logger, + planRepo: planRepo, + deviceRepo: deviceRepo, + unitOfWork: unitOfWork, + taskFactory: taskFactory, + logger: logger, } } @@ -117,13 +124,40 @@ func (s *planServiceImpl) CreatePlan(planToCreate *models.Plan) (*models.Plan, e } planToCreate.ReorderSteps() - // 3. 调用仓库方法创建计划 - if err := s.planRepo.CreatePlan(planToCreate); err != nil { + // 3. 在调用仓库前,准备好所有数据,包括设备关联 + for i := range planToCreate.Tasks { + taskModel := &planToCreate.Tasks[i] + // 使用工厂创建临时领域对象 + taskResolver, err := s.taskFactory.CreateTaskFromModel(taskModel) + if err != nil { + // 如果一个任务类型不支持,我们可以选择跳过或报错 + s.logger.Warnf("跳过为任务类型 '%s' 解析设备ID: %v", taskModel.Type, err) + continue + } + + deviceIDs, err := taskResolver.ResolveDeviceIDs() + if err != nil { + // 在事务外解析失败,直接返回错误 + return nil, fmt.Errorf("为任务 '%s' 提取设备ID失败: %w", taskModel.Name, err) + } + if len(deviceIDs) > 0 { + // 优化:无需查询完整的设备对象,只需构建包含ID的结构体即可建立关联 + devices := make([]models.Device, len(deviceIDs)) + for i, id := range deviceIDs { + devices[i] = models.Device{Model: gorm.Model{ID: id}} + } + taskModel.Devices = devices + } + } + + // 4. 调用仓库方法创建计划,该方法内部会处理事务 + err := s.planRepo.CreatePlan(planToCreate) + if err != nil { s.logger.Errorf("%s: 数据库创建计划失败: %v", actionType, err) return nil, err } - // 4. 创建成功后,调用 manager 确保触发器任务定义存在,但不立即加入待执行队列 + // 5. 创建成功后,调用 manager 确保触发器任务定义存在,但不立即加入待执行队列 if err := s.taskManager.EnsureAnalysisTaskDefinition(planToCreate.ID); err != nil { // 这是一个非阻塞性错误,我们只记录日志,因为主流程(创建计划)已经成功 s.logger.Errorf("为新创建的计划 %d 确保触发器任务定义失败: %v", planToCreate.ID, err) @@ -203,7 +237,32 @@ func (s *planServiceImpl) UpdatePlan(planToUpdate *models.Plan) (*models.Plan, e planToUpdate.ExecuteCount = 0 s.logger.Infof("计划 #%d 被更新,执行计数器已重置为 0。", planToUpdate.ID) - if err := s.planRepo.UpdatePlanMetadataAndStructure(planToUpdate); err != nil { + // 在调用仓库前,准备好所有数据,包括设备关联 + for i := range planToUpdate.Tasks { + taskModel := &planToUpdate.Tasks[i] + taskResolver, err := s.taskFactory.CreateTaskFromModel(taskModel) + if err != nil { + s.logger.Warnf("跳过为任务类型 '%s' 解析设备ID: %v", taskModel.Type, err) + continue + } + + deviceIDs, err := taskResolver.ResolveDeviceIDs() + if err != nil { + return nil, fmt.Errorf("为任务 '%s' 提取设备ID失败: %w", taskModel.Name, err) + } + if len(deviceIDs) > 0 { + // 优化:无需查询完整的设备对象,只需构建包含ID的结构体即可建立关联 + devices := make([]models.Device, len(deviceIDs)) + for i, id := range deviceIDs { + devices[i] = models.Device{Model: gorm.Model{ID: id}} + } + taskModel.Devices = devices + } + } + + // 调用仓库方法更新计划,该方法内部会处理事务 + err = s.planRepo.UpdatePlanMetadataAndStructure(planToUpdate) + if err != nil { s.logger.Errorf("%s: 数据库更新计划失败: %v, Plan: %+v", actionType, err, planToUpdate) return nil, err } diff --git a/internal/domain/plan/task.go b/internal/domain/plan/task.go index 124a1a5..33108cd 100644 --- a/internal/domain/plan/task.go +++ b/internal/domain/plan/task.go @@ -29,4 +29,6 @@ type TaskDeviceIDResolver interface { type TaskFactory interface { // Production 根据指定的任务执行日志创建一个任务实例。 Production(claimedLog *models.TaskExecutionLog) Task + // CreateTaskFromModel 仅根据任务模型创建一个任务实例,用于非执行场景(如参数解析)。 + CreateTaskFromModel(taskModel *models.Task) (TaskDeviceIDResolver, error) } diff --git a/internal/domain/task/task.go b/internal/domain/task/task.go index e2cf98d..d045285 100644 --- a/internal/domain/task/task.go +++ b/internal/domain/task/task.go @@ -1,6 +1,8 @@ package task import ( + "fmt" + "git.huangwc.com/pig/pig-farm-controller/internal/domain/device" "git.huangwc.com/pig/pig-farm-controller/internal/domain/plan" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" @@ -43,3 +45,27 @@ func (t *taskFactory) Production(claimedLog *models.TaskExecutionLog) plan.Task panic("不支持的任务类型") // 显式panic防编译器报错 } } + +// CreateTaskFromModel 实现了 TaskFactory 接口,用于从模型创建任务实例。 +func (t *taskFactory) CreateTaskFromModel(taskModel *models.Task) (plan.TaskDeviceIDResolver, error) { + // 这个方法不关心 claimedLog 的其他字段,所以可以构造一个临时的 + // 它只用于访问那些不依赖于执行日志的方法,比如 ResolveDeviceIDs + tempLog := &models.TaskExecutionLog{Task: *taskModel} + + switch taskModel.Type { + case models.TaskTypeWaiting: + return NewDelayTask(t.logger, tempLog), nil + case models.TaskTypeReleaseFeedWeight: + return NewReleaseFeedWeightTask( + tempLog, + t.sensorDataRepo, + t.deviceRepo, + t.deviceService, + t.logger, + ), nil + case models.TaskTypeFullCollection: + return NewFullCollectionTask(tempLog, t.deviceRepo, t.deviceService, t.logger), nil + default: + return nil, fmt.Errorf("不支持为类型 '%s' 的任务创建模型实例", taskModel.Type) + } +} diff --git a/internal/infra/models/models.go b/internal/infra/models/models.go index 43b0edb..3d360a4 100644 --- a/internal/infra/models/models.go +++ b/internal/infra/models/models.go @@ -22,6 +22,7 @@ func GetAllModels() []interface{} { &DeviceTemplate{}, &SensorData{}, &DeviceCommandLog{}, + &DeviceTask{}, // Plan & Task Models &Plan{}, diff --git a/internal/infra/repository/device_repository.go b/internal/infra/repository/device_repository.go index 96104cb..bb0d2f3 100644 --- a/internal/infra/repository/device_repository.go +++ b/internal/infra/repository/device_repository.go @@ -17,7 +17,7 @@ type DeviceRepository interface { // FindByID 根据主键 ID 查找设备 FindByID(id uint) (*models.Device, error) - // FindByIDString 根据字符串形式的主键 ID 查找设备,方便控制器调用 + // FindByIDString 根据字符串形式的主键 ID 查找设备 FindByIDString(id string) (*models.Device, error) // ListAll 获取所有设备的列表 @@ -26,7 +26,7 @@ type DeviceRepository interface { // ListAllSensors 获取所有传感器类型的设备列表 ListAllSensors() ([]*models.Device, error) - // ListByAreaControllerID 根据区域主控 ID 列出所有子设备。 + // ListByAreaControllerID 根据区域主控 ID 列出所有子设备 ListByAreaControllerID(areaControllerID uint) ([]*models.Device, error) // FindByDeviceTemplateID 根据设备模板ID查找所有使用该模板的设备 @@ -40,6 +40,9 @@ type DeviceRepository interface { // FindByAreaControllerAndPhysicalAddress 根据区域主控ID和物理地址(总线号、总线地址)查找设备 FindByAreaControllerAndPhysicalAddress(areaControllerID uint, busNumber int, busAddress int) (*models.Device, error) + + // GetDevicesByIDsTx 在指定事务中根据ID列表获取设备 + GetDevicesByIDsTx(tx *gorm.DB, ids []uint) ([]models.Device, error) } // gormDeviceRepository 是 DeviceRepository 的 GORM 实现 @@ -66,6 +69,18 @@ func (r *gormDeviceRepository) FindByID(id uint) (*models.Device, error) { return &device, nil } +// GetDevicesByIDsTx 在指定事务中根据ID列表获取设备 +func (r *gormDeviceRepository) GetDevicesByIDsTx(tx *gorm.DB, ids []uint) ([]models.Device, error) { + var devices []models.Device + if len(ids) == 0 { + return devices, nil + } + if err := tx.Where("id IN ?", ids).Find(&devices).Error; err != nil { + return nil, err + } + return devices, nil +} + // FindByIDString 根据字符串形式的主键 ID 查找设备 func (r *gormDeviceRepository) FindByIDString(id string) (*models.Device, error) { // 将字符串ID转换为uint64 diff --git a/internal/infra/repository/plan_repository.go b/internal/infra/repository/plan_repository.go index 21e0648..25fdbc0 100644 --- a/internal/infra/repository/plan_repository.go +++ b/internal/infra/repository/plan_repository.go @@ -48,6 +48,8 @@ type PlanRepository interface { GetPlansByIDs(ids []uint) ([]models.Plan, error) // CreatePlan 创建一个新的计划 CreatePlan(plan *models.Plan) error + // CreatePlanTx 在指定事务中创建一个新的计划 + CreatePlanTx(tx *gorm.DB, plan *models.Plan) error // UpdatePlanMetadataAndStructure 更新计划的元数据和结构,但不包括状态等运行时信息 UpdatePlanMetadataAndStructure(plan *models.Plan) error // UpdatePlan 更新计划的所有字段 @@ -200,62 +202,66 @@ func (r *gormPlanRepository) GetPlanByID(id uint) (*models.Plan, error) { // CreatePlan 创建一个新的计划 func (r *gormPlanRepository) CreatePlan(plan *models.Plan) error { - return r.db.Transaction(func(tx *gorm.DB) error { - // 1. 前置校验 - if plan.ID != 0 { - return ErrCreateWithNonZeroID - } + return r.CreatePlanTx(r.db, plan) +} - // 检查是否同时包含任务和子计划 - if len(plan.Tasks) > 0 && len(plan.SubPlans) > 0 { - return ErrMixedContent - } +// CreatePlanTx 在指定事务中创建一个新的计划 +func (r *gormPlanRepository) CreatePlanTx(tx *gorm.DB, plan *models.Plan) error { + // 1. 前置校验 + if plan.ID != 0 { + return ErrCreateWithNonZeroID + } - // 检查是否有重复的执行顺序 - if err := plan.ValidateExecutionOrder(); err != nil { - return fmt.Errorf("计划 (ID: %d) 的执行顺序无效: %w", plan.ID, err) - } + // 检查是否同时包含任务和子计划 + if len(plan.Tasks) > 0 && len(plan.SubPlans) > 0 { + return ErrMixedContent + } - // 如果是子计划类型,验证所有子计划是否存在且ID不为0 - if plan.ContentType == models.PlanContentTypeSubPlans { - childIDsToValidate := make(map[uint]bool) - for _, subPlanLink := range plan.SubPlans { - if subPlanLink.ChildPlanID == 0 { - return ErrSubPlanIDIsZeroOnCreate - } - childIDsToValidate[subPlanLink.ChildPlanID] = true + // 检查是否有重复的执行顺序 + if err := plan.ValidateExecutionOrder(); err != nil { + return fmt.Errorf("计划 (ID: %d) 的执行顺序无效: %w", plan.ID, err) + } + + // 如果是子计划类型,验证所有子计划是否存在且ID不为0 + if plan.ContentType == models.PlanContentTypeSubPlans { + childIDsToValidate := make(map[uint]bool) + for _, subPlanLink := range plan.SubPlans { + if subPlanLink.ChildPlanID == 0 { + return ErrSubPlanIDIsZeroOnCreate } + childIDsToValidate[subPlanLink.ChildPlanID] = true + } - var ids []uint - for id := range childIDsToValidate { - ids = append(ids, id) + var ids []uint + for id := range childIDsToValidate { + ids = append(ids, id) + } + + if len(ids) > 0 { + var count int64 + if err := tx.Model(&models.Plan{}).Where("id IN ?", ids).Count(&count).Error; err != nil { + return fmt.Errorf("验证子计划存在性失败: %w", err) } - - if len(ids) > 0 { - var count int64 - if err := tx.Model(&models.Plan{}).Where("id IN ?", ids).Count(&count).Error; err != nil { - return fmt.Errorf("验证子计划存在性失败: %w", err) - } - if int(count) != len(ids) { - return ErrNodeDoesNotExist - } + if int(count) != len(ids) { + return ErrNodeDoesNotExist } } + } - // 2. 创建根计划 - // GORM 会自动处理关联的 Tasks (如果 ContentType 是 tasks 且 Task.ID 为 0) - if err := tx.Create(plan).Error; err != nil { - return err - } + // 2. 创建根计划 + // GORM 会自动处理关联的 Tasks (如果 ContentType 是 tasks 且 Task.ID 为 0), + // 以及 Tasks 内部已经填充好的 Devices 关联。 + if err := tx.Create(plan).Error; err != nil { + return err + } - // 3. 创建触发器Task - // 关键修改:调用 createPlanAnalysisTask 并处理其返回的 Task 对象 - _, err := r.createPlanAnalysisTask(tx, plan) - if err != nil { - return err - } - return nil - }) + // 3. 创建触发器Task + // 关键修改:调用 createPlanAnalysisTask 并处理其返回的 Task 对象 + _, err := r.createPlanAnalysisTask(tx, plan) + if err != nil { + return err + } + return nil } // UpdatePlan 更新计划 @@ -414,9 +420,7 @@ func (r *gormPlanRepository) reconcileTasks(tx *gorm.DB, plan *models.Plan) erro } if len(tasksToDelete) > 0 { - if err := tx.Delete(&models.Task{}, tasksToDelete).Error; err != nil { - return err - } + return r.deleteTasksTx(tx, tasksToDelete) } return nil } @@ -555,42 +559,43 @@ func (r *gormPlanRepository) flattenPlanTasksRecursive(plan *models.Plan) ([]mod func (r *gormPlanRepository) DeleteTask(id int) error { // 使用事务确保操作的原子性 return r.db.Transaction(func(tx *gorm.DB) error { - return r.deleteTask(tx, id) + return r.deleteTasksTx(tx, []int{id}) }) } -// deleteTask 根据ID删除任务 -func (r *gormPlanRepository) deleteTask(tx *gorm.DB, id int) error { - // 1. 检查是否有待执行任务引用了这个任务 +// deleteTasksTx 在事务中批量软删除任务,并物理删除其在关联表中的记录 +func (r *gormPlanRepository) deleteTasksTx(tx *gorm.DB, ids []int) error { + if len(ids) == 0 { + return nil + } + + // 检查是否有待执行任务引用了这些任务 var pendingTaskCount int64 - if err := tx.Model(&models.PendingTask{}).Where("task_id = ?", id).Count(&pendingTaskCount).Error; err != nil { + if err := tx.Model(&models.PendingTask{}).Where("task_id IN ?", ids).Count(&pendingTaskCount).Error; err != nil { return fmt.Errorf("检查待执行任务时出错: %w", err) } // 如果有待执行任务引用该任务,不能删除 if pendingTaskCount > 0 { - return fmt.Errorf("无法删除任务(ID: %d),因为存在 %d 条待执行任务引用该任务", id, pendingTaskCount) + return fmt.Errorf("无法删除任务,因为存在 %d 条待执行任务引用这些任务", pendingTaskCount) } - // 2. 检查是否有计划仍在使用这个任务 - var planCount int64 - if err := tx.Model(&models.Plan{}).Joins("JOIN tasks ON plans.id = tasks.plan_id").Where("tasks.id = ?", id).Count(&planCount).Error; err != nil { - return fmt.Errorf("检查计划引用任务时出错: %w", err) + // 因为钩子函数在批量删除中不会被触发, 所以手动删除关联表, 通过批量删除语句优化性能 + + // 1. 直接、高效地从关联表中物理删除所有相关记录 + // 这是最关键的优化,避免了不必要的查询和循环 + if err := tx.Where("task_id IN ?", ids).Delete(&models.DeviceTask{}).Error; err != nil { + return fmt.Errorf("清理任务的设备关联失败: %w", err) } - // 如果有计划在使用该任务,不能删除 - if planCount > 0 { - return fmt.Errorf("无法删除任务(ID: %d),因为存在 %d 个计划仍在使用该任务", id, planCount) - } - - // 3. 执行删除操作 - result := tx.Delete(&models.Task{}, id) + // 2. 对任务本身进行软删除 + result := tx.Delete(&models.Task{}, ids) if result.Error != nil { - return fmt.Errorf("删除任务失败: %w", result.Error) + return result.Error } - // 检查是否实际删除了记录 - if result.RowsAffected == 0 { + // 3. 如果是单个删除且未找到记录,则返回错误 + if len(ids) == 1 && result.RowsAffected == 0 { return gorm.ErrRecordNotFound }