diff --git a/internal/app/api/api.go b/internal/app/api/api.go index 5200b7e..024b40f 100644 --- a/internal/app/api/api.go +++ b/internal/app/api/api.go @@ -29,7 +29,7 @@ import ( "git.huangwc.com/pig/pig-farm-controller/internal/domain/audit" domain_device "git.huangwc.com/pig/pig-farm-controller/internal/domain/device" domain_notify "git.huangwc.com/pig/pig-farm-controller/internal/domain/notify" - "git.huangwc.com/pig/pig-farm-controller/internal/domain/task" + "git.huangwc.com/pig/pig-farm-controller/internal/domain/scheduler" "git.huangwc.com/pig/pig-farm-controller/internal/domain/token" "git.huangwc.com/pig/pig-farm-controller/internal/infra/config" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" @@ -40,21 +40,21 @@ import ( // API 结构体定义了 HTTP 服务器及其依赖 type API struct { - engine *gin.Engine // Gin 引擎实例,用于处理 HTTP 请求 - logger *logs.Logger // 日志记录器,用于输出日志信息 - userRepo repository.UserRepository // 用户数据仓库接口,用于用户数据操作 - tokenService token.Service // Token 服务接口,用于 JWT token 的生成和解析 - auditService audit.Service // 审计服务,用于记录用户操作 - httpServer *http.Server // 标准库的 HTTP 服务器实例,用于启动和停止服务 - config config.ServerConfig // API 服务器的配置,使用 infra/config 包中的 ServerConfig - userController *user.Controller // 用户控制器实例 - deviceController *device.Controller // 设备控制器实例 - planController *plan.Controller // 计划控制器实例 - pigFarmController *management.PigFarmController // 猪场管理控制器实例 - pigBatchController *management.PigBatchController // 猪群控制器实例 - monitorController *monitor.Controller // 数据监控控制器实例 - listenHandler webhook.ListenHandler // 设备上行事件监听器 - analysisTaskManager *task.AnalysisPlanTaskManager // 计划触发器管理器实例 + engine *gin.Engine // Gin 引擎实例,用于处理 HTTP 请求 + logger *logs.Logger // 日志记录器,用于输出日志信息 + userRepo repository.UserRepository // 用户数据仓库接口,用于用户数据操作 + tokenService token.Service // Token 服务接口,用于 JWT token 的生成和解析 + auditService audit.Service // 审计服务,用于记录用户操作 + httpServer *http.Server // 标准库的 HTTP 服务器实例,用于启动和停止服务 + config config.ServerConfig // API 服务器的配置,使用 infra/config 包中的 ServerConfig + userController *user.Controller // 用户控制器实例 + deviceController *device.Controller // 设备控制器实例 + planController *plan.Controller // 计划控制器实例 + pigFarmController *management.PigFarmController // 猪场管理控制器实例 + pigBatchController *management.PigBatchController // 猪群控制器实例 + monitorController *monitor.Controller // 数据监控控制器实例 + listenHandler webhook.ListenHandler // 设备上行事件监听器 + analysisTaskManager *scheduler.AnalysisPlanTaskManager // 计划触发器管理器实例 } // NewAPI 创建并返回一个新的 API 实例 @@ -74,7 +74,7 @@ func NewAPI(cfg config.ServerConfig, notifyService domain_notify.Service, deviceService domain_device.Service, listenHandler webhook.ListenHandler, - analysisTaskManager *task.AnalysisPlanTaskManager) *API { + analysisTaskManager *scheduler.AnalysisPlanTaskManager) *API { // 设置 Gin 模式,例如 gin.ReleaseMode (生产模式) 或 gin.DebugMode (开发模式) // 从配置中获取 Gin 模式 gin.SetMode(cfg.Mode) diff --git a/internal/app/controller/plan/plan_controller.go b/internal/app/controller/plan/plan_controller.go index 5e30d13..1671d96 100644 --- a/internal/app/controller/plan/plan_controller.go +++ b/internal/app/controller/plan/plan_controller.go @@ -61,7 +61,11 @@ func (c *Controller) CreatePlan(ctx *gin.Context) { return } - // --- 自动判断 ContentType --- + // --- 业务规则处理 --- + // 1. 设置计划类型:用户创建的计划永远是自定义计划 + planToCreate.PlanType = models.PlanTypeCustom + + // 2. 自动判断 ContentType if len(req.SubPlanIDs) > 0 { planToCreate.ContentType = models.PlanContentTypeSubPlans } else { @@ -145,16 +149,25 @@ func (c *Controller) GetPlan(ctx *gin.Context) { // ListPlans godoc // @Summary 获取计划列表 -// @Description 获取所有计划的列表 +// @Description 获取所有计划的列表,支持按类型过滤和分页 // @Tags 计划管理 // @Security BearerAuth // @Produce json -// @Success 200 {object} controller.Response{data=[]dto.PlanResponse} "业务码为200代表成功获取列表" +// @Param query query dto.ListPlansQuery false "查询参数" +// @Success 200 {object} controller.Response{data=dto.ListPlansResponse} "业务码为200代表成功获取列表" // @Router /api/v1/plans [get] func (c *Controller) ListPlans(ctx *gin.Context) { const actionType = "获取计划列表" + var query dto.ListPlansQuery + if err := ctx.ShouldBindQuery(&query); err != nil { + c.logger.Errorf("%s: 查询参数绑定失败: %v", actionType, err) + controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "查询参数绑定失败", query) + return + } + // 1. 调用仓库层获取所有计划 - plans, err := c.planRepo.ListBasicPlans() + opts := repository.ListPlansOptions{PlanType: repository.PlanTypeFilter(query.PlanType)} + plans, total, err := c.planRepo.ListPlans(opts, query.Page, query.PageSize) if err != nil { c.logger.Errorf("%s: 数据库查询失败: %v", actionType, err) controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划列表时发生内部错误", actionType, "数据库查询失败", nil) @@ -176,7 +189,7 @@ func (c *Controller) ListPlans(ctx *gin.Context) { // 3. 构造并发送成功响应 resp := dto.ListPlansResponse{ Plans: planResponses, - Total: len(planResponses), + Total: total, } c.logger.Infof("%s: 获取计划列表成功, 数量: %d", actionType, len(planResponses)) controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划列表成功", resp, actionType, "获取计划列表成功", resp) @@ -184,7 +197,7 @@ func (c *Controller) ListPlans(ctx *gin.Context) { // UpdatePlan godoc // @Summary 更新计划 -// @Description 根据计划ID更新计划的详细信息。 +// @Description 根据计划ID更新计划的详细信息。系统计划不允许修改。 // @Tags 计划管理 // @Security BearerAuth // @Accept json @@ -212,7 +225,27 @@ func (c *Controller) UpdatePlan(ctx *gin.Context) { return } - // 3. 将请求转换为模型(转换函数带校验) + // 3. 检查计划是否存在 + existingPlan, err := c.planRepo.GetBasicPlanByID(uint(id)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) + controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "计划不存在", actionType, "计划不存在", id) + return + } + c.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, id) + controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划信息时发生内部错误", actionType, "数据库查询失败", id) + return + } + + // 4. 业务规则:系统计划不允许修改 + if existingPlan.PlanType == models.PlanTypeSystem { + c.logger.Warnf("%s: 尝试修改系统计划, ID: %d", actionType, id) + controller.SendErrorWithAudit(ctx, controller.CodeForbidden, "系统计划不允许修改", actionType, "尝试修改系统计划", id) + return + } + + // 5. 将请求转换为模型(转换函数带校验) planToUpdate, err := dto.NewPlanFromUpdateRequest(&req) if err != nil { c.logger.Errorf("%s: 计划数据校验失败: %v", actionType, err) @@ -229,20 +262,7 @@ func (c *Controller) UpdatePlan(ctx *gin.Context) { planToUpdate.ContentType = models.PlanContentTypeTasks } - // 4. 检查计划是否存在 - _, err = c.planRepo.GetBasicPlanByID(uint(id)) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - c.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) - controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "计划不存在", actionType, "计划不存在", id) - return - } - c.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, id) - controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划信息时发生内部错误", actionType, "数据库查询失败", id) - return - } - - // 5. 调用仓库方法更新计划 + // 6. 调用仓库方法更新计划 // 只要是更新任务,就重置执行计数器 planToUpdate.ExecuteCount = 0 // 重置计数器 c.logger.Infof("计划 #%d 被更新,执行计数器已重置为 0。", planToUpdate.ID) @@ -259,7 +279,7 @@ func (c *Controller) UpdatePlan(ctx *gin.Context) { c.logger.Errorf("为更新后的计划 %d 确保触发器任务定义失败: %v", planToUpdate.ID, err) } - // 6. 获取更新后的完整计划用于响应 + // 7. 获取更新后的完整计划用于响应 updatedPlan, err := c.planRepo.GetPlanByID(uint(id)) if err != nil { c.logger.Errorf("%s: 获取更新后计划详情失败: %v, ID: %d", actionType, err, id) @@ -267,7 +287,7 @@ func (c *Controller) UpdatePlan(ctx *gin.Context) { return } - // 7. 将模型转换为响应 DTO + // 8. 将模型转换为响应 DTO resp, err := dto.NewPlanToResponse(updatedPlan) if err != nil { c.logger.Errorf("%s: 序列化响应失败: %v, Updated Plan: %+v", actionType, err, updatedPlan) @@ -275,14 +295,14 @@ func (c *Controller) UpdatePlan(ctx *gin.Context) { return } - // 8. 发送成功响应 + // 9. 发送成功响应 c.logger.Infof("%s: 计划更新成功, ID: %d", actionType, updatedPlan.ID) controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划更新成功", resp, actionType, "计划更新成功", resp) } // DeletePlan godoc // @Summary 删除计划 -// @Description 根据计划ID删除计划。(软删除) +// @Description 根据计划ID删除计划。(软删除)系统计划不允许删除。 // @Tags 计划管理 // @Security BearerAuth // @Produce json @@ -313,7 +333,14 @@ func (c *Controller) DeletePlan(ctx *gin.Context) { return } - // 3. 停止这个计划 + // 3. 业务规则:系统计划不允许删除 + if plan.PlanType == models.PlanTypeSystem { + c.logger.Warnf("%s: 尝试删除系统计划, ID: %d", actionType, id) + controller.SendErrorWithAudit(ctx, controller.CodeForbidden, "系统计划不允许删除", actionType, "尝试删除系统计划", id) + return + } + + // 4. 停止这个计划 if plan.Status == models.PlanStatusEnabled { if err := c.planRepo.StopPlanTransactionally(uint(id)); err != nil { c.logger.Errorf("%s: 停止计划失败: %v, ID: %d", actionType, err, id) @@ -322,21 +349,21 @@ func (c *Controller) DeletePlan(ctx *gin.Context) { } } - // 4. 调用仓库层删除计划 + // 5. 调用仓库层删除计划 if err := c.planRepo.DeletePlan(uint(id)); err != nil { c.logger.Errorf("%s: 数据库删除失败: %v, ID: %d", actionType, err, id) controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除计划时发生内部错误", actionType, "数据库删除失败", id) return } - // 5. 发送成功响应 + // 6. 发送成功响应 c.logger.Infof("%s: 计划删除成功, ID: %d", actionType, id) controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划删除成功", nil, actionType, "计划删除成功", id) } // StartPlan godoc // @Summary 启动计划 -// @Description 根据计划ID启动一个计划的执行。 +// @Description 根据计划ID启动一个计划的执行。系统计划不允许手动启动。 // @Tags 计划管理 // @Security BearerAuth // @Produce json @@ -367,7 +394,12 @@ func (c *Controller) StartPlan(ctx *gin.Context) { return } - // 3. 检查计划当前状态 + // 3. 业务规则检查 + if plan.PlanType == models.PlanTypeSystem { + c.logger.Warnf("%s: 尝试手动启动系统计划, ID: %d", actionType, id) + controller.SendErrorWithAudit(ctx, controller.CodeForbidden, "系统计划不允许手动启动", actionType, "尝试手动启动系统计划", id) + return + } if plan.Status == models.PlanStatusEnabled { c.logger.Warnf("%s: 计划已处于启动状态,无需重复操作, ID: %d", actionType, id) controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "计划已处于启动状态,无需重复操作", actionType, "计划已处于启动状态", id) @@ -416,7 +448,7 @@ func (c *Controller) StartPlan(ctx *gin.Context) { // StopPlan godoc // @Summary 停止计划 -// @Description 根据计划ID停止一个正在执行的计划。 +// @Description 根据计划ID停止一个正在执行的计划。系统计划不能被停止。 // @Tags 计划管理 // @Security BearerAuth // @Produce json @@ -447,21 +479,28 @@ func (c *Controller) StopPlan(ctx *gin.Context) { return } - // 3. 检查计划当前状态 + // 3. 业务规则:系统计划不允许停止 + if plan.PlanType == models.PlanTypeSystem { + c.logger.Warnf("%s: 尝试停止系统计划, ID: %d", actionType, id) + controller.SendErrorWithAudit(ctx, controller.CodeForbidden, "系统计划不允许停止", actionType, "尝试停止系统计划", id) + return + } + + // 4. 检查计划当前状态 if plan.Status != models.PlanStatusEnabled { c.logger.Warnf("%s: 计划当前不是启用状态, ID: %d, Status: %s", actionType, id, plan.Status) controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "计划当前不是启用状态", actionType, "计划未启用", id) return } - // 4. 调用仓库层方法,该方法内部处理事务 + // 5. 调用仓库层方法,该方法内部处理事务 if err := c.planRepo.StopPlanTransactionally(uint(id)); err != nil { c.logger.Errorf("%s: 停止计划失败: %v, ID: %d", actionType, err, id) controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "停止计划时发生内部错误: "+err.Error(), actionType, "停止计划失败", id) return } - // 5. 发送成功响应 + // 6. 发送成功响应 c.logger.Infof("%s: 计划已成功停止, ID: %d", actionType, id) controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划已成功停止", nil, actionType, "计划已成功停止", id) } diff --git a/internal/app/controller/response.go b/internal/app/controller/response.go index 7f180c7..cb06298 100644 --- a/internal/app/controller/response.go +++ b/internal/app/controller/response.go @@ -18,6 +18,7 @@ const ( // 客户端错误状态码 (4000-4999) CodeBadRequest ResponseCode = 4000 // 请求参数错误 CodeUnauthorized ResponseCode = 4001 // 未授权 + CodeForbidden ResponseCode = 4003 // 禁止访问 CodeNotFound ResponseCode = 4004 // 资源未找到 CodeConflict ResponseCode = 4009 // 资源冲突 diff --git a/internal/app/dto/plan_converter.go b/internal/app/dto/plan_converter.go index 2ead166..7763ecf 100644 --- a/internal/app/dto/plan_converter.go +++ b/internal/app/dto/plan_converter.go @@ -17,6 +17,7 @@ func NewPlanToResponse(plan *models.Plan) (*PlanResponse, error) { ID: plan.ID, Name: plan.Name, Description: plan.Description, + PlanType: plan.PlanType, ExecutionType: plan.ExecutionType, Status: plan.Status, ExecuteNum: plan.ExecuteNum, @@ -64,7 +65,7 @@ func NewPlanFromCreateRequest(req *CreatePlanRequest) (*models.Plan, error) { ExecutionType: req.ExecutionType, ExecuteNum: req.ExecuteNum, CronExpression: req.CronExpression, - // ContentType 在控制器中设置,此处不再处理 + // ContentType 和 PlanType 在控制器中设置,此处不再处理 } // 处理子计划 (通过ID引用) @@ -116,7 +117,7 @@ func NewPlanFromUpdateRequest(req *UpdatePlanRequest) (*models.Plan, error) { ExecutionType: req.ExecutionType, ExecuteNum: req.ExecuteNum, CronExpression: req.CronExpression, - // ContentType 在控制器中设置,此处不再处理 + // ContentType 和 PlanType 在控制器中设置,此处不再处理 } // 处理子计划 (通过ID引用) diff --git a/internal/app/dto/plan_dto.go b/internal/app/dto/plan_dto.go index 37935a2..c84a3ed 100644 --- a/internal/app/dto/plan_dto.go +++ b/internal/app/dto/plan_dto.go @@ -2,6 +2,13 @@ package dto import "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" +// ListPlansQuery 定义了获取计划列表时的查询参数 +type ListPlansQuery struct { + PlanType string `form:"planType,default=custom"` // 计划类型 (all, custom, system),默认为 custom + Page int `form:"page,default=1"` // 页码 + PageSize int `form:"pageSize,default=10"` // 每页大小 +} + // CreatePlanRequest 定义创建计划请求的结构体 type CreatePlanRequest struct { Name string `json:"name" binding:"required" example:"猪舍温度控制计划"` @@ -18,6 +25,7 @@ type PlanResponse struct { ID uint `json:"id" example:"1"` Name string `json:"name" example:"猪舍温度控制计划"` Description string `json:"description" example:"根据温度自动调节风扇和加热器"` + PlanType models.PlanType `json:"plan_type" example:"自定义任务"` ExecutionType models.PlanExecutionType `json:"execution_type" example:"自动"` Status models.PlanStatus `json:"status" example:"已启用"` ExecuteNum uint `json:"execute_num" example:"10"` @@ -31,7 +39,7 @@ type PlanResponse struct { // ListPlansResponse 定义获取计划列表响应的结构体 type ListPlansResponse struct { Plans []PlanResponse `json:"plans"` - Total int `json:"total" example:"100"` + Total int64 `json:"total" example:"100"` } // UpdatePlanRequest 定义更新计划请求的结构体 diff --git a/internal/infra/repository/plan_repository.go b/internal/infra/repository/plan_repository.go index 6c6876a..c877b8d 100644 --- a/internal/infra/repository/plan_repository.go +++ b/internal/infra/repository/plan_repository.go @@ -21,11 +21,25 @@ var ( ErrDeleteWithReferencedPlan = errors.New("禁止删除正在被引用的计划") ) +// PlanTypeFilter 定义计划类型的过滤器 +type PlanTypeFilter string + +const ( + PlanTypeFilterAll PlanTypeFilter = "all" + PlanTypeFilterCustom PlanTypeFilter = "custom" + PlanTypeFilterSystem PlanTypeFilter = "system" +) + +// ListPlansOptions 定义了查询计划时的可选参数 +type ListPlansOptions struct { + PlanType PlanTypeFilter +} + // PlanRepository 定义了与计划模型相关的数据库操作接口 // 这是为了让业务逻辑层依赖于抽象,而不是具体的数据库实现 type PlanRepository interface { - // ListBasicPlans 获取所有计划的基本信息,不包含子计划和任务详情 - ListBasicPlans() ([]models.Plan, error) + // ListPlans 获取计划列表,支持过滤和分页 + ListPlans(opts ListPlansOptions, page, pageSize int) ([]models.Plan, int64, error) // GetBasicPlanByID 根据ID获取计划的基本信息,不包含子计划和任务详情 GetBasicPlanByID(id uint) (*models.Plan, error) // GetPlanByID 根据ID获取计划,包含子计划和任务详情 @@ -81,15 +95,37 @@ func NewGormPlanRepository(db *gorm.DB) PlanRepository { } } -// ListBasicPlans 获取所有计划的基本信息,不包含子计划和任务详情 -func (r *gormPlanRepository) ListBasicPlans() ([]models.Plan, error) { - var plans []models.Plan - // GORM 默认不会加载关联,除非使用 Preload,所以直接 Find 即可满足要求 - result := r.db.Find(&plans) - if result.Error != nil { - return nil, result.Error +// ListPlans 获取计划列表,支持过滤和分页 +func (r *gormPlanRepository) ListPlans(opts ListPlansOptions, page, pageSize int) ([]models.Plan, int64, error) { + if page <= 0 || pageSize <= 0 { + return nil, 0, ErrInvalidPagination } - return plans, nil + + var plans []models.Plan + var total int64 + + query := r.db.Model(&models.Plan{}) + + switch opts.PlanType { + case PlanTypeFilterCustom: + query = query.Where("plan_type = ?", models.PlanTypeCustom) + case PlanTypeFilterSystem: + query = query.Where("plan_type = ?", models.PlanTypeSystem) + case PlanTypeFilterAll: + // 不添加 plan_type 的过滤条件 + default: + // 默认查询自定义 + query = query.Where("plan_type = ?", models.PlanTypeCustom) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + err := query.Limit(pageSize).Offset(offset).Order("id DESC").Find(&plans).Error + + return plans, total, err } // GetBasicPlanByID 根据ID获取计划的基本信息,不包含子计划和任务详情