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

180 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}