thread-master/apps/backend/internal/module/radar/usecase/sweep_run.go

180 lines
5.2 KiB
Go

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
}