180 lines
5.2 KiB
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
|
||
|
|
}
|