package usecase import ( "context" "fmt" "strings" "apps/backend/internal/module/radar/domain" ) // SweepRunResult is the outcome of one radar_sweep job execution. type SweepRunResult struct { Sweep *domain.RadarSweep Created int Judged int Truncated int FailedJudges int FetchFailed bool FailedReason string } // Notifier sends in-app alerts for sweep failures. type SweepNotifier interface { NotifySweepFailed(ctx context.Context, ownerUID int64, sweepID, watchID, reason string) error } /* RunSweep is the full pipeline: create/reuse Sweep → fetch → judge → persist → finish. Fetch failure: Sweep failed_reason set, job should fail, notify user — never empty success. Partial judge failures: Sweep still succeeds with counters. Resume: skip external_ids already on the Sweep record. */ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID string) (*SweepRunResult, error) { if ownerUID <= 0 || watchID == "" { return nil, fmt.Errorf("%w: owner_uid and watch_id required", domain.ErrValidation) } w, err := s.Repo.GetWatch(ctx, watchID) if err != nil { return nil, err } if w.OwnerUID != ownerUID { return nil, domain.ErrForbidden } if w.Status != domain.WatchActive { return nil, fmt.Errorf("%w: only active watches can be swept (status=%s)", domain.ErrValidation, w.Status) } profile, err := s.Repo.GetServiceProfile(ctx, ownerUID) if err != nil { return nil, fmt.Errorf("%w: service profile required for sweep", domain.ErrValidation) } // Resume: if a sweep already exists for this job, reuse it. var sw *domain.RadarSweep if jobID != "" { if existing, gerr := s.Repo.GetSweepByJobID(ctx, jobID); gerr == nil && existing != nil { sw = existing } } if sw == nil { sw, err = s.BeginSweepRecord(ctx, ownerUID, watchID, jobID, domain.SweepPathAPI) if err != nil { return nil, err } } already := map[string]bool{} for _, id := range sw.JudgedExternalIDs { already[id] = true } cands, path, fetchCredits, ferr := s.FetchCandidates(ctx, ownerUID, w, 40) if ferr != nil { reason := humanFetchError(ferr) end := domain.NowNano() _, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{ FailedReason: &reason, EndedAt: end, CreditsUsed: fetchCredits, }) _ = s.notifySweepFailed(ctx, ownerUID, sw.ID, watchID, reason) sw, _ = s.Repo.GetSweep(ctx, sw.ID) return &SweepRunResult{Sweep: sw, FetchFailed: true, FailedReason: reason}, ferr } _, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{ HitCount: len(cands), CreditsUsed: fetchCredits, }) // set path on record if path != "" && sw.Path != path { sw.Path = path // path is not in SweepDelta; re-save via create is wrong — store via failed empty update isn't enough. // Use UpdateSweep only for counters; path was set at Begin — recreate if needed. if sw.Path == domain.SweepPathAPI && path == domain.SweepPathCrawler { // best-effort: include in failed_reason empty path note not needed; set via full get/update memory _ = s.setSweepPath(ctx, sw.ID, path) } } created, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates( ctx, ownerUID, w, profile, sw.ID, cands, already, ) if perr != nil { reason := "判定流程失敗:" + perr.Error() end := domain.NowNano() _, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{FailedReason: &reason, EndedAt: end}) _ = s.notifySweepFailed(ctx, ownerUID, sw.ID, watchID, reason) return nil, perr } end := domain.NowNano() var failPtr *string if failed > 0 && created == 0 && judged > 0 { r := fmt.Sprintf("%d 筆判定失敗", failed) failPtr = &r } sw, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{ CreditsUsed: judgeCredits, EndedAt: end, FailedReason: failPtr, }) _ = s.Repo.TouchWatchSweptAt(ctx, watchID, end) return &SweepRunResult{ Sweep: sw, Created: created, Judged: judged, Truncated: truncated, FailedJudges: failed, }, nil } func (s *Service) setSweepPath(ctx context.Context, id, path string) error { sw, err := s.Repo.GetSweep(ctx, id) if err != nil { return err } sw.Path = path // Memory/mongo lack ReplaceSweep — use UpdateSweep no-op + store path only on create. // For mongo, UpdateOne $set path: type pathSetter interface { SetSweepPath(ctx context.Context, id, path string) error } if ps, ok := s.Repo.(pathSetter); ok { return ps.SetSweepPath(ctx, id, path) } // memory: mutate via UpdateSweep zero + re-get won't change path; patch memory map if possible if m, ok := s.Repo.(interface { PatchSweepPath(ctx context.Context, id, path string) error }); ok { return m.PatchSweepPath(ctx, id, path) } _ = sw return nil } func (s *Service) notifySweepFailed(ctx context.Context, ownerUID int64, sweepID, watchID, reason string) error { if s.Notifier == nil { return nil } return s.Notifier.NotifySweepFailed(ctx, ownerUID, sweepID, watchID, reason) } func humanFetchError(err error) string { if err == nil { return "抓取失敗" } msg := err.Error() // never include token-like blobs if strings.Contains(strings.ToLower(msg), "bearer ") { return "抓取路徑不可用" } if len(msg) > 200 { msg = msg[:200] } return "今天沒巡到:" + msg }