328 lines
10 KiB
Go
328 lines
10 KiB
Go
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()
|
||
}
|