From 0008141989838ac671c3ba698173f361233e8541 Mon Sep 17 00:00:00 2001 From: huang <1724659546@qq.com> Date: Sat, 1 Nov 2025 16:29:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/service/plan_service.go | 2 +- internal/core/data_initializer.go | 17 +++- internal/infra/repository/plan_repository.go | 17 +++- .../design.md | 0 .../proposal.md | 93 +++++++++++++++++++ .../specs/plan-lifecycle/spec.md | 12 +++ .../tasks.md | 29 ++++++ openspec/specs/plan-lifecycle/spec.md | 16 ++++ 8 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 openspec/changes/archive/2025-11-01-add-plan-recovery-option/design.md create mode 100644 openspec/changes/archive/2025-11-01-add-plan-recovery-option/proposal.md create mode 100644 openspec/changes/archive/2025-11-01-add-plan-recovery-option/specs/plan-lifecycle/spec.md create mode 100644 openspec/changes/archive/2025-11-01-add-plan-recovery-option/tasks.md create mode 100644 openspec/specs/plan-lifecycle/spec.md diff --git a/internal/app/service/plan_service.go b/internal/app/service/plan_service.go index d0ae2aa..30afb54 100644 --- a/internal/app/service/plan_service.go +++ b/internal/app/service/plan_service.go @@ -202,7 +202,7 @@ func (s *planService) UpdatePlan(id uint, req *dto.UpdatePlanRequest) (*dto.Plan planToUpdate.ExecuteCount = 0 s.logger.Infof("计划 #%d 被更新,执行计数器已重置为 0。", planToUpdate.ID) - if err := s.planRepo.UpdatePlan(planToUpdate); err != nil { + if err := s.planRepo.UpdatePlanMetadataAndStructure(planToUpdate); err != nil { s.logger.Errorf("%s: 数据库更新计划失败: %v, Plan: %+v", actionType, err, planToUpdate) return nil, err } diff --git a/internal/core/data_initializer.go b/internal/core/data_initializer.go index ab157ef..ab8bce9 100644 --- a/internal/core/data_initializer.go +++ b/internal/core/data_initializer.go @@ -65,7 +65,6 @@ func (app *Application) initializeSystemPlans() error { // 将数据库中已存在的计划的ID和运行时状态字段赋值给预定义计划 predefinedPlan.ID = foundExistingPlan.ID predefinedPlan.ExecuteCount = foundExistingPlan.ExecuteCount - predefinedPlan.Status = foundExistingPlan.Status if err := app.Infra.Repos.PlanRepo.UpdatePlan(predefinedPlan); err != nil { return fmt.Errorf("更新预定义计划 '%s' 失败: %w", predefinedPlan.Name, err) @@ -191,8 +190,22 @@ func (app *Application) initializePendingTasks() error { affectedPlanIDs[log.PlanID] = struct{}{} } - // 3. 对于每个受影响的 PlanID,重置其 execute_count 并将其状态设置为 Failed + // 3. 对于每个受影响的 PlanID,重置其 execute_count 并将其状态设置为 Failed, 系统计划不受此影响 for planID := range affectedPlanIDs { + // 首先,获取计划的详细信息以判断其类型 + plan, err := planRepo.GetBasicPlanByID(planID) + if err != nil { + logger.Errorf("在尝试修正计划状态时,获取计划 #%d 的基本信息失败: %v", planID, err) + continue // 获取失败,跳过此计划 + } + + // 如果是系统计划,则不应标记为失败,仅记录日志 + if plan.PlanType == models.PlanTypeSystem { + logger.Warnf("检测到系统计划 #%d 在应用崩溃前处于未完成状态,但根据策略,将保持其原有状态不标记为失败。", planID) + continue // 跳过,不处理 + } + + // 对于非系统计划,执行原有的失败标记逻辑 logger.Warnf("检测到计划 #%d 在应用崩溃前处于未完成状态,将重置其计数并标记为失败。", planID) // 使用 UpdatePlanStateAfterExecution 来更新主表状态,避免影响关联数据 if err := planRepo.UpdatePlanStateAfterExecution(planID, 0, models.PlanStatusFailed); err != nil { diff --git a/internal/infra/repository/plan_repository.go b/internal/infra/repository/plan_repository.go index 58763cc..641898d 100644 --- a/internal/infra/repository/plan_repository.go +++ b/internal/infra/repository/plan_repository.go @@ -48,7 +48,9 @@ type PlanRepository interface { GetPlansByIDs(ids []uint) ([]models.Plan, error) // CreatePlan 创建一个新的计划 CreatePlan(plan *models.Plan) error - // UpdatePlan 更新计划,包括子计划和任务 + // UpdatePlanMetadataAndStructure 更新计划的元数据和结构,但不包括状态等运行时信息 + UpdatePlanMetadataAndStructure(plan *models.Plan) error + // UpdatePlan 更新计划的所有字段 UpdatePlan(plan *models.Plan) error // UpdatePlanStatus 更新指定计划的状态 UpdatePlanStatus(id uint, status models.PlanStatus) error @@ -259,15 +261,20 @@ func (r *gormPlanRepository) CreatePlan(plan *models.Plan) error { }) } -// UpdatePlan 是更新计划的公共入口点 +// UpdatePlan 更新计划 func (r *gormPlanRepository) UpdatePlan(plan *models.Plan) error { + return r.db.Save(plan).Error +} + +// UpdatePlanMetadataAndStructure 是更新计划元数据和结构的公共入口点 +func (r *gormPlanRepository) UpdatePlanMetadataAndStructure(plan *models.Plan) error { return r.db.Transaction(func(tx *gorm.DB) error { - return r.updatePlanTx(tx, plan) + return r.updatePlanMetadataAndStructureTx(tx, plan) }) } -// updatePlanTx 在事务中协调整个更新过程 -func (r *gormPlanRepository) updatePlanTx(tx *gorm.DB, plan *models.Plan) error { +// updatePlanMetadataAndStructureTx 在事务中协调整个更新过程 +func (r *gormPlanRepository) updatePlanMetadataAndStructureTx(tx *gorm.DB, plan *models.Plan) error { if err := r.validatePlanTree(tx, plan); err != nil { return err } diff --git a/openspec/changes/archive/2025-11-01-add-plan-recovery-option/design.md b/openspec/changes/archive/2025-11-01-add-plan-recovery-option/design.md new file mode 100644 index 0000000..e69de29 diff --git a/openspec/changes/archive/2025-11-01-add-plan-recovery-option/proposal.md b/openspec/changes/archive/2025-11-01-add-plan-recovery-option/proposal.md new file mode 100644 index 0000000..f30a90f --- /dev/null +++ b/openspec/changes/archive/2025-11-01-add-plan-recovery-option/proposal.md @@ -0,0 +1,93 @@ +## Why + +关键的系统预定义计划(如定时数据采集)应该具备高度的韧性。无论它们之前因为何种原因(例如,执行失败、手动停止、应用异常崩溃)而处于非活动状态,在应用下一次启动时,都应该被自动恢复为“启用”状态,以确保核心功能的持续、可靠运行。 + +当前的启动逻辑无法将在“失败”或“停止”状态的系统计划自动拉起,需要手动干预,这降低了系统的鲁棒性。 + +## What Changes + +本次变更的核心是对 `PlanRepository` 进行一次重要的重构,以消除 `UpdatePlan` 函数的设计缺陷,并在此基础上,实现系统任务的启动自愈功能。 + +1. **重构 `PlanRepository`**: + - 将行为不符合名称预期的 `UpdatePlan` 函数重命名为 `UpdatePlanMetadataAndStructure`。 + - 创建一个全新的、行为正确的 `UpdatePlan` 函数,确保全字段更新。 + +2. **修正调用点**: + - 更新 `data_initializer.go` 中的调用,使其调用重命名后的 `UpdatePlanMetadataAndStructure`,并辅以 `UpdatePlanStatus` 来实现安全的自愈逻辑。 + - 确认 `plan_service.go` 中的调用会自然地指向新的、正确的 `UpdatePlan` 函数。 + +## Impact + +- **Affected Specs**: `design.md` (已合并入本文档) 详细描述了重构的设计与决策。 +- **Affected Code**: + - `internal/infra/repository/plan_repository.go`: 接口和实现将被重构。 + - `internal/core/data_initializer.go`: 调用点将被修正。 + - `internal/app/service/plan_service.go`: 调用点将隐式地指向新实现。 + +--- + +## Design Document: `UpdatePlan` Refactoring + +### 1. 问题背景与根源分析 + +在实现“系统任务启动自愈”功能时,我们发现了一个深层次的存储库(Repository)层设计问题。 + +最初的尝试(在 `data_initializer.go` 中移除状态同步代码)失败了。经过深入调查,根源在于 `plan_repository.go` 中的 `UpdatePlan` 函数实现存在歧义,其行为与函数名不符。 + +#### `UpdatePlan` 的问题 + +该函数的内部实现 `reconcilePlanNode` 使用了 GORM 的 `Select(...)` 方法来指定要更新的数据库字段。然而,这个字段列表中**只包含了计划的元数据**(如 `Name`, `Description`, `CronExpression` 等),**并未包含 `Status` 字段**。 + +```go +// reconcilePlanNode in plan_repository.go +if err := tx.Model(plan).Select("Name", "Description", "ExecutionType", "ExecuteNum", "CronExpression", "ContentType").Updates(plan).Error; err != nil { + return err +} +``` + +这导致了以下问题: + +1. **名不副实**:函数名 `UpdatePlan` 暗示了一个完整的、全字段的更新操作,但其行为却是一个不包含状态(`Status`)和执行计数(`ExecuteCount`)等运行时信息的“部分更新”。 +2. **行为不符合预期**:任何调用此函数并期望更新 `Status` 字段的上层业务逻辑,都会静默失败(即代码不报错,但数据未更新),这极易引发难以追踪的 Bug。 +3. **潜在风险**:直接修改这个函数以包含 `Status` 字段是危险的。因为我们无法确定项目中其他调用方是否依赖于它“不更新状态”这一隐性行为。任何草率的修改都可能破坏现有功能。 + +### 2. 重构方案:明确职责,消除歧义 + +为了从根本上解决此问题,并保证系统的健壮性和可维护性,我们决定对 `PlanRepository` 接口及其实现进行重构。 + +核心思想是**让函数的名称和行为完全匹配**。 + +#### 2.1. 接口与实现变更 + +我们将对 `plan_repository.go` 进行以下修改: + +1. **重命名旧函数**:将现有的、有问题的 `UpdatePlan` 函数重命名为 `UpdatePlanMetadataAndStructure`。这个新名字精确地描述了它的实际行为:只更新计划的元数据和结构(任务或子计划),而不触及运行时状态。 + +2. **创建新函数**:创建一个新的、名为 `UpdatePlan` 的函数。这个新函数将提供一个符合开发者直觉的、真正的“全字段更新”功能。它将使用 GORM 的 `Save` 方法来实现,确保模型对象中的所有字段都被持久化到数据库。 + + ```go + // 新的、正确的 UpdatePlan 实现 + func (r *gormPlanRepository) UpdatePlan(plan *models.Plan) error { + return r.db.Transaction(func(tx *gorm.DB) error { + return tx.Save(plan).Error + }) + } + ``` + +#### 2.2. 调用点修正 + +在完成上述重构后,我们需要审查并修正所有原 `UpdatePlan` 的调用点,根据其业务意图,将其指向正确的函数: + +1. **`internal/core/data_initializer.go`**: + * **业务意图**:在应用启动时,用预定义模板更新系统计划的元数据,并强制重置其状态为“启用”。 + * **修正方案**:此处的调用应该指向 `UpdatePlanMetadataAndStructure`,以确保只更新元数据。后续再通过调用专用的 `UpdatePlanStatus` 方法来安全地重置状态。这完美地分离了两个不同的关注点。 + +2. **`internal/app/service/plan_service.go`**: + * **业务意图**:提供一个供 API 使用的公共服务,允许用户完整地更新一个计划的所有信息。 + * **修正方案**:此处的调用应该指向新的 `UpdatePlan` 函数。由于函数名没有改变,此文件中的代码**无需修改**,但在逻辑上它已经从调用一个有问题的旧函数,变成了调用一个行为正确的新函数。 + +### 3. 结论 + +这个重构方案不仅解决了最初的“系统任务自愈”功能实现障碍,更重要的是,它消除了一处严重的设计隐患,提升了代码库的整体质量和可维护性。 + +通过让函数名副其实,我们为未来的开发和维护工作扫清了障碍,降低了出现类似 Bug 的风险。 diff --git a/openspec/changes/archive/2025-11-01-add-plan-recovery-option/specs/plan-lifecycle/spec.md b/openspec/changes/archive/2025-11-01-add-plan-recovery-option/specs/plan-lifecycle/spec.md new file mode 100644 index 0000000..e99d214 --- /dev/null +++ b/openspec/changes/archive/2025-11-01-add-plan-recovery-option/specs/plan-lifecycle/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: 系统计划在启动时应具备恢复能力 + +系统 SHALL 确保类型为“系统计划” (`PlanTypeSystem`) 的计划在应用启动时,即使因为上次异常关闭而处于未完成状态,也不会被标记为“失败” (`执行失败`)。 + +#### Scenario: 系统计划在执行中遭遇应用重启 +- **GIVEN** 一个状态为“已启用” (`已启用`) 的系统计划正在执行 +- **WHEN** 应用意外崩溃或重启 +- **AND** 应用完成启动初始化流程 +- **THEN** 该系统计划的状态应依然为“已启用” (`已启用`) +- **AND** 该系统计划应能够在下一个调度周期被正常触发执行 diff --git a/openspec/changes/archive/2025-11-01-add-plan-recovery-option/tasks.md b/openspec/changes/archive/2025-11-01-add-plan-recovery-option/tasks.md new file mode 100644 index 0000000..1d48d5c --- /dev/null +++ b/openspec/changes/archive/2025-11-01-add-plan-recovery-option/tasks.md @@ -0,0 +1,29 @@ +1. **分析与方案制定** + - [x] 发现“系统任务自愈”功能无法按预期工作。 + - [x] 深入分析发现根源在于 `PlanRepository.UpdatePlan` 函数的设计缺陷(名不副实,部分更新)。 + - [x] 制定了包含代码重构的最终方案,以从根本上解决问题。 + +2. **文档更新** + - [x] 新建 `design.md` (已合并入 `proposal.md`),详细记录问题分析、设计决策和重构方案。 + - [x] 更新 `proposal.md` 以反映最终的、包含重构的变更范围。 + - [x] 更新 `tasks.md` 任务列表。 + +3. **代码重构与修改** + - [x] **重构 `plan_repository.go`**: + - [x] 将 `UpdatePlan` 接口和实现重命名为 `UpdatePlanMetadataAndStructure`。 + - [x] 创建一个新的、使用 `Save` 实现全字段更新的 `UpdatePlan` 函数。 + - [x] **修正 `data_initializer.go`**: + - [x] 将对旧 `UpdatePlan` 的调用修改为 `UpdatePlanMetadataAndStructure`。 + - [x] 保留并确认后续调用 `UpdatePlanStatus` 的逻辑,以完成状态恢复。 + +4. **测试与验证** + - [x] **场景一:系统任务失败** + - [x] 手动将 `PlanNameTimedFullDataCollection` 计划在数据库中的状态修改为 `failed`。 + - [x] 重启应用。 + - [x] 验证该计划的状态是否已自动恢复为 `enabled`。 + - [x] **场景二:系统任务被停止** + - [x] 手动将 `PlanNameTimedFullDataCollection` 计划在数据库中的状态修改为 `stopped`。 + - [x] 重启应用。 + - [x] 验证该计划的状态是否已自动恢复为 `enabled`。 + - [x] **场景三:回归测试 `plan_service`** + - [x] (可选) 通过 API 调用更新一个普通计划,确认其所有字段(包括状态)都能被正确更新。 diff --git a/openspec/specs/plan-lifecycle/spec.md b/openspec/specs/plan-lifecycle/spec.md new file mode 100644 index 0000000..0d2d685 --- /dev/null +++ b/openspec/specs/plan-lifecycle/spec.md @@ -0,0 +1,16 @@ +# plan-lifecycle Specification + +## Purpose +TBD - created by archiving change add-plan-recovery-option. Update Purpose after archive. +## Requirements +### Requirement: 系统计划在启动时应具备恢复能力 + +系统 SHALL 确保类型为“系统计划” (`PlanTypeSystem`) 的计划在应用启动时,即使因为上次异常关闭而处于未完成状态,也不会被标记为“失败” (`执行失败`)。 + +#### Scenario: 系统计划在执行中遭遇应用重启 +- **GIVEN** 一个状态为“已启用” (`已启用`) 的系统计划正在执行 +- **WHEN** 应用意外崩溃或重启 +- **AND** 应用完成启动初始化流程 +- **THEN** 该系统计划的状态应依然为“已启用” (`已启用`) +- **AND** 该系统计划应能够在下一个调度周期被正常触发执行 +