package usecase import ( "context" "fmt" "time" "apps/backend/internal/module/radar/domain" ) // DailySweepHourUTC is the fixed daily schedule (spec §4.2:UTC 22:00 =台北 06:00)。 const DailySweepHourUTC = 22 // SweepJobScheduler schedules one radar_sweep job per watch per UTC day. // Implemented by a thin adapter over job usecase.Service (see worker / service_context). type SweepJobScheduler interface { ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (jobID string, err error) } // SweepJobSchedulerFunc adapts a function to SweepJobScheduler. type SweepJobSchedulerFunc func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (jobID string, err error) func (f SweepJobSchedulerFunc) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) { return f(ctx, ownerUID, watchID, runAt) } // ScheduleDailySweeps 在已過當日 UTC 22:00 時,為每個 active watch 建一筆 radar_sweep Job。 // // 呼叫端必須已持有 worker maintenance Redis lock(value = workerID), // 本函式本身不做分散式鎖;未過 22:00 時回 0 且不建 Job。 // // 回傳建立(或已存在而回傳)的 job 數。 func (s *Service) ScheduleDailySweeps(ctx context.Context, now time.Time) (int, error) { if s.SweepJobs == nil { return 0, fmt.Errorf("%w: sweep job scheduler not configured", domain.ErrNotReady) } if now.IsZero() { now = time.Now().UTC() } else { now = now.UTC() } if !PastDailySweepSlot(now) { return 0, nil } runAt := DailySweepRunAt(now) watches, err := s.Repo.ListAllActiveWatches(ctx) if err != nil { return 0, err } n := 0 for _, w := range watches { if w == nil || w.Status != domain.WatchActive { continue } if _, err := s.SweepJobs.ScheduleRadarSweep(ctx, w.OwnerUID, w.ID, runAt); err != nil { return n, fmt.Errorf("schedule watch %s owner %d: %w", w.ID, w.OwnerUID, err) } n++ } return n, nil } // PastDailySweepSlot reports whether now is at or after today's UTC 22:00. func PastDailySweepSlot(now time.Time) bool { now = now.UTC() slot := time.Date(now.Year(), now.Month(), now.Day(), DailySweepHourUTC, 0, 0, 0, time.UTC) return !now.Before(slot) } // DailySweepRunAt returns unix-ns for today's UTC 22:00 (the slot being scheduled). func DailySweepRunAt(now time.Time) int64 { now = now.UTC() slot := time.Date(now.Year(), now.Month(), now.Day(), DailySweepHourUTC, 0, 0, 0, time.UTC) return slot.UnixNano() } // BeginSweepRecord creates the RadarSweep shell for a claimed job (T527). // Fetch / judge (T528–T530) attach progress onto the same record via UpdateSweep. func (s *Service) BeginSweepRecord(ctx context.Context, ownerUID int64, watchID, jobID, path string) (*domain.RadarSweep, error) { if ownerUID <= 0 || watchID == "" { return nil, fmt.Errorf("%w: owner_uid and watch_id required", domain.ErrValidation) } if path == "" { path = domain.SweepPathAPI } sw := &domain.RadarSweep{ ID: domain.NewID(), OwnerUID: ownerUID, WatchID: watchID, JobID: jobID, Path: path, StartedAt: domain.NowNano(), } if err := sw.Normalize(); err != nil { return nil, err } if err := s.Repo.CreateSweep(ctx, sw); err != nil { return nil, err } return sw, nil }