package projects import ( "errors" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" "senlinai-agent/backend/internal/models" ) var ( ErrTagNameRequired = errors.New("tag name is required") ErrCronTitleRequired = errors.New("cron plan title is required") ErrCronScheduleRequired = errors.New("cron schedule is required") ) func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronPlanInput) (*models.SenlinAgentCronPlan, error) { if err := ensureProjectOwner(ownerID, projectID); err != nil { return nil, err } title := strings.TrimSpace(input.Title) if title == "" { return nil, ErrCronTitleRequired } schedule := strings.TrimSpace(input.Schedule) if schedule == "" { return nil, ErrCronScheduleRequired } plan := &models.SenlinAgentCronPlan{ ProjectID: projectID, CreatedBy: ownerID, Title: title, Schedule: schedule, Enabled: input.Enabled, NextRunAt: input.NextRunAt, } return plan, models.DBService.Create(plan).Error } func (s *Service) CreateProjectTag(ownerID uint, projectID uint, name string) (*models.SenlinAgentTag, error) { if err := ensureProjectOwner(ownerID, projectID); err != nil { return nil, err } return s.CreateTag(projectID, name) } func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) { name = strings.TrimSpace(name) if name == "" { return nil, ErrTagNameRequired } var lastErr error for attempt := 0; attempt < 5; attempt++ { candidate := models.SenlinAgentTag{ProjectID: projectID, Name: name} lastErr = models.DBService.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "project_id"}, {Name: "name"}}, DoNothing: true, }).Create(&candidate).Error if lastErr == nil { var stored models.SenlinAgentTag lastErr = models.DBService.Where("project_id = ? AND name = ?", projectID, name).First(&stored).Error if lastErr == nil { return &stored, nil } } if !retryableTagWrite(lastErr) { return nil, lastErr } time.Sleep(time.Duration(attempt+1) * time.Millisecond) } return nil, lastErr } func retryableTagWrite(err error) bool { if errors.Is(err, gorm.ErrDuplicatedKey) || errors.Is(err, gorm.ErrRecordNotFound) || strings.Contains(strings.ToLower(err.Error()), "database is locked") { return true } var sqlState interface{ SQLState() string } if errors.As(err, &sqlState) { switch sqlState.SQLState() { case "40001", "40P01", "55P03": return true } } return false } func (s *Service) ListProjectTags(ownerID uint, projectID uint) ([]models.SenlinAgentTag, error) { if err := ensureProjectOwner(ownerID, projectID); err != nil { return nil, err } return s.ListTags(projectID) } func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) { var tags []models.SenlinAgentTag err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error return tags, err } func ensureProjectOwner(ownerID uint, projectID uint) error { var count int64 if err := models.DBService.Model(&models.SenlinAgentProject{}).Where("id = ? AND owner_id = ?", projectID, ownerID).Count(&count).Error; err != nil { return err } if count == 0 { return gorm.ErrRecordNotFound } return nil }