You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
372 lines
11 KiB
372 lines
11 KiB
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").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
|
|
}
|
|
|