package service import ( "encoding/json" "errors" "fmt" "strconv" "strings" "time" "github.com/google/uuid" "github.com/robfig/cron/v3" "gorm.io/gorm" "laic-backend/cache" "laic-backend/common" "laic-backend/logger" "laic-backend/model" "laic-backend/tool" "laic-backend/vo" ) type TaskService struct{} var DefaultTaskService = &TaskService{} // GetPage 任务分页列表 func (s *TaskService) GetPage(userID int64, isAdmin bool, req *vo.TaskPageReq) (*common.PageResponse[vo.TaskPlanVO], *common.BusiError) { db := common.DB.Model(&model.TaskPlan{}).Scopes(withUserFilter(userID, isAdmin)) if req.Status != "" { db = db.Where("status = ?", req.Status) } if req.ScheduleType != "" { db = db.Where("schedule_type = ?", req.ScheduleType) } if req.Keyword != "" { db = db.Where("name LIKE ?", "%"+req.Keyword+"%") } var total int64 if err := db.Count(&total).Error; err != nil { logger.ERROR("统计任务失败", err) return nil, common.ErrInternal } var tasks []model.TaskPlan if err := db.Scopes(req.Paginate).Order("created_at DESC, id DESC").Find(&tasks).Error; err != nil { logger.ERROR("查询任务列表失败", err) return nil, common.ErrInternal } latestByTaskID := make(map[string]*model.TaskExecution, len(tasks)) if len(tasks) > 0 { taskIDs := make([]string, 0, len(tasks)) for _, task := range tasks { taskIDs = append(taskIDs, task.ID) } var executions []model.TaskExecution if err := common.DB.Where("task_id IN ?", taskIDs).Order("created_at DESC, id DESC").Find(&executions).Error; err != nil { logger.ERROR("查询任务最近执行记录失败", err) return nil, common.ErrInternal } for i := range executions { exec := &executions[i] if _, exists := latestByTaskID[exec.TaskID]; !exists { latestByTaskID[exec.TaskID] = exec } } } list := make([]vo.TaskPlanVO, 0, len(tasks)) for _, task := range tasks { latest := latestByTaskID[task.ID] list = append(list, vo.TaskPlanVO{ TaskPlan: task, PlanStatus: taskPlanStatus(task.Status, latest), LatestExecution: latest, }) } return common.Page(req.Pagination, total, list), nil } func taskPlanStatus(status string, latest *model.TaskExecution) string { if status == "disabled" { return "paused" } if latest != nil && (latest.Status == "pending" || latest.Status == "running") { return "running" } return "pending" } // GetDetail 任务详情 func (s *TaskService) GetDetail(userID int64, isAdmin bool, id string) (*model.TaskPlan, *common.BusiError) { var task model.TaskPlan if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&task, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, common.ErrTaskNotFound } return nil, common.ErrInternal } return &task, nil } func validateTaskSchedule(scheduleType, scheduleCron string) *common.BusiError { if scheduleType == "once" { return nil } if scheduleType != "cron" || scheduleCron == "" { return common.NewBusiError(common.ParamError, "定时任务必须配置执行时间") } if _, err := cron.ParseStandard(scheduleCron); err != nil { return common.NewBusiError(common.ParamError, "定时执行表达式无效") } return nil } // Create 新增任务 func (s *TaskService) Create(userID int64, req *vo.TaskCreateReq) (*model.TaskPlan, *common.BusiError) { scheduleType := req.ScheduleType if scheduleType == "" { scheduleType = "once" } if busiErr := validateTaskSchedule(scheduleType, req.ScheduleCron); busiErr != nil { return nil, busiErr } videoPolicy := req.VideoPolicy if videoPolicy == "" { videoPolicy = "raw" } now := time.Now() task := &model.TaskPlan{ ID: strings.ReplaceAll(uuid.New().String(), "-", ""), UserID: userID, Name: req.Name, DockID: req.DockID, RouteID: req.RouteID, ScheduleType: scheduleType, ScheduleCron: req.ScheduleCron, VideoPolicy: videoPolicy, Status: "enabled", CreatedBy: userID, CreatedAt: now, UpdatedAt: now, } if err := common.DB.Create(task).Error; err != nil { logger.ERROR("新增任务失败", err) return nil, common.ErrInternal } DefaultScheduler.SyncTask(task.ID) return task, nil } // Update 编辑任务 func (s *TaskService) Update(userID int64, isAdmin bool, id string, req *vo.TaskUpdateReq) *common.BusiError { var task model.TaskPlan if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&task, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return common.ErrTaskNotFound } return common.ErrInternal } scheduleType := task.ScheduleType if req.ScheduleType != "" { scheduleType = req.ScheduleType } scheduleCron := task.ScheduleCron if req.ScheduleCron != nil { scheduleCron = *req.ScheduleCron } if scheduleType == "once" { scheduleCron = "" } if busiErr := validateTaskSchedule(scheduleType, scheduleCron); busiErr != nil { return busiErr } if req.Status != "" && req.Status != "draft" && req.Status != "enabled" && req.Status != "disabled" { return common.NewBusiError(common.ParamError, "任务状态无效") } updates := map[string]any{"updated_at": time.Now()} if req.Name != "" { updates["name"] = req.Name } if req.DockID != "" { updates["dock_id"] = req.DockID } if req.RouteID != nil { updates["route_id"] = *req.RouteID } if req.ScheduleType != "" { updates["schedule_type"] = req.ScheduleType } if req.ScheduleCron != nil { updates["schedule_cron"] = *req.ScheduleCron } if req.ScheduleType != "" && req.ScheduleType == "once" { updates["schedule_cron"] = "" } if req.VideoPolicy != "" { updates["video_policy"] = req.VideoPolicy } if req.Status != "" { updates["status"] = req.Status } if err := common.DB.Model(&task).Updates(updates).Error; err != nil { logger.ERROR("更新任务失败", err) return common.ErrInternal } DefaultScheduler.SyncTask(id) return nil } // Delete 删除任务 func (s *TaskService) Delete(userID int64, isAdmin bool, id string) *common.BusiError { res := common.DB.Scopes(withUserFilter(userID, isAdmin)).Delete(&model.TaskPlan{}, "id = ?", id) if res.Error != nil { logger.ERROR("删除任务失败", res.Error) return common.ErrInternal } if res.RowsAffected == 0 { return common.ErrTaskNotFound } DefaultScheduler.RemoveTask(id) return nil } // Execute 立即执行任务:落库执行记录 + 下发 workflow.start_task 指令 func (s *TaskService) Execute(userID int64, isAdmin bool, id string) (*vo.TaskExecuteVO, *common.BusiError) { var task model.TaskPlan if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&task, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { logger.WARN("执行任务失败,任务不存在:", id) return nil, common.ErrTaskNotFound } logger.ERROR("查询待执行任务失败", err) return nil, common.ErrInternal } if task.Status == "disabled" { logger.WARN("执行任务失败,任务已禁用:", id) return nil, common.ErrTaskStateErr } online, _ := common.SetMemberExists(cache.OnlineDockSetKey, task.DockID) if !online { logger.WARN("执行任务失败,机巢离线:", task.DockID, "任务:", id) return nil, common.ErrDockOffline } commandID, err := tool.NextID() if err != nil { return nil, common.ErrInternal } execID, err := tool.NextID() if err != nil { return nil, common.ErrInternal } requestID := strings.ReplaceAll(uuid.New().String(), "-", "") now := time.Now() params := map[string]any{ "taskId": task.ID, "missionId": strings.ReplaceAll(uuid.New().String(), "-", ""), "executionId": strconv.FormatInt(execID, 10), "recordOriginalVideo": task.VideoPolicy == "raw", } if task.RouteID != 0 { var waypoints []model.RouteWaypoint if err := common.DB.Where("route_id = ?", task.RouteID).Order("seq ASC").Find(&waypoints).Error; err != nil { logger.ERROR("查询航线航点失败", err) return nil, common.ErrInternal } payload := make([]map[string]any, 0, len(waypoints)) for i, wp := range waypoints { payload = append(payload, map[string]any{ "seq": i, "longitude": wp.Longitude, "latitude": wp.Latitude, "altitude": wp.Altitude, "speed": wp.Speed, "yaw": wp.Yaw, "holdSec": wp.HoldSec, }) } params["waypoints"] = payload } var originalVideo *model.Video if task.VideoPolicy == "raw" { videoID, err := tool.NextID() if err != nil { return nil, common.ErrInternal } expireAt := now.Add(10 * time.Minute) originalVideo = &model.Video{ ID: videoID, UserID: task.UserID, ExecutionID: execID, FileName: fmt.Sprintf("execution-%d-original.mp4", execID), OssKey: fmt.Sprintf("media/%d/execution-%d-original.mp4", videoID, execID), OssBucket: common.AppConf.OSS.Bucket, Status: "pending", UploadExpireAt: &expireAt, CreatedAt: now, } uploadURL, err := common.PresignOSS("PUT", originalVideo.OssKey, 600) if err != nil { logger.ERROR("生成原始视频上传地址失败", err) return nil, common.ErrInternal } params["originalVideo"] = map[string]any{ "videoId": strconv.FormatInt(videoID, 10), "executionId": strconv.FormatInt(execID, 10), "uploadUrl": uploadURL, "uploadExpireAt": expireAt.UnixMilli(), "fileName": originalVideo.FileName, "contentType": "video/mp4", } } paramsJSON := "" if b, e := json.Marshal(params); e == nil { paramsJSON = string(b) } cmdLog := &model.DeviceCommandLog{ ID: commandID, DockID: task.DockID, CommandType: "workflow.start_task", Params: paramsJSON, RequestID: requestID, TTLMs: 30000, Status: "sent", SentAt: &now, CreatedAt: now, } exec := &model.TaskExecution{ ID: execID, TaskID: task.ID, CommandID: strconv.FormatInt(commandID, 10), DockID: task.DockID, Status: "pending", TrajectoryJSON: json.RawMessage("[]"), CreatedAt: now, } if err := common.DB.Transaction(func(tx *gorm.DB) error { if err := tx.Create(cmdLog).Error; err != nil { return err } if err := tx.Create(exec).Error; err != nil { return err } if originalVideo != nil { return tx.Create(originalVideo).Error } return nil }); err != nil { logger.ERROR("创建执行记录失败", err) return nil, common.ErrInternal } var drone model.Drone _ = common.DB.Where("dock_id = ?", task.DockID).First(&drone).Error DefaultTrajectoryStore.Begin(task.DockID, execID, drone.DroneSN) if err := DefaultCommandService.publish(cmdLog, params); err != nil { logger.ERROR("下发任务指令失败", err) return nil, common.ErrInternal } return &vo.TaskExecuteVO{Execution: exec, Command: cmdLog}, nil }