package domain import ( "fmt" "strings" ) const ( WatchActive = "active" WatchPaused = "paused" WatchArchived = "archived" MaxWatchTerms = 20 MaxWatchExcludeTerms = 30 MaxTermLen = 60 MinTermLen = 2 ) /* RadarWatch 是常駐關鍵字監控。每日排程只撈 active 的。 regions 留空代表沿用服務檔案的地區 —— 這裡刻意不把服務檔案的值複製進來, 否則之後改服務檔案,舊訂閱會繼續用舊地區判定,而使用者不會知道。 */ type RadarWatch struct { ID string `bson:"_id" json:"id"` OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` Terms []string `bson:"terms" json:"terms"` ExcludeTerms []string `bson:"exclude_terms" json:"exclude_terms"` Regions []string `bson:"regions" json:"regions"` Status string `bson:"status" json:"status"` LastSweptAt int64 `bson:"last_swept_at,omitempty" json:"last_swept_at,omitempty"` CreatedAt int64 `bson:"created_at" json:"created_at"` UpdatedAt int64 `bson:"updated_at" json:"updated_at"` } type WatchListFilter struct { Status string Page int PageSize int } func IsWatchStatus(s string) bool { switch s { case WatchActive, WatchPaused, WatchArchived: return true } return false } /* CanTransitionWatch 實作 spec §3.1:active ↔ paused 可往返,兩者都能封存, archived 是終態。 終態不可逆是刻意的:封存後歷史商機與統計都還留著,如果允許復活, 「這批統計是哪個訂閱在什麼期間跑出來的」就會失去單一解釋。要再監控同一組 關鍵字請建新的訂閱。 */ func CanTransitionWatch(from, to string) bool { if from == to { return true } switch from { case WatchActive: return to == WatchPaused || to == WatchArchived case WatchPaused: return to == WatchActive || to == WatchArchived default: return false } } func (w *RadarWatch) Transition(to string) error { if !IsWatchStatus(to) { return fmt.Errorf("%w: unknown watch status %q", ErrValidation, to) } if !CanTransitionWatch(w.Status, to) { if w.Status == WatchArchived { return fmt.Errorf("%w: archived watch cannot become %s; create a new watch instead", ErrValidation, to) } return fmt.Errorf("%w: cannot change watch from %s to %s", ErrValidation, w.Status, to) } w.Status = to w.UpdatedAt = NowNano() return nil } /* Normalize 正規化關鍵字並驗證。 term 一律 lower-case 存放:Threads 搜尋不分大小寫,若不正規化,「Wedding」與 「wedding」會被當成兩個 term,之後 T556 的關鍵字轉換率就會把同一個詞拆成兩列。 */ func (w *RadarWatch) Normalize() error { if w.OwnerUID <= 0 { return fmt.Errorf("%w: owner_uid required", ErrValidation) } terms, err := normalizeTerms(w.Terms, "terms", MaxWatchTerms) if err != nil { return err } if len(terms) == 0 { return fmt.Errorf("%w: terms required", ErrValidation) } w.Terms = terms excludes, err := normalizeTerms(w.ExcludeTerms, "exclude_terms", MaxWatchExcludeTerms) if err != nil { return err } w.ExcludeTerms = excludes // 同一個詞同時要與不要,等於這個訂閱永遠不會命中任何東西。 excludeSet := map[string]bool{} for _, e := range excludes { excludeSet[e] = true } for _, t := range terms { if excludeSet[t] { return fmt.Errorf("%w: %q is in both terms and exclude_terms", ErrValidation, t) } } regions := make([]string, 0, len(w.Regions)) seen := map[string]bool{} for _, r := range w.Regions { code := strings.ToUpper(strings.TrimSpace(r)) if code == "" { continue } if !IsServiceAreaCode(code) { return fmt.Errorf("%w: regions contains unknown code %q", ErrValidation, code) } if seen[code] { continue } seen[code] = true regions = append(regions, code) } w.Regions = regions if w.Status == "" { w.Status = WatchActive } if !IsWatchStatus(w.Status) { return fmt.Errorf("%w: unknown watch status %q", ErrValidation, w.Status) } return nil } func normalizeTerms(in []string, field string, max int) ([]string, error) { out := make([]string, 0, len(in)) seen := map[string]bool{} for _, raw := range in { // 全形空白也要收掉:中文輸入法很容易打出來,而它不會命中任何東西。 t := strings.TrimSpace(strings.ReplaceAll(raw, "\u3000", " ")) t = strings.Join(strings.Fields(t), " ") if t == "" { continue } t = strings.ToLower(t) if len([]rune(t)) < MinTermLen { return nil, fmt.Errorf("%w: %s contains a term shorter than %d characters (%q)", ErrValidation, field, MinTermLen, t) } if len([]rune(t)) > MaxTermLen { return nil, fmt.Errorf("%w: %s contains a term longer than %d characters", ErrValidation, field, MaxTermLen) } if seen[t] { continue } seen[t] = true out = append(out, t) } if len(out) > max { return nil, fmt.Errorf("%w: %s exceeds %d items", ErrValidation, field, max) } return out, nil }