thread-master/apps/backend/internal/module/radar/domain/watch.go

180 lines
4.9 KiB
Go
Raw Normal View History

2026-08-03 05:52:02 +00:00
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.1active 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
}