package usecase import ( "context" "encoding/json" "fmt" "net/url" "strings" "time" "apps/backend/internal/module/ai" "apps/backend/internal/module/scout/domain" studioPublish "apps/backend/internal/module/studio/publish" threadsDomain "apps/backend/internal/module/threads/domain" "github.com/google/uuid" ) // SettingsReader for dev_mode type SettingsReader interface { DevModeEnabled(ctx context.Context, uid int64) (bool, error) } type ReplyQueue interface { QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (outboxID string, err error) } type Service struct { Repo domain.Repository Settings SettingsReader // Transport is retained only for test construction compatibility. Scout never publishes directly. Transport studioPublish.Transport AI ai.Client // Retained for service wiring; Scout drafts never call AI. ReplyQueue ReplyQueue Provider ThreadSearchProvider Crawler ChromeCrawlerProvider SessionSecret string } func New(repo domain.Repository) *Service { return &Service{Repo: repo, Provider: newDefaultExaThreadsProvider()} } func (s *Service) ListBrands(ctx context.Context, ownerUID int64) ([]*domain.Brand, error) { return s.Repo.ListBrands(ctx, ownerUID) } func (s *Service) GetBrand(ctx context.Context, ownerUID int64, id string) (*domain.Brand, error) { b, err := s.Repo.GetBrand(ctx, id) if err != nil { return nil, err } if b.OwnerUID != ownerUID { return nil, domain.ErrForbidden } return b, nil } func (s *Service) CreateBrand(ctx context.Context, ownerUID int64, name, brief string) (*domain.Brand, error) { now := domain.NowNano() if name == "" { name = "未命名品牌" } b := &domain.Brand{ ID: "br_" + uuid.NewString()[:10], OwnerUID: ownerUID, DisplayName: name, Brief: brief, CreatedAt: now, UpdatedAt: now, } if err := s.Repo.SaveBrand(ctx, b); err != nil { return nil, err } aid, _ := s.Repo.GetActiveBrandID(ctx, ownerUID) if aid == "" { _ = s.Repo.SetActiveBrandID(ctx, ownerUID, b.ID) } return b, nil } func (s *Service) SaveBrand(ctx context.Context, ownerUID int64, b *domain.Brand) (*domain.Brand, error) { now := domain.NowNano() if b.ID == "" { return s.CreateBrand(ctx, ownerUID, b.DisplayName, b.Brief) } ex, err := s.GetBrand(ctx, ownerUID, b.ID) if err != nil { return nil, err } b.OwnerUID = ownerUID b.CreatedAt = ex.CreatedAt b.UpdatedAt = now if err := s.Repo.SaveBrand(ctx, b); err != nil { return nil, err } return b, nil } func (s *Service) RemoveBrand(ctx context.Context, ownerUID int64, id string) error { if _, err := s.GetBrand(ctx, ownerUID, id); err != nil { return err } prods, _ := s.Repo.ListProducts(ctx, ownerUID, id) if len(prods) > 0 { return domain.ErrHasProducts } if err := s.Repo.DeleteBrand(ctx, id); err != nil { return err } aid, _ := s.Repo.GetActiveBrandID(ctx, ownerUID) if aid == id { list, _ := s.Repo.ListBrands(ctx, ownerUID) next := "" if len(list) > 0 { next = list[0].ID } _ = s.Repo.SetActiveBrandID(ctx, ownerUID, next) } return nil } func (s *Service) GetActiveBrandID(ctx context.Context, ownerUID int64) (string, error) { return s.Repo.GetActiveBrandID(ctx, ownerUID) } func (s *Service) SetActiveBrandID(ctx context.Context, ownerUID int64, id string) error { if id != "" { if _, err := s.GetBrand(ctx, ownerUID, id); err != nil { return err } } return s.Repo.SetActiveBrandID(ctx, ownerUID, id) } func (s *Service) ListProducts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Product, error) { return s.Repo.ListProducts(ctx, ownerUID, brandID) } func (s *Service) ListAllProducts(ctx context.Context, ownerUID int64) ([]*domain.Product, error) { return s.Repo.ListProducts(ctx, ownerUID, "") } func (s *Service) GetProduct(ctx context.Context, ownerUID int64, id string) (*domain.Product, error) { p, err := s.Repo.GetProduct(ctx, id) if err != nil { return nil, err } if p.OwnerUID != ownerUID { return nil, domain.ErrForbidden } return p, nil } func (s *Service) SaveProduct(ctx context.Context, ownerUID int64, p *domain.Product) (*domain.Product, error) { now := domain.NowNano() if p.BrandID == "" { return nil, fmt.Errorf("%w: brand_id required", domain.ErrValidation) } if _, err := s.GetBrand(ctx, ownerUID, p.BrandID); err != nil { return nil, err } if p.ID == "" { p.ID = "prd_" + uuid.NewString()[:10] p.CreatedAt = now } else { ex, err := s.GetProduct(ctx, ownerUID, p.ID) if err == nil { p.CreatedAt = ex.CreatedAt } else if err != domain.ErrNotFound { return nil, err } else { p.CreatedAt = now } } p.OwnerUID = ownerUID p.UpdatedAt = now if p.Label == "" { p.Label = "未命名產品" } if err := s.Repo.SaveProduct(ctx, p); err != nil { return nil, err } return p, nil } func (s *Service) RemoveProduct(ctx context.Context, ownerUID int64, id string) error { if _, err := s.GetProduct(ctx, ownerUID, id); err != nil { return err } return s.Repo.DeleteProduct(ctx, id) } func (s *Service) ImportProductFromURL(_ context.Context, raw string) (*domain.ImportDraft, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, fmt.Errorf("%w: empty url", domain.ErrValidation) } u, err := url.Parse(raw) if err != nil || (u.Host == "" && !strings.HasPrefix(raw, "http")) { return nil, fmt.Errorf("%w: bad url", domain.ErrValidation) } host := "" if u != nil { host = u.Host } label := "匯入商品" if host != "" { label = host + " 商品" } return &domain.ImportDraft{ Label: label, ProductContext: "從 " + raw + " 推估的產品情境(live 可接真爬頁)", PainPoints: []string{"找不到適合的", "價格猶豫", "不確定是否適合自己"}, MatchTags: []string{"好物", "推薦", "踩雷"}, PlacementURL: raw, SourceNote: "importProductFromUrl · " + host, }, nil } func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, brandID, productID, purpose string, deep bool) (*domain.RunBrief, error) { _ = deep intent = strings.TrimSpace(intent) if intent == "" { return nil, fmt.Errorf("%w: intent required", domain.ErrValidation) } mode := domain.ModeTheme if purpose == "activity" { mode = domain.ModeActivity } brief := &domain.RunBrief{ Intent: intent, Mode: mode, BrandID: brandID, ProductID: productID, Pains: []string{}, Tags: []string{}, Periphery: []string{}, ScanTerms: []string{}, } if productID != "" { p, err := s.GetProduct(ctx, ownerUID, productID) if err == nil { mode = domain.ModeProduct if purpose == "activity" { mode = domain.ModeActivity } brief.Mode = mode brief.ProductLabel = p.Label brief.ProductContext = p.ProductContext brief.Pains = append([]string(nil), p.PainPoints...) brief.Tags = append([]string(nil), p.MatchTags...) brief.BrandID = p.BrandID brief.PlacementNote = "軟性經驗分享,避免硬廣" } } if len(brief.Pains) == 0 { brief.Pains = []string{intent, "相關困擾"} } if len(brief.Tags) == 0 { brief.Tags = tokenize(intent) } brief.Periphery = []string{"使用情境", "替代方案", "成分/規格"} brief.ScanTerms = planScanTerms(brief) brief.ThemeLabel = truncate(intent, 36) brief.ThemeKey = mode + "|" + productID + "|" + truncate(intent, 48) brief.ResponseStance = "先共鳴再給建議" return brief, nil } func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *domain.RunBrief) ([]*domain.Post, error) { if brief == nil { return nil, fmt.Errorf("%w: nil brief", domain.ErrValidation) } if len(brief.ScanTerms) == 0 { return nil, fmt.Errorf("%w: need scan_terms", domain.ErrValidation) } path := domain.PathAPI devMode := false if s.Settings != nil { if d, err := s.Settings.DevModeEnabled(ctx, ownerUID); err == nil { devMode = d } } if devMode { storageState, err := s.GetCrawlerSessionToken(ctx, ownerUID) if err != nil { return nil, domain.ErrNoCrawlerSession } path = domain.PathCrawler if s.Crawler == nil { return nil, fmt.Errorf("Chrome crawler is not configured") } hits, err := s.Crawler.SearchChrome(ctx, storageState, brief.ScanTerms, 20) if err != nil { return nil, err } return s.persistSearchHits(ctx, ownerUID, brief, path, hits) } if s.Provider == nil { return nil, fmt.Errorf("scout search provider is not configured") } hits, err := s.Provider.SearchThreads(ctx, brief.ScanTerms, 5) if err != nil { return nil, err } return s.persistSearchHits(ctx, ownerUID, brief, path, hits) } func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *domain.RunBrief, path string, hits []ThreadSearchResult) ([]*domain.Post, error) { now := domain.NowNano() out := make([]*domain.Post, 0, len(hits)) for i, hit := range hits { text := strings.TrimSpace(hit.Snippet) if text == "" { continue } permalink := canonicalPermalink(hit.URL) if permalink == "" { continue } term := matchingTerm(text+" "+hit.Title, brief.ScanTerms) classified := classifyPost(brief.Mode, text+" "+hit.Title, brief.ScanTerms) if classified.classification == domain.ClassificationNoise { continue } p := &domain.Post{ ID: permalinkID(ownerUID, permalink), ExternalID: permalink, Permalink: permalink, OwnerUID: ownerUID, BrandID: brief.BrandID, Author: authorFromThreadsURL(permalink), Text: text, SearchTag: term, Opportunity: "", OutreachStatus: domain.OutreachNew, Score: classified.score, Classification: classified.classification, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel, MatchReason: classified.reason, ScoutMode: brief.Mode, IntentSnippet: brief.Intent, ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, ScanPath: path, CreatedAt: now - int64(i)*1000, } if err := s.Repo.SavePost(ctx, p); err != nil { return nil, err } out = append(out, p) } return out, nil } func matchingTerm(text string, terms []string) string { for _, term := range terms { if term = strings.TrimSpace(term); term != "" && strings.Contains(strings.ToLower(text), strings.ToLower(term)) { return term } } return "" } func authorFromThreadsURL(raw string) string { u, err := url.Parse(raw) if err != nil { return "" } for _, segment := range strings.Split(u.Path, "/") { segment = strings.TrimPrefix(strings.TrimSpace(segment), "@") if segment != "" { return segment } } return "" } func (s *Service) ListPosts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Post, error) { return s.Repo.ListPosts(ctx, ownerUID, brandID) } func (s *Service) DraftOutreach(ctx context.Context, ownerUID int64, postID, personaID string) (*domain.Post, error) { _ = personaID p, err := s.getPostOwned(ctx, ownerUID, postID) if err != nil { return nil, err } draft := "嗨 @" + p.Author + ",看到你提到「" + p.SearchTag + "」,我也遇過類似情況。若你願意,想聽聽你後來怎麼處理。" p.DraftText = draft p.OutreachStatus = domain.OutreachDrafted if err := s.Repo.SavePost(ctx, p); err != nil { return nil, err } return p, nil } func (s *Service) SkipOutreach(ctx context.Context, ownerUID int64, postID string) (*domain.Post, error) { p, err := s.getPostOwned(ctx, ownerUID, postID) if err != nil { return nil, err } p.OutreachStatus = domain.OutreachSkipped if err := s.Repo.SavePost(ctx, p); err != nil { return nil, err } return p, nil } func (s *Service) MarkPublished(ctx context.Context, ownerUID int64, postID string) (*domain.Post, error) { _, err := s.getPostOwned(ctx, ownerUID, postID) if err != nil { return nil, err } return nil, fmt.Errorf("manual published status is removed; send through Outbox") } func (s *Service) SendOutreach(ctx context.Context, ownerUID int64, postID, text, accountID string) (*domain.Post, error) { p, err := s.getPostOwned(ctx, ownerUID, postID) if err != nil { return nil, err } text = strings.TrimSpace(text) if text == "" { text = p.DraftText } if text == "" { return nil, fmt.Errorf("%w: empty outreach text", domain.ErrValidation) } if s.ReplyQueue == nil { return nil, fmt.Errorf("Scout reply Outbox is not configured") } mediaID := p.ExternalID // A permalink can be resolved to a real Threads media ID before the official // API sends. This also refreshes any legacy IDs that were not Graph media IDs. if strings.TrimSpace(p.Permalink) != "" && s.Crawler != nil { state, err := s.GetCrawlerSessionToken(ctx, ownerUID) if err != nil { return nil, domain.ErrNoCrawlerSession } mediaID, err = s.Crawler.ResolveMediaID(ctx, state, p.Permalink) if err != nil { return nil, fmt.Errorf("%w: unable to resolve the target Threads post: %v", domain.ErrValidation, err) } } if !isNumericMediaID(mediaID) { if s.Crawler == nil { return nil, fmt.Errorf("%w: target Threads media ID is not resolved; configure Chrome crawler", domain.ErrValidation) } state, err := s.GetCrawlerSessionToken(ctx, ownerUID) if err != nil { return nil, domain.ErrNoCrawlerSession } mediaID, err = s.Crawler.ResolveMediaID(ctx, state, p.Permalink) if err != nil { return nil, fmt.Errorf("%w: unable to resolve the target Threads post: %v", domain.ErrValidation, err) } } p.ExternalID = mediaID outboxID, err := s.ReplyQueue.QueueExternalReply(ctx, ownerUID, accountID, mediaID, text, "Scout 回覆 · "+truncate(p.Author, 30)) if err != nil { return nil, err } p.DraftText = text p.OutboxID = outboxID p.OutreachStatus = domain.OutreachQueued if err := s.Repo.SavePost(ctx, p); err != nil { return nil, err } return p, nil } func isNumericMediaID(value string) bool { if value == "" { return false } for _, ch := range value { if ch < '0' || ch > '9' { return false } } return true } func (s *Service) RemovePost(ctx context.Context, ownerUID int64, postID string) error { if _, err := s.getPostOwned(ctx, ownerUID, postID); err != nil { return err } return s.Repo.DeletePost(ctx, postID) } func (s *Service) RemoveTheme(ctx context.Context, ownerUID int64, themeKey string) error { if err := s.Repo.DeletePostsByTheme(ctx, ownerUID, themeKey); err != nil { return err } // A patrol batch owns both its hits and its research snapshot. if err := s.Repo.DeleteHomework(ctx, ownerUID, themeKey); err != nil && err != domain.ErrNotFound { return err } return nil } func (s *Service) ListHomework(ctx context.Context, ownerUID int64) ([]*domain.Homework, error) { return s.Repo.ListHomework(ctx, ownerUID) } func (s *Service) SaveHomework(ctx context.Context, ownerUID int64, h *domain.Homework) (*domain.Homework, error) { if h.ThemeKey == "" { return nil, fmt.Errorf("%w: theme_key required", domain.ErrValidation) } h.OwnerUID = ownerUID if h.CreatedAt == 0 { h.CreatedAt = domain.NowNano() } if err := s.Repo.SaveHomework(ctx, h); err != nil { return nil, err } return h, nil } func (s *Service) GetHomework(ctx context.Context, ownerUID int64, themeKey string) (*domain.Homework, error) { return s.Repo.GetHomework(ctx, ownerUID, themeKey) } func (s *Service) RemoveHomework(ctx context.Context, ownerUID int64, themeKey string) error { return s.Repo.DeleteHomework(ctx, ownerUID, themeKey) } func (s *Service) SetCrawlerSession(ctx context.Context, ownerUID int64, storageState string) error { expiresAt, err := validateStorageState(storageState) if err != nil { return err } if s.SessionSecret == "" { return fmt.Errorf("%w: crawler session secret is required", domain.ErrValidation) } storageStateEnc, err := threadsDomain.Seal(s.SessionSecret, storageState) if err != nil { return err } now := domain.NowNano() return s.Repo.SetCrawlerSession(ctx, &domain.CrawlerSession{ OwnerUID: ownerUID, StorageStateEnc: storageStateEnc, UpdatedAt: now, ExpiresAt: expiresAt, }) } // GetCrawlerSessionToken returns decrypted Playwright storageState JSON for browser crawl. func (s *Service) GetCrawlerSessionToken(ctx context.Context, ownerUID int64) (string, error) { sess, err := s.Repo.GetCrawlerSession(ctx, ownerUID) if err != nil { return "", err } if sess == nil || sess.StorageStateEnc == "" || (sess.ExpiresAt > 0 && sess.ExpiresAt <= domain.NowNano()) { return "", domain.ErrNoCrawlerSession } if s.SessionSecret == "" { return "", fmt.Errorf("%w: crawler session secret is required", domain.ErrValidation) } storageState, err := threadsDomain.Open(s.SessionSecret, sess.StorageStateEnc) if err != nil { return "", err } return storageState, nil } type storageStateCookie struct { Domain string `json:"domain"` Expires float64 `json:"expires"` } func validateStorageState(storageState string) (int64, error) { if len(storageState) > 256*1024 { return 0, fmt.Errorf("%w: storage state exceeds 256KB", domain.ErrValidation) } var state map[string]json.RawMessage if err := json.Unmarshal([]byte(storageState), &state); err != nil || state == nil { return 0, fmt.Errorf("%w: storage state must be a JSON object", domain.ErrValidation) } rawCookies, ok := state["cookies"] if !ok { return 0, fmt.Errorf("%w: cookies required", domain.ErrValidation) } var cookies []storageStateCookie if err := json.Unmarshal(rawCookies, &cookies); err != nil || len(cookies) == 0 { return 0, fmt.Errorf("%w: cookies must be a nonempty array", domain.ErrValidation) } var expiresAt int64 for _, cookie := range cookies { domainName := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(cookie.Domain)), ".") if !allowedCookieDomain(domainName) { return 0, fmt.Errorf("%w: cookie domain %q is not allowed", domain.ErrValidation, cookie.Domain) } if cookie.Expires > 0 { expires := int64(cookie.Expires * float64(time.Second)) if expiresAt == 0 || expires < expiresAt { expiresAt = expires } } } return expiresAt, nil } func allowedCookieDomain(domainName string) bool { for _, allowed := range []string{"threads.net", "threads.com", "instagram.com", "facebook.com"} { if domainName == allowed || strings.HasSuffix(domainName, "."+allowed) { return true } } return false } func (s *Service) ClearCrawlerSession(ctx context.Context, ownerUID int64) error { return s.Repo.ClearCrawlerSession(ctx, ownerUID) } // TopicRemoved — SC: ScoutTopic CRUD not implemented func (s *Service) TopicRemoved() error { return domain.ErrTopicRemoved } func (s *Service) getPostOwned(ctx context.Context, ownerUID int64, id string) (*domain.Post, error) { p, err := s.Repo.GetPost(ctx, id) if err != nil { return nil, err } if p.OwnerUID != ownerUID { return nil, domain.ErrForbidden } return p, nil } func tokenize(s string) []string { parts := strings.FieldsFunc(s, func(r rune) bool { return r == ' ' || r == '、' || r == ',' || r == '/' }) if len(parts) == 0 { return []string{s} } if len(parts) > 4 { parts = parts[:4] } return parts } func truncate(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(r[:n]) }