package domain import ( "fmt" "strings" ) // Sweep fetch path — reuses the dual-path split (api | crawler); no third path. const ( SweepPathAPI = "api" SweepPathCrawler = "crawler" ) /* RadarSweep 是一次每日巡(或手動觸發)的執行紀錄。 計數以累加更新為主:Job 中途失敗時已寫入的進度保留,供續跑判斷 (judged_external_ids 標記已判過的貼文,避免重跑)。 */ type RadarSweep struct { ID string `bson:"_id" json:"id"` OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` WatchID string `bson:"watch_id" json:"watch_id"` JobID string `bson:"job_id,omitempty" json:"job_id,omitempty"` Path string `bson:"path" json:"path"` HitCount int `bson:"hit_count" json:"hit_count"` JudgedCount int `bson:"judged_count" json:"judged_count"` CreatedCount int `bson:"created_count" json:"created_count"` TruncatedCount int `bson:"truncated_count" json:"truncated_count"` FailedReason string `bson:"failed_reason,omitempty" json:"failed_reason,omitempty"` CreditsUsed int `bson:"credits_used" json:"credits_used"` JudgedExternalIDs []string `bson:"judged_external_ids,omitempty" json:"judged_external_ids,omitempty"` StartedAt int64 `bson:"started_at" json:"started_at"` EndedAt int64 `bson:"ended_at,omitempty" json:"ended_at,omitempty"` } // SweepDelta is an incremental progress patch applied with $inc / $addToSet. type SweepDelta struct { HitCount int JudgedCount int CreatedCount int TruncatedCount int CreditsUsed int JudgedExternalIDs []string FailedReason *string // nil = leave unchanged; non-nil (incl. empty) = set EndedAt int64 // 0 = leave unchanged } // SweepListFilter pages sweeps for an owner, optionally scoped to one watch. type SweepListFilter struct { WatchID string Page int PageSize int } func IsSweepPath(s string) bool { switch s { case SweepPathAPI, SweepPathCrawler: return true } return false } func (s *RadarSweep) Normalize() error { if s.OwnerUID <= 0 { return fmt.Errorf("%w: owner_uid required", ErrValidation) } if strings.TrimSpace(s.WatchID) == "" { return fmt.Errorf("%w: watch_id required", ErrValidation) } if s.Path == "" { s.Path = SweepPathAPI } if !IsSweepPath(s.Path) { return fmt.Errorf("%w: unknown sweep path %q", ErrValidation, s.Path) } // failed_reason must never hold tokens; strip obvious bearer-like blobs is out of scope — // callers are responsible. We only reject empty path/owner. if s.FailedReason != "" { s.FailedReason = strings.TrimSpace(s.FailedReason) } s.JudgedExternalIDs = dedupeStrings(s.JudgedExternalIDs) return nil } // MergeJudgedExternalIDs appends new IDs without duplicates (order preserved). func MergeJudgedExternalIDs(existing, add []string) []string { return dedupeStrings(append(append([]string{}, existing...), add...)) }