package domain import ( "errors" "strings" "time" ) var ( ErrNotFound = errors.New("scout not found") ErrForbidden = errors.New("scout forbidden") ErrValidation = errors.New("scout validation") ErrNoCrawlerSession = errors.New("crawler session required when dev_mode enabled") ErrCrawlerSessionExpired = errors.New("crawler session expired") ErrTopicRemoved = errors.New("ScoutTopic CRUD removed") ErrHasProducts = errors.New("brand has products; remove products first") ErrIllegalRunStatus = errors.New("illegal scout run status transition") ) func NowNano() int64 { return time.Now().UTC().UnixNano() } const ( PathAPI = "api" PathCrawler = "crawler" OutreachNew = "new" OutreachDrafted = "drafted" OutreachPublished = "published" OutreachSkipped = "skipped" OutreachQueued = "queued" ModeProduct = "product" ModeTheme = "theme" ModeActivity = "activity" ModeDemand = "demand" ModeProvider = "provider" RunQueued = "queued" RunRunning = "running" RunSucceeded = "succeeded" RunFailed = "failed" RunCancelled = "cancelled" ShortfallSourceExhausted = "source_exhausted" ShortfallDuplicateExhausted = "duplicate_exhausted" ShortfallRelevanceExhausted = "relevance_exhausted" ShortfallSourceUnavailable = "source_unavailable" ShortfallLimitReached = "limit_reached" ClassificationSeekingHelp = "seeking_help" ClassificationSeekingRecommendation = "seeking_recommendation" ClassificationProviderOffer = "provider_offer" ClassificationDiscussion = "discussion" ClassificationAsking = "asking" ClassificationAnnouncement = "announcement" ClassificationNoise = "noise" ClassificationProviderDirect = "provider_direct" ClassificationProviderRecommended = "provider_recommended" ) const LegacyRunPrefix = "legacy:" func LegacyRunID(themeKey string) string { if themeKey == "" { themeKey = "uncategorized" } return LegacyRunPrefix + themeKey } func LegacyThemeKey(runID string) (string, bool) { if !strings.HasPrefix(runID, LegacyRunPrefix) { return "", false } key := strings.TrimPrefix(runID, LegacyRunPrefix) if key == "uncategorized" { return "", true } return key, true } type Brand struct { ID string `bson:"_id" json:"id"` OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` DisplayName string `bson:"display_name" json:"display_name"` Brief string `bson:"brief" json:"brief"` TargetAudience string `bson:"target_audience,omitempty" json:"target_audience,omitempty"` Goals string `bson:"goals,omitempty" json:"goals,omitempty"` // Learning — growth-loop 資產複利 LearningSummary string `bson:"learning_summary,omitempty" json:"learning_summary,omitempty"` LearningVersion int `bson:"learning_version,omitempty" json:"learning_version,omitempty"` LearnedFromPostsCount int `bson:"learned_from_posts_count,omitempty" json:"learned_from_posts_count,omitempty"` LastLearnedAt int64 `bson:"last_learned_at,omitempty" json:"last_learned_at,omitempty"` CreatedAt int64 `bson:"created_at" json:"created_at"` UpdatedAt int64 `bson:"updated_at" json:"updated_at"` } type Product struct { ID string `bson:"_id" json:"id"` OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` BrandID string `bson:"brand_id" json:"brand_id"` Label string `bson:"label" json:"label"` ProductContext string `bson:"product_context" json:"product_context"` MatchTags []string `bson:"match_tags" json:"match_tags"` PainPoints []string `bson:"pain_points" json:"pain_points"` ProviderCapabilityTerms []string `bson:"provider_capability_terms" json:"provider_capability_terms"` ProviderExcludeTerms []string `bson:"provider_exclude_terms" json:"provider_exclude_terms"` PlacementURL string `bson:"placement_url,omitempty" json:"placement_url,omitempty"` CreatedAt int64 `bson:"created_at" json:"created_at"` UpdatedAt int64 `bson:"updated_at" json:"updated_at"` } type ActiveBrand struct { OwnerUID int64 `bson:"_id" json:"owner_uid"` BrandID string `bson:"brand_id" json:"brand_id"` UpdatedAt int64 `bson:"updated_at" json:"updated_at"` } type RunBrief struct { Intent string `json:"intent"` Mode string `json:"mode"` BrandID string `json:"brand_id,omitempty"` ProductID string `json:"product_id,omitempty"` ProductLabel string `json:"product_label,omitempty"` Pains []string `json:"pains"` Tags []string `json:"tags"` Periphery []string `json:"periphery"` ScanTerms []string `json:"scan_terms"` PlacementNote string `json:"placement_note,omitempty"` ResponseStance string `json:"response_stance,omitempty"` ThemeKey string `json:"theme_key,omitempty"` ThemeLabel string `json:"theme_label,omitempty"` ProductContext string `json:"product_context,omitempty"` // TargetCount is the desired hit count for this scan (topic daily goal). // When >0, RunScanFromBrief may top up (boost per-query, then secondary path). TargetCount int `json:"target_count,omitempty"` } // Run is one immutable scan attempt. theme_key groups intent only; ID keeps // repeated scans of the same theme independent and auditable. type Run struct { ID string `bson:"_id" json:"id"` OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` JobID string `bson:"job_id" json:"job_id"` ThemeKey string `bson:"theme_key" json:"theme_key"` ThemeLabel string `bson:"theme_label" json:"theme_label"` Intent string `bson:"intent" json:"intent"` Mode string `bson:"mode" json:"mode"` BrandID string `bson:"brand_id,omitempty" json:"brand_id,omitempty"` TargetCount int `bson:"target_count" json:"target_count"` Status string `bson:"status" json:"status"` SearchedCount int `bson:"searched_count" json:"searched_count"` DuplicateCount int `bson:"duplicate_count" json:"duplicate_count"` IrrelevantCount int `bson:"irrelevant_count" json:"irrelevant_count"` EligibleCount int `bson:"eligible_count" json:"eligible_count"` PendingCount int `bson:"pending_count" json:"pending_count"` ShortfallCount int `bson:"shortfall_count" json:"shortfall_count"` ShortfallReasons []string `bson:"shortfall_reasons" json:"shortfall_reasons"` CreatedAt int64 `bson:"created_at" json:"created_at"` StartedAt int64 `bson:"started_at,omitempty" json:"started_at,omitempty"` CompletedAt int64 `bson:"completed_at,omitempty" json:"completed_at,omitempty"` Error string `bson:"error,omitempty" json:"error,omitempty"` } func NormalizeTargetCount(n int) int { if n <= 0 { return 20 } if n > 40 { return 40 } return n } func NewRun(id, jobID string, ownerUID int64, brief RunBrief, now int64) *Run { if now <= 0 { now = NowNano() } return &Run{ ID: id, OwnerUID: ownerUID, JobID: jobID, ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, Intent: brief.Intent, Mode: brief.Mode, BrandID: brief.BrandID, TargetCount: NormalizeTargetCount(brief.TargetCount), Status: RunQueued, ShortfallReasons: []string{}, CreatedAt: now, } } func IsRunTerminal(status string) bool { return status == RunSucceeded || status == RunFailed || status == RunCancelled } func CanTransitionRun(from, to string) bool { switch from { case RunQueued: return to == RunRunning || to == RunFailed || to == RunCancelled case RunRunning: return to == RunSucceeded || to == RunFailed || to == RunCancelled case RunSucceeded, RunFailed, RunCancelled: return false default: return false } } func (r *Run) Transition(to string, now int64) error { if r == nil || !CanTransitionRun(r.Status, to) { return ErrIllegalRunStatus } if now <= 0 { now = NowNano() } r.Status = to if to == RunRunning && r.StartedAt == 0 { r.StartedAt = now } if IsRunTerminal(to) { r.CompletedAt = now } return nil } type Post struct { ID string `bson:"_id" json:"id"` RunID string `bson:"run_id,omitempty" json:"run_id,omitempty"` ExternalID string `bson:"external_id,omitempty" json:"external_id,omitempty"` Permalink string `bson:"permalink,omitempty" json:"permalink,omitempty"` OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` BrandID string `bson:"brand_id,omitempty" json:"brand_id,omitempty"` Author string `bson:"author" json:"author"` Text string `bson:"text" json:"text"` SearchTag string `bson:"search_tag" json:"search_tag"` Opportunity string `bson:"opportunity" json:"opportunity"` OutreachStatus string `bson:"outreach_status" json:"outreach_status"` DraftText string `bson:"draft_text,omitempty" json:"draft_text,omitempty"` OutboxID string `bson:"outbox_id,omitempty" json:"outbox_id,omitempty"` Score int `bson:"score" json:"score"` Classification string `bson:"classification,omitempty" json:"classification,omitempty"` MatchedProductID string `bson:"matched_product_id,omitempty" json:"matched_product_id,omitempty"` MatchedProductLabel string `bson:"matched_product_label,omitempty" json:"matched_product_label,omitempty"` MatchReason string `bson:"match_reason,omitempty" json:"match_reason,omitempty"` ScoutMode string `bson:"scout_mode,omitempty" json:"scout_mode,omitempty"` IntentSnippet string `bson:"intent_snippet,omitempty" json:"intent_snippet,omitempty"` ThemeKey string `bson:"theme_key,omitempty" json:"theme_key,omitempty"` ThemeLabel string `bson:"theme_label,omitempty" json:"theme_label,omitempty"` ScanPath string `bson:"scan_path,omitempty" json:"scan_path,omitempty"` // api|crawler // PostedAt = 原文發文時間(unix ns);未知時為 0。列表以 score 優先, // 再用 PostedAt/CreatedAt/ID 做穩定 tie-breaker。 PostedAt int64 `bson:"posted_at,omitempty" json:"posted_at,omitempty"` CreatedAt int64 `bson:"created_at" json:"created_at"` } type Homework struct { ThemeKey string `bson:"_id" json:"theme_key"` OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` ThemeLabel string `bson:"theme_label" json:"theme_label"` Purpose string `bson:"purpose" json:"purpose"` Brief RunBrief `bson:"brief" json:"brief"` CreatedAt int64 `bson:"created_at" json:"created_at"` } type CrawlerSession struct { OwnerUID int64 `bson:"_id" json:"owner_uid"` StorageStateEnc string `bson:"storage_state_enc" json:"storage_state_enc"` UpdatedAt int64 `bson:"updated_at" json:"updated_at"` ExpiresAt int64 `bson:"expires_at" json:"expires_at"` } type ImportDraft struct { Label string `json:"label"` ProductContext string `json:"product_context"` PainPoints []string `json:"pain_points"` MatchTags []string `json:"match_tags"` PlacementURL string `json:"placement_url"` SourceNote string `json:"source_note"` }