2026-07-13 01:15:30 +00:00
|
|
|
|
package domain
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"errors"
|
2026-08-13 02:22:24 +00:00
|
|
|
|
"strings"
|
2026-07-13 01:15:30 +00:00
|
|
|
|
"time"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
2026-07-13 08:59:13 +00:00
|
|
|
|
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")
|
|
|
|
|
|
ErrTopicRemoved = errors.New("ScoutTopic CRUD removed")
|
|
|
|
|
|
ErrHasProducts = errors.New("brand has products; remove products first")
|
2026-08-13 02:22:24 +00:00
|
|
|
|
ErrIllegalRunStatus = errors.New("illegal scout run status transition")
|
2026-07-13 01:15:30 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
func NowNano() int64 { return time.Now().UTC().UnixNano() }
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
PathAPI = "api"
|
|
|
|
|
|
PathCrawler = "crawler"
|
|
|
|
|
|
|
|
|
|
|
|
OutreachNew = "new"
|
|
|
|
|
|
OutreachDrafted = "drafted"
|
|
|
|
|
|
OutreachPublished = "published"
|
|
|
|
|
|
OutreachSkipped = "skipped"
|
2026-07-13 08:59:13 +00:00
|
|
|
|
OutreachQueued = "queued"
|
2026-07-13 01:15:30 +00:00
|
|
|
|
|
|
|
|
|
|
ModeProduct = "product"
|
|
|
|
|
|
ModeTheme = "theme"
|
|
|
|
|
|
ModeActivity = "activity"
|
2026-08-06 14:16:49 +00:00
|
|
|
|
ModeDemand = "demand"
|
|
|
|
|
|
ModeProvider = "provider"
|
2026-07-13 08:59:13 +00:00
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
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"
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
ClassificationSeekingHelp = "seeking_help"
|
|
|
|
|
|
ClassificationSeekingRecommendation = "seeking_recommendation"
|
|
|
|
|
|
ClassificationProviderOffer = "provider_offer"
|
|
|
|
|
|
ClassificationDiscussion = "discussion"
|
|
|
|
|
|
ClassificationAsking = "asking"
|
|
|
|
|
|
ClassificationAnnouncement = "announcement"
|
|
|
|
|
|
ClassificationNoise = "noise"
|
2026-08-06 14:16:49 +00:00
|
|
|
|
ClassificationProviderDirect = "provider_direct"
|
|
|
|
|
|
ClassificationProviderRecommended = "provider_recommended"
|
2026-07-13 01:15:30 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 01:15:30 +00:00
|
|
|
|
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"`
|
2026-07-23 05:56:42 +00:00
|
|
|
|
// 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"`
|
2026-07-13 01:15:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type Product struct {
|
2026-08-06 14:16:49 +00:00
|
|
|
|
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"`
|
2026-07-13 01:15:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-07-13 08:59:13 +00:00
|
|
|
|
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"`
|
2026-08-09 07:57:35 +00:00
|
|
|
|
// 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"`
|
2026-07-13 01:15:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 01:15:30 +00:00
|
|
|
|
type Post struct {
|
|
|
|
|
|
ID string `bson:"_id" json:"id"`
|
2026-08-13 02:22:24 +00:00
|
|
|
|
RunID string `bson:"run_id,omitempty" json:"run_id,omitempty"`
|
2026-07-13 08:59:13 +00:00
|
|
|
|
ExternalID string `bson:"external_id,omitempty" json:"external_id,omitempty"`
|
|
|
|
|
|
Permalink string `bson:"permalink,omitempty" json:"permalink,omitempty"`
|
2026-07-13 01:15:30 +00:00
|
|
|
|
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"`
|
2026-07-13 08:59:13 +00:00
|
|
|
|
OutboxID string `bson:"outbox_id,omitempty" json:"outbox_id,omitempty"`
|
2026-07-13 01:15:30 +00:00
|
|
|
|
Score int `bson:"score" json:"score"`
|
2026-07-13 08:59:13 +00:00
|
|
|
|
Classification string `bson:"classification,omitempty" json:"classification,omitempty"`
|
2026-07-13 01:15:30 +00:00
|
|
|
|
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
|
2026-08-13 02:22:24 +00:00
|
|
|
|
// PostedAt = 原文發文時間(unix ns);未知時為 0。列表以 score 優先,
|
|
|
|
|
|
// 再用 PostedAt/CreatedAt/ID 做穩定 tie-breaker。
|
2026-08-03 07:58:07 +00:00
|
|
|
|
PostedAt int64 `bson:"posted_at,omitempty" json:"posted_at,omitempty"`
|
|
|
|
|
|
CreatedAt int64 `bson:"created_at" json:"created_at"`
|
2026-07-13 01:15:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-07-13 08:59:13 +00:00
|
|
|
|
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"`
|
2026-07-13 01:15:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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"`
|
|
|
|
|
|
}
|