package domain import ( "fmt" "strings" "time" ) // Opportunity status — spec §3.2. const ( OppJudging = "judging" OppQualified = "qualified" OppRejected = "rejected" OppAccepted = "accepted" OppDismissed = "dismissed" ) // Intent band thresholds are locked by spec §3.3 (80 / 50). Do not change here. const ( BandHigh = "high" BandMid = "mid" BandLow = "low" BandHighMinScore = 80 BandMidMinScore = 50 ) // Region match is three-state; unknown is not mismatch (OP-04). const ( RegionMatch = "match" RegionMismatch = "mismatch" RegionUnknown = "unknown" ) // Opportunity sources. const ( OppSourceThreads = "threads" OppSourceManual = "manual" OppSourceScoutPromote = "scout_promote" ) // Five judge dimensions — reasons[] must include every one (OP-06). const ( DimAuthenticity = "authenticity" DimIntent = "intent" DimRegion = "region" DimFreshness = "freshness" DimFit = "fit" ) // RequiredReasonDimensions is the fixed set that must all be present to persist. var RequiredReasonDimensions = []string{ DimAuthenticity, DimIntent, DimRegion, DimFreshness, DimFit, } // OpportunityReason is one scored dimension of the five-question judge. type OpportunityReason struct { Dimension string `bson:"dimension" json:"dimension"` Score int `bson:"score" json:"score"` Reason string `bson:"reason" json:"reason"` } // OpportunityOverride records a human band/status correction for later calibration. type OpportunityOverride struct { FromBand string `bson:"from_band,omitempty" json:"from_band,omitempty"` ToBand string `bson:"to_band,omitempty" json:"to_band,omitempty"` FromStatus string `bson:"from_status,omitempty" json:"from_status,omitempty"` ToStatus string `bson:"to_status,omitempty" json:"to_status,omitempty"` ActorUID int64 `bson:"actor_uid" json:"actor_uid"` At int64 `bson:"at" json:"at"` } /* Opportunity 是經五問判定後的商機。 同一 owner 下 external_id 唯一:跨 watch 命中同一貼文只留一筆,新觸發 term 併入 matched_terms,不重跑判定(SW-06)。 */ type Opportunity struct { ID string `bson:"_id" json:"id"` OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` WatchID string `bson:"watch_id,omitempty" json:"watch_id,omitempty"` Source string `bson:"source" json:"source"` SourceScoutPostID string `bson:"source_scout_post_id,omitempty" json:"source_scout_post_id,omitempty"` ExternalID string `bson:"external_id" json:"external_id"` Permalink string `bson:"permalink" json:"permalink"` AuthorHandle string `bson:"author_handle" json:"author_handle"` Text string `bson:"text" json:"text"` PostedAt int64 `bson:"posted_at" json:"posted_at"` Status string `bson:"status" json:"status"` IntentScore int `bson:"intent_score" json:"intent_score"` IntentBand string `bson:"intent_band" json:"intent_band"` Reasons []OpportunityReason `bson:"reasons" json:"reasons"` RegionDetected string `bson:"region_detected,omitempty" json:"region_detected,omitempty"` RegionMatch string `bson:"region_match" json:"region_match"` FreshnessHours int `bson:"freshness_hours" json:"freshness_hours"` MatchedService string `bson:"matched_service,omitempty" json:"matched_service,omitempty"` MatchedTerms []string `bson:"matched_terms" json:"matched_terms"` RejectReason string `bson:"reject_reason,omitempty" json:"reject_reason,omitempty"` Override *OpportunityOverride `bson:"override,omitempty" json:"override,omitempty"` ContactID string `bson:"contact_id,omitempty" json:"contact_id,omitempty"` CreatedAt int64 `bson:"created_at" json:"created_at"` UpdatedAt int64 `bson:"updated_at" json:"updated_at"` } // OpportunityListFilter 支援 band/status/watch/日期區間。 // Status 與 Statuses 擇一:Statuses 非空時用 $in;否則 Status 做單值比對。 type OpportunityListFilter struct { Band string Status string Statuses []string // 多狀態;$in(今日頁:qualified/accepted/dismissed) WatchID string CreatedFrom int64 // inclusive, unix ns; 0 = no lower bound CreatedTo int64 // exclusive, unix ns; 0 = no upper bound Page int PageSize int } // BandFromScore maps intent_score → band with locked 80/50 thresholds. func BandFromScore(score int) string { if score >= BandHighMinScore { return BandHigh } if score >= BandMidMinScore { return BandMid } return BandLow } func IsOppStatus(s string) bool { switch s { case OppJudging, OppQualified, OppRejected, OppAccepted, OppDismissed: return true } return false } func IsIntentBand(s string) bool { switch s { case BandHigh, BandMid, BandLow: return true } return false } func IsRegionMatch(s string) bool { switch s { case RegionMatch, RegionMismatch, RegionUnknown: return true } return false } func IsOppSource(s string) bool { switch s { case OppSourceThreads, OppSourceManual, OppSourceScoutPromote: return true } return false } /* CanTransitionOpportunity 實作 spec §3.2。 accepted/dismissed 為使用者終態;rejected → qualified 僅經覆寫。 judging 之後不可回到 judging。 */ func CanTransitionOpportunity(from, to string) bool { if from == to { return true } switch from { case OppJudging: return to == OppQualified || to == OppRejected case OppQualified: return to == OppAccepted || to == OppDismissed case OppRejected: return to == OppQualified default: return false } } func (o *Opportunity) Transition(to string) error { if !IsOppStatus(to) { return fmt.Errorf("%w: unknown opportunity status %q", ErrValidation, to) } if !CanTransitionOpportunity(o.Status, to) { return fmt.Errorf("%w: cannot change opportunity from %s to %s", ErrValidation, o.Status, to) } o.Status = to o.UpdatedAt = NowNano() return nil } // ApplyBandFromScore sets IntentBand from IntentScore (locked thresholds). func (o *Opportunity) ApplyBandFromScore() { o.IntentBand = BandFromScore(o.IntentScore) } /* ValidateReasons 要求五維度齊全、每條有非空白人話理由(OP-06)。 缺任一維度 → 明確錯誤,repository 不得寫入。 */ func ValidateReasons(reasons []OpportunityReason) error { if len(reasons) == 0 { return fmt.Errorf("%w: reasons required (need all five dimensions)", ErrValidation) } seen := map[string]bool{} for _, r := range reasons { dim := strings.TrimSpace(r.Dimension) if dim == "" { return fmt.Errorf("%w: reasons entry missing dimension", ErrValidation) } if !isReasonDimension(dim) { return fmt.Errorf("%w: unknown reason dimension %q", ErrValidation, dim) } if seen[dim] { return fmt.Errorf("%w: duplicate reason dimension %q", ErrValidation, dim) } if strings.TrimSpace(r.Reason) == "" { return fmt.Errorf("%w: reasons[%s] needs a human-readable reason", ErrValidation, dim) } seen[dim] = true } for _, dim := range RequiredReasonDimensions { if !seen[dim] { return fmt.Errorf("%w: reasons missing dimension %q (need all five)", ErrValidation, dim) } } return nil } func isReasonDimension(s string) bool { switch s { case DimAuthenticity, DimIntent, DimRegion, DimFreshness, DimFit: return true } return false } // NormalizeMatchedTerms lower-cases, trims, and de-duplicates terms. func NormalizeMatchedTerms(in []string) []string { out := make([]string, 0, len(in)) seen := map[string]bool{} for _, raw := range in { t := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(raw, "\u3000", " "))) t = strings.Join(strings.Fields(t), " ") if t == "" || seen[t] { continue } seen[t] = true out = append(out, t) } return out } // MergeMatchedTerms returns a∪b with stable order (a first, then new from b). func MergeMatchedTerms(a, b []string) []string { return NormalizeMatchedTerms(append(append([]string{}, a...), b...)) } /* ValidateForWrite 在首次寫入/完整更新前檢查契約欄位。 Upsert 命中既有列時只併 term,不走這條(既有資料已通過判定)。 */ func (o *Opportunity) ValidateForWrite() error { if o.OwnerUID <= 0 { return fmt.Errorf("%w: owner_uid required", ErrValidation) } if strings.TrimSpace(o.ExternalID) == "" { return fmt.Errorf("%w: external_id required", ErrValidation) } if o.Source == "" { o.Source = OppSourceThreads } if !IsOppSource(o.Source) { return fmt.Errorf("%w: unknown opportunity source %q", ErrValidation, o.Source) } if o.Status == "" { o.Status = OppJudging } if !IsOppStatus(o.Status) { return fmt.Errorf("%w: unknown opportunity status %q", ErrValidation, o.Status) } // judging 尚未出分時允許缺 reasons;一旦進入 qualified/rejected 必須五條齊全。 if o.Status != OppJudging { if err := ValidateReasons(o.Reasons); err != nil { return err } } else if len(o.Reasons) > 0 { // 若呼叫端已帶 reasons,仍驗一次,避免半成品入庫。 if err := ValidateReasons(o.Reasons); err != nil { return err } } if o.RegionMatch == "" { o.RegionMatch = RegionUnknown } if !IsRegionMatch(o.RegionMatch) { return fmt.Errorf("%w: unknown region_match %q", ErrValidation, o.RegionMatch) } if o.IntentBand == "" && (o.Status == OppQualified || o.Status == OppRejected || o.Status == OppAccepted || o.Status == OppDismissed) { o.ApplyBandFromScore() } if o.IntentBand != "" && !IsIntentBand(o.IntentBand) { return fmt.Errorf("%w: unknown intent_band %q", ErrValidation, o.IntentBand) } o.MatchedTerms = NormalizeMatchedTerms(o.MatchedTerms) return nil } // UTCDayBounds returns [start, end) unix ns for the UTC calendar day that contains at. func UTCDayBounds(at int64) (start, end int64) { if at <= 0 { at = NowNano() } t := time.Unix(0, at).UTC() day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) return day.UnixNano(), day.Add(24 * time.Hour).UnixNano() }