2026-08-03 05:52:02 +00:00
|
|
|
|
package domain
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
WatchActive = "active"
|
|
|
|
|
|
WatchPaused = "paused"
|
|
|
|
|
|
WatchArchived = "archived"
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
MaxWatchTerms = 20
|
|
|
|
|
|
MaxWatchExcludeTerms = 30
|
|
|
|
|
|
MaxTermLen = 60
|
|
|
|
|
|
MinTermLen = 2
|
|
|
|
|
|
WatchContextGeneric = "generic"
|
|
|
|
|
|
WatchContextProduct = "product"
|
|
|
|
|
|
PauseReasonUser = "user"
|
|
|
|
|
|
PauseReasonProductUnavailable = "product_unavailable"
|
|
|
|
|
|
PauseReasonBrandUnavailable = "brand_unavailable"
|
2026-08-03 05:52:02 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
RadarWatch 是常駐關鍵字監控。每日排程只撈 active 的。
|
|
|
|
|
|
|
|
|
|
|
|
regions 留空代表沿用服務檔案的地區 —— 這裡刻意不把服務檔案的值複製進來,
|
|
|
|
|
|
否則之後改服務檔案,舊訂閱會繼續用舊地區判定,而使用者不會知道。
|
|
|
|
|
|
*/
|
|
|
|
|
|
type RadarWatch struct {
|
2026-08-13 02:22:24 +00:00
|
|
|
|
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"`
|
|
|
|
|
|
ContextMode string `bson:"context_mode,omitempty" json:"context_mode,omitempty"`
|
|
|
|
|
|
BrandID string `bson:"brand_id,omitempty" json:"brand_id,omitempty"`
|
|
|
|
|
|
ProductID string `bson:"product_id,omitempty" json:"product_id,omitempty"`
|
|
|
|
|
|
BrandNameSnapshot string `bson:"brand_name_snapshot,omitempty" json:"brand_name_snapshot,omitempty"`
|
|
|
|
|
|
ProductLabelSnapshot string `bson:"product_label_snapshot,omitempty" json:"product_label_snapshot,omitempty"`
|
|
|
|
|
|
ContextBoundAt int64 `bson:"context_bound_at,omitempty" json:"context_bound_at,omitempty"`
|
|
|
|
|
|
PauseReason string `bson:"pause_reason,omitempty" json:"pause_reason,omitempty"`
|
|
|
|
|
|
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"`
|
2026-08-06 14:50:36 +00:00
|
|
|
|
|
|
|
|
|
|
// FirstSweepTriggered 是建立當下的一次性狀態,不落庫:只用來讓前端在
|
|
|
|
|
|
// 使用者第一組訂閱剛好排入首巡時顯示「首巡進行中」提示。
|
|
|
|
|
|
FirstSweepTriggered bool `bson:"-" json:"-"`
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type WatchListFilter struct {
|
2026-08-13 02:22:24 +00:00
|
|
|
|
Status string
|
|
|
|
|
|
ContextMode string
|
|
|
|
|
|
BrandID string
|
|
|
|
|
|
ProductID string
|
|
|
|
|
|
Page int
|
|
|
|
|
|
PageSize int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func IsWatchContext(s string) bool { return s == WatchContextGeneric || s == WatchContextProduct }
|
|
|
|
|
|
|
|
|
|
|
|
func IsWatchPauseReason(s string) bool {
|
|
|
|
|
|
return s == "" || s == PauseReasonUser || s == PauseReasonProductUnavailable || s == PauseReasonBrandUnavailable
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NormalizeContext makes pre-product documents read as generic and validates
|
|
|
|
|
|
// the all-or-nothing product identity invariant.
|
|
|
|
|
|
func (w *RadarWatch) NormalizeContext() error {
|
|
|
|
|
|
if w.ContextMode == "" {
|
|
|
|
|
|
w.ContextMode = WatchContextGeneric
|
|
|
|
|
|
}
|
|
|
|
|
|
if !IsWatchContext(w.ContextMode) {
|
|
|
|
|
|
return fmt.Errorf("%w: unknown context_mode %q", ErrValidation, w.ContextMode)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !IsWatchPauseReason(w.PauseReason) {
|
|
|
|
|
|
return fmt.Errorf("%w: unknown pause_reason %q", ErrValidation, w.PauseReason)
|
|
|
|
|
|
}
|
|
|
|
|
|
if w.ContextMode == WatchContextGeneric {
|
|
|
|
|
|
if w.BrandID != "" || w.ProductID != "" {
|
|
|
|
|
|
return fmt.Errorf("%w: generic watch cannot carry product IDs", ErrValidation)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(w.BrandID) == "" || strings.TrimSpace(w.ProductID) == "" {
|
|
|
|
|
|
return fmt.Errorf("%w: product watch requires brand_id and product_id", ErrValidation)
|
|
|
|
|
|
}
|
|
|
|
|
|
if w.ContextBoundAt <= 0 {
|
|
|
|
|
|
w.ContextBoundAt = w.UpdatedAt
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// BindProduct is one-way: replacing a product would make historical sweeps ambiguous.
|
|
|
|
|
|
func (w *RadarWatch) BindProduct(brandID, productID, brandSnapshot, productSnapshot string, at int64) error {
|
|
|
|
|
|
if err := w.NormalizeContext(); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if w.ContextMode != WatchContextGeneric || w.BrandID != "" || w.ProductID != "" {
|
|
|
|
|
|
return fmt.Errorf("%w: watch product context is already bound", ErrValidation)
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(brandID) == "" || strings.TrimSpace(productID) == "" {
|
|
|
|
|
|
return fmt.Errorf("%w: brand_id and product_id must be provided together", ErrValidation)
|
|
|
|
|
|
}
|
|
|
|
|
|
w.ContextMode, w.BrandID, w.ProductID = WatchContextProduct, strings.TrimSpace(brandID), strings.TrimSpace(productID)
|
|
|
|
|
|
w.BrandNameSnapshot, w.ProductLabelSnapshot, w.ContextBoundAt = strings.TrimSpace(brandSnapshot), strings.TrimSpace(productSnapshot), at
|
|
|
|
|
|
w.UpdatedAt = at
|
|
|
|
|
|
return nil
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
if to == WatchActive && (w.PauseReason == PauseReasonProductUnavailable || w.PauseReason == PauseReasonBrandUnavailable) {
|
|
|
|
|
|
return fmt.Errorf("%w: unavailable product watch must be archived and recreated", ErrValidation)
|
|
|
|
|
|
}
|
2026-08-03 05:52:02 +00:00
|
|
|
|
w.Status = to
|
2026-08-13 02:22:24 +00:00
|
|
|
|
if to == WatchActive {
|
|
|
|
|
|
w.PauseReason = ""
|
|
|
|
|
|
}
|
|
|
|
|
|
if to == WatchPaused && w.PauseReason == "" {
|
|
|
|
|
|
w.PauseReason = PauseReasonUser
|
|
|
|
|
|
}
|
2026-08-03 05:52:02 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
if err := w.NormalizeContext(); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-08-03 05:52:02 +00:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeTerms(in []string, field string, max int) ([]string, error) {
|
2026-08-13 07:54:25 +00:00
|
|
|
|
if field == "terms" {
|
|
|
|
|
|
out := ExpandSearchTerms(in, max)
|
|
|
|
|
|
if len(out) > max {
|
|
|
|
|
|
return nil, fmt.Errorf("%w: %s exceeds %d items", ErrValidation, field, max)
|
|
|
|
|
|
}
|
|
|
|
|
|
return out, nil
|
|
|
|
|
|
}
|
2026-08-03 05:52:02 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|