package services import ( "context" "crypto/sha256" "encoding/hex" "fmt" "time" "git.pengzhan.dev/haopengzhan/rsspipe/internal/db" "git.pengzhan.dev/haopengzhan/rsspipe/internal/events" "git.pengzhan.dev/haopengzhan/rsspipe/internal/logger" "git.pengzhan.dev/haopengzhan/rsspipe/internal/models" "github.com/google/uuid" "go.uber.org/zap" ) type JobService struct { db *db.DB bus *events.EventBus log *zap.Logger } func NewJobService(database *db.DB, bus *events.EventBus) *JobService { return &JobService{ db: database, bus: bus, log: logger.Named("job-service"), } } // GenerateIdempotencyKey creates a deterministic key to prevent duplicate jobs func GenerateIdempotencyKey(jobType, entityID, action string, extra string) string { raw := fmt.Sprintf("%s:%s:%s:%s", jobType, entityID, action, extra) h := sha256.Sum256([]byte(raw)) return hex.EncodeToString(h[:16]) } // Enqueue submits a job to the persistent SQLite queue. // If an idempotency key is provided and a job already exists with that key, returns the existing job. func (s *JobService) Enqueue(ctx context.Context, jobType string, payload map[string]interface{}, idempotencyKey string) (*models.Job, error) { if idempotencyKey != "" { if existing, err := s.db.GetJobByIdempotencyKey(idempotencyKey); err == nil && existing != nil { s.log.Debug("Job already exists with idempotency key, skipping duplicate enqueue", zap.String("job_id", existing.ID), zap.String("type", jobType), zap.String("key", idempotencyKey), ) return existing, nil } } job := models.Job{ ID: uuid.New().String(), Type: jobType, Payload: payload, Status: "pending", Progress: 0, Attempts: 0, MaxAttempts: 3, IdempotencyKey: idempotencyKey, RunAt: time.Now().UTC(), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } created, err := s.db.CreateJob(job) if err != nil { return nil, fmt.Errorf("failed to enqueue job: %w", err) } s.log.Info("Job enqueued", zap.String("job_id", created.ID), zap.String("type", created.Type), zap.String("idempotency_key", idempotencyKey), ) if s.bus != nil { s.bus.Publish(ctx, events.Event{ Type: events.EventJobProgress, Job: created, }) } return created, nil } func (s *JobService) GetJob(ctx context.Context, id string) (*models.Job, error) { return s.db.GetJob(id) } func (s *JobService) ListJobs(ctx context.Context, filter models.JobFilter) ([]models.Job, error) { return s.db.ListJobs(filter) } func (s *JobService) CancelJob(ctx context.Context, id string) error { err := s.db.CancelJob(id) if err == nil && s.bus != nil { if j, err := s.db.GetJob(id); err == nil { s.bus.Publish(ctx, events.Event{ Type: events.EventJobFailed, Job: j, }) } } return err } func (s *JobService) RetryJob(ctx context.Context, id string) error { j, err := s.db.GetJob(id) if err != nil { return err } j.Status = "pending" j.ErrorMessage = "" j.Attempts = 0 j.RunAt = time.Now().UTC() err = s.db.UpdateJob(*j) if err == nil && s.bus != nil { s.bus.Publish(ctx, events.Event{ Type: events.EventJobProgress, Job: j, }) } return err }