thread-master/apps/backend/internal/module/scout/usecase/service.go

1471 lines
44 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package usecase
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"unicode/utf8"
"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"
usageDomain "apps/backend/internal/module/usage/domain"
usageUC "apps/backend/internal/module/usage/usecase"
"github.com/google/uuid"
"github.com/zeromicro/go-zero/core/logx"
)
// 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)
}
// OutreachPublishedHook fires after mark-published / successful outreach send path.
type OutreachPublishedHook func(ctx context.Context, ownerUID int64, postID, accountID string)
// ProductWatchLifecycle is a narrow bridge to Radar; Scout owns product CRUD,
// while Radar owns watch state and historical sweep semantics.
type ProductWatchLifecycle interface {
PauseProductWatches(ctx context.Context, ownerUID int64, productID string) (int, 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 // drafts 不用;話題關鍵字 AI 擴充可走此 fallback
AIRegistry *ai.Registry
ResolveAI func(ctx context.Context, uid int64) (provider, model, apiKey string, err error)
Usage *usageUC.Service // 可空:單元測試不扣點
ReplyQueue ReplyQueue
Provider ThreadSearchProvider
Crawler ChromeCrawlerProvider
SessionSecret string
// OnOutreachPublished optional growth-loop outcome hook.
OnOutreachPublished OutreachPublishedHook
RadarLifecycle ProductWatchLifecycle
}
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
}
if s.RadarLifecycle != nil {
if _, err := s.RadarLifecycle.PauseProductWatches(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 + " 推估的產品情境(請改寫成實際賣點與使用場景)",
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) {
intent = strings.TrimSpace(intent)
if intent == "" && purpose != "provider" && purpose != "demand" {
return nil, fmt.Errorf("%w: intent required", domain.ErrValidation)
}
// 話題靈感:真正「產」關鍵字(規則變體 + 可選 AI不再只拆使用者原句。
if purpose == "activity" {
return s.prepareActivityBrief(ctx, ownerUID, intent, deep)
}
mode := domain.ModeTheme
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 && purpose == "provider" {
return nil, err
}
if err == nil {
mode = domain.ModeProduct
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 purpose == "provider" {
if productID == "" {
return nil, fmt.Errorf("%w: product required for provider matching", domain.ErrValidation)
}
p, err := s.GetProduct(ctx, ownerUID, productID)
if err != nil {
return nil, err
}
pains, capabilities, excludes := providerMatchingFields(p)
brief.Mode = domain.ModeProvider
brief.BrandID = p.BrandID
brief.ProductLabel = p.Label
brief.ProductContext = p.ProductContext
brief.Pains = pains
brief.Tags = capabilities
brief.Periphery = excludes
brief.Intent = p.Label
brief.ResponseStance = "找可驗證的解法提供者,不推產品"
brief.ScanTerms = planScanTerms(brief)
brief.ThemeLabel = truncate(p.Label+" 解法媒合", 36)
brief.ThemeKey = brief.Mode + "|" + productID
return brief, nil
}
if purpose == "demand" {
if productID == "" {
return nil, fmt.Errorf("%w: product required for demand matching", domain.ErrValidation)
}
p, err := s.GetProduct(ctx, ownerUID, productID)
if err != nil {
return nil, err
}
pains, excludes := demandMatchingFields(p)
brief.Mode = domain.ModeDemand
brief.BrandID = p.BrandID
brief.ProductLabel = p.Label
brief.ProductContext = p.ProductContext
brief.Pains = pains
brief.Periphery = excludes
brief.Intent = p.Label
brief.ResponseStance = "找正在求助的需求貼文,再決定如何回應"
brief.ScanTerms = planScanTerms(brief)
brief.ThemeLabel = truncate(p.Label+" 需求痛點", 36)
brief.ThemeKey = brief.Mode + "|" + productID
return brief, nil
}
if len(brief.Pains) == 0 {
// 預設用「求助/求推」語感,避免把產品賣點當搜尋詞
brief.Pains = []string{intent}
}
if len(brief.Tags) == 0 {
brief.Tags = tokenize(intent)
}
// periphery 只當作業備註,不進 scan_terms否則會污染 Exa 查詢)
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) prepareActivityBrief(ctx context.Context, ownerUID int64, intent string, deep bool) (*domain.RunBrief, error) {
ruleTerms := planActivityTerms(intent)
// 預設就試 AI 產詞deep 預留給前端「再想一輪」等同義,不額外收費路徑分叉。
_ = deep
aiTerms, err := s.suggestActivityTermsAI(ctx, ownerUID, intent, 8)
if err != nil {
// AI 不可用只降級規則,不擋話題工坊。
logx.Infof("scout activity terms AI unavailable uid=%d: %v", ownerUID, err)
}
terms := mergeActivityTerms(aiTerms, ruleTerms, 8)
if len(terms) == 0 {
// 最後防線:至少給可搜的核或原句截斷
if c := compactCore(intent); c != "" {
terms = []string{c}
} else {
terms = []string{truncateRunes(intent, 4)}
}
}
return &domain.RunBrief{
Intent: intent,
Mode: domain.ModeActivity,
Pains: []string{intent},
Tags: extractTopicCores(intent),
Periphery: []string{},
ScanTerms: terms,
ThemeLabel: truncate(intent, 36),
ThemeKey: domain.ModeActivity + "||" + truncate(intent, 48),
ResponseStance: "先共鳴再給建議",
}, nil
}
/*
suggestActivityTermsAI 請模型依意圖產生 Threads 短詞;失敗回 error呼叫端降級規則。
計費:有 Usage 時走 ai_copysource=scout.topic.suggest無 Usage測試不扣點。
*/
func (s *Service) suggestActivityTermsAI(ctx context.Context, ownerUID int64, intent string, limit int) (_ []string, err error) {
if s == nil || (s.ResolveAI == nil && s.AI == nil) {
return nil, fmt.Errorf("ai not configured")
}
if limit <= 0 {
limit = 8
}
// 預留點數Usage 可空)
var charged bool
var mode string
if s.Usage != nil {
m, berr := s.Usage.PrepareCall(ctx, ownerUID, usageDomain.MeterAICopy)
if berr != nil {
return nil, berr
}
mode = m
charged = true
defer func() {
if !charged {
return
}
if err != nil {
_ = s.Usage.ReleaseCall(ctx, ownerUID, usageDomain.MeterAICopy, mode)
return
}
if _, rerr := s.Usage.RecordCall(ctx, ownerUID, usageDomain.MeterAICopy, mode, "話題關鍵字建議", "scout.topic.suggest"); rerr != nil {
logx.Errorf("scout topic suggest record uid=%d: %v", ownerUID, rerr)
}
}()
}
raw, cerr := s.completeActivityAI(ctx, ownerUID, activityTermsPrompt(intent, limit))
if cerr != nil {
err = cerr
return nil, err
}
parsed := filterThreadsSearchableTerms(parseActivityAITerms(raw))
if len(parsed) == 0 {
err = fmt.Errorf("ai returned no searchable terms")
return nil, err
}
return capTerms(parsed, limit), nil
}
func (s *Service) completeActivityAI(ctx context.Context, ownerUID int64, prompt string) (string, error) {
if s.ResolveAI != nil && s.AIRegistry != nil {
provider, model, apiKey, rerr := s.ResolveAI(ctx, ownerUID)
if rerr != nil {
return "", rerr
}
if strings.TrimSpace(apiKey) == "" || strings.HasPrefix(strings.ToLower(apiKey), "fake") {
return "", fmt.Errorf("ai key empty")
}
c, cerr := s.AIRegistry.Client(provider)
if cerr != nil {
return "", cerr
}
return c.Complete(ctx, apiKey, model, prompt)
}
// 單元測試路徑:未接 ResolveAI 時才用注入的 AI client
if s.AI != nil {
return s.AI.Complete(ctx, "test-key", "grok-3", prompt)
}
return "", fmt.Errorf("ai not configured")
}
// providerMatchingFields keeps existing products usable: their established match
// tags become matching terms until the more specific provider terms are added.
func providerMatchingFields(p *domain.Product) (pains, capabilities, excludes []string) {
pains = nonEmptyTerms(p.PainPoints)
if len(pains) == 0 {
pains = nonEmptyTerms(p.MatchTags)
}
capabilities = nonEmptyTerms(p.ProviderCapabilityTerms)
if len(capabilities) == 0 {
capabilities = nonEmptyTerms(p.MatchTags)
}
if label := strings.TrimSpace(p.Label); label != "" {
if len(pains) == 0 {
pains = []string{label}
}
if len(capabilities) == 0 {
capabilities = []string{label}
}
}
excludes = nonEmptyTerms(p.ProviderExcludeTerms)
if label := strings.TrimSpace(p.Label); label != "" && !containsTerm(pains, label) && !containsTerm(capabilities, label) {
excludes = dedupeTerms(excludes, []string{label})
}
return pains, capabilities, excludes
}
func containsTerm(terms []string, want string) bool {
for _, term := range terms {
if strings.EqualFold(strings.TrimSpace(term), want) {
return true
}
}
return false
}
func demandMatchingFields(p *domain.Product) (pains, excludes []string) {
pains = nonEmptyTerms(p.PainPoints)
if len(pains) == 0 {
pains = nonEmptyTerms(p.MatchTags)
}
if len(pains) == 0 && strings.TrimSpace(p.Label) != "" {
pains = []string{strings.TrimSpace(p.Label)}
}
excludes = nonEmptyTerms(p.ProviderExcludeTerms)
return pains, excludes
}
// SearchHitsOnly runs the dual-path Threads search without persisting Scout posts.
// Radar reuses this so the crawl split (api vs crawler via dev_mode) stays one code path (RG-01).
//
// Terms are searched one-by-one (fan-out) then deduped by canonical permalink — joining all
// terms into a single query dilutes Threads/Exa recall (daily radar sweep bug).
func (s *Service) SearchHitsOnly(ctx context.Context, ownerUID int64, terms []string, limit int) (hits []ThreadSearchResult, path string, err error) {
terms = nonEmptyTerms(terms)
if len(terms) == 0 {
return nil, "", fmt.Errorf("%w: need search terms", domain.ErrValidation)
}
if limit <= 0 {
limit = 10
}
if limit > 40 {
limit = 40
}
// per-query budget mirrors RunScanFromBrief / fanOutSearch.
perQuery := 8
if len(terms) == 1 {
perQuery = 20
} else if len(terms) >= 6 {
perQuery = 5
}
if perQuery > limit {
perQuery = limit
}
path = domain.PathAPI
devMode := false
if s.Settings != nil {
if d, derr := s.Settings.DevModeEnabled(ctx, ownerUID); derr == nil {
devMode = d
}
}
if devMode {
var storageState string
var serr error
if s.Repo != nil {
storageState, serr = s.GetCrawlerSessionToken(ctx, ownerUID)
} else {
serr = domain.ErrNoCrawlerSession
}
if serr != nil || strings.TrimSpace(storageState) == "" {
if s.Provider == nil {
return nil, domain.PathCrawler, domain.ErrNoCrawlerSession
}
logx.Infof("scout SearchHitsOnly: no crawler session uid=%d; falling back to api", ownerUID)
} else if s.Crawler == nil {
if s.Provider == nil {
return nil, domain.PathCrawler, fmt.Errorf("Chrome crawler is not configured")
}
logx.Infof("scout SearchHitsOnly: crawler not configured uid=%d; falling back to api", ownerUID)
} else {
path = domain.PathCrawler
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
return s.Crawler.SearchChrome(ctx, storageState, []string{q}, n)
})
if err == nil {
hits = fillSearchHitsToTarget(ctx, s, terms, hits, limit, path, storageState)
return capHits(hits, limit), path, nil
}
if s.Provider == nil {
return nil, path, err
}
logx.Errorf("scout SearchHitsOnly: crawler failed uid=%d: %v; falling back to api", ownerUID, err)
}
}
if s.Provider == nil {
return nil, path, fmt.Errorf("scout search provider is not configured")
}
path = domain.PathAPI
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
return s.Provider.SearchThreads(ctx, []string{q}, n)
})
if err != nil {
return nil, path, err
}
hits = fillSearchHitsToTarget(ctx, s, terms, hits, limit, path, "")
return capHits(hits, limit), path, nil
}
func capHits(hits []ThreadSearchResult, limit int) []ThreadSearchResult {
if limit > 0 && len(hits) > limit {
return hits[:limit]
}
return hits
}
func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *domain.RunBrief) ([]*domain.Post, error) {
return s.runScanFromBrief(ctx, ownerUID, brief, "")
}
// RunScanForRun executes the same search pipeline without making results
// visible through the legacy posts collection. Results are staged under the
// running run; PublishRun opens the visibility barrier after the whole scan.
func (s *Service) RunScanForRun(ctx context.Context, ownerUID int64, runID string, brief *domain.RunBrief) ([]*domain.Post, error) {
if runID == "" {
return nil, domain.ErrValidation
}
posts, err := s.runScanFromBrief(ctx, ownerUID, brief, runID)
if err != nil {
return nil, err
}
if err := s.StageRunPosts(ctx, ownerUID, runID, posts); err != nil {
return nil, err
}
return posts, nil
}
func (s *Service) runScanFromBrief(ctx context.Context, ownerUID int64, brief *domain.RunBrief, runID string) ([]*domain.Post, error) {
if brief == nil {
return nil, fmt.Errorf("%w: nil brief", domain.ErrValidation)
}
terms := nonEmptyTerms(brief.ScanTerms)
if len(terms) == 0 {
return nil, fmt.Errorf("%w: need scan_terms", domain.ErrValidation)
}
if brief.Mode == domain.ModeProvider {
p, err := s.GetProduct(ctx, ownerUID, brief.ProductID)
if err != nil {
return nil, err
}
pains, capabilities, excludes := providerMatchingFields(p)
brief.BrandID = p.BrandID
brief.ProductLabel = p.Label
brief.Pains = pains
brief.Tags = capabilities
brief.Periphery = excludes
terms = filterProviderScanTerms(terms, p)
if len(terms) == 0 {
return nil, fmt.Errorf("%w: provider scan terms cannot be product/category exclusions", domain.ErrValidation)
}
}
if brief.Mode == domain.ModeDemand {
p, err := s.GetProduct(ctx, ownerUID, brief.ProductID)
if err != nil {
return nil, err
}
brief.BrandID = p.BrandID
brief.ProductLabel = p.Label
brief.Pains, brief.Periphery = demandMatchingFields(p)
}
path := domain.PathAPI
devMode := false
if s.Settings != nil {
if d, err := s.Settings.DevModeEnabled(ctx, ownerUID); err == nil {
devMode = d
}
}
// 每條關鍵字獨立搜尋再合併(使用者已審過的 scan_terms
perQuery := 8
if len(terms) == 1 {
perQuery = 20
} else if len(terms) >= 6 {
perQuery = 5
}
target := brief.TargetCount
if target < 0 {
target = 0
}
if target > 40 {
target = 40
}
// 有目標時略抬高 per-query減少第一輪就差很多則。
if target > 0 {
needPerTerm := (target + len(terms) - 1) / len(terms)
if needPerTerm > perQuery {
perQuery = needPerTerm
}
if perQuery > 20 {
perQuery = 20
}
}
var err error
var storageState string
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")
}
} else {
if s.Provider == nil {
return nil, fmt.Errorf("scout search provider is not configured")
}
}
pipeline, err := s.searchEligiblePipeline(ctx, ownerUID, terms, brief, target, path, storageState, perQuery)
if err != nil {
return nil, err
}
return s.persistSearchHitsWithRun(ctx, ownerUID, brief, path, runID, pipeline.Hits)
}
// fillSearchHitsToTarget tops up hits when the first fan-out misses the daily goal.
// 1) Same path with higher per-query 2) If primary is crawler, secondary is search Provider.
func fillSearchHitsToTarget(
ctx context.Context,
s *Service,
terms []string,
hits []ThreadSearchResult,
target int,
primaryPath string,
crawlerState string,
) []ThreadSearchResult {
if target <= 0 || len(hits) >= target {
return hits
}
// Stage A: same path, boost per-query (cap 20).
boost := 20
need := target - len(hits)
if need < boost {
// still request up to boost so sparse terms can contribute
_ = need
}
var more []ThreadSearchResult
var err error
if primaryPath == domain.PathCrawler && s.Crawler != nil && crawlerState != "" {
more, err = fanOutSearch(ctx, terms, boost, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) {
return s.Crawler.SearchChrome(ctx, crawlerState, []string{q}, limit)
})
} else if s.Provider != nil {
more, err = fanOutSearch(ctx, terms, boost, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) {
return s.Provider.SearchThreads(ctx, []string{q}, limit)
})
}
if err == nil && len(more) > 0 {
hits = mergeHitsDedupe(hits, more)
}
if len(hits) >= target {
return hits
}
// Stage B: crawler primary → top up with search provider (Exa / Threads-domain search).
if primaryPath == domain.PathCrawler && s.Provider != nil {
topup, terr := fanOutSearch(ctx, terms, boost, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) {
return s.Provider.SearchThreads(ctx, []string{q}, limit)
})
if terr == nil && len(topup) > 0 {
hits = mergeHitsDedupe(hits, topup)
}
}
return hits
}
func mergeHitsDedupe(groups ...[]ThreadSearchResult) []ThreadSearchResult {
seen := make(map[string]struct{})
var out []ThreadSearchResult
for _, group := range groups {
for _, hit := range group {
permalink := canonicalPermalink(hit.URL)
key := canonicalPostIdentity(hit.URL)
if key == "" {
key = permalink
}
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if permalink != "" {
hit.URL = permalink
}
out = append(out, hit)
}
}
return out
}
func filterProviderScanTerms(terms []string, p *domain.Product) []string {
var out []string
for _, term := range terms {
term = strings.TrimSpace(term)
if term == "" || strings.EqualFold(term, p.Label) || hasMatchingTerm(strings.ToLower(term), p.ProviderExcludeTerms) {
continue
}
out = append(out, term)
}
return dedupeTerms(out)
}
// fanOutSearch runs one search per query term and dedupes by canonical permalink.
func fanOutSearch(ctx context.Context, terms []string, perQuery int, search func(context.Context, string, int) ([]ThreadSearchResult, error)) ([]ThreadSearchResult, error) {
if perQuery < 1 {
perQuery = 5
}
seen := make(map[string]struct{})
out := make([]ThreadSearchResult, 0, len(terms)*perQuery)
var firstErr error
for _, term := range terms {
hits, err := search(ctx, term, perQuery)
if err != nil {
if firstErr == nil {
firstErr = err
}
if isCrawlerSessionDead(err) {
break
}
continue
}
for _, hit := range hits {
permalink := canonicalPermalink(hit.URL)
identity := canonicalPostIdentity(hit.URL)
if identity == "" || permalink == "" {
continue
}
if _, ok := seen[identity]; ok {
continue
}
seen[identity] = struct{}{}
hit.URL = permalink
// 記住是哪條 query 命中,方便 search_tag
if hit.MatchedQuery == "" {
hit.MatchedQuery = term
}
out = append(out, hit)
}
}
if len(out) == 0 && firstErr != nil {
return nil, firstErr
}
return out, nil
}
func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *domain.RunBrief, path string, hits []ThreadSearchResult) ([]*domain.Post, error) {
return s.persistSearchHitsWithRun(ctx, ownerUID, brief, path, "", hits)
}
func (s *Service) persistSearchHitsWithRun(ctx context.Context, ownerUID int64, brief *domain.RunBrief, path, runID string, hits []ThreadSearchResult) ([]*domain.Post, error) {
now := domain.NowNano()
// Provider 原始順序只用於 deterministic created_at最終結果由
// evaluator score 排序不能再被來源的時間track 順序覆蓋。
hasTrack := hitsHaveTrack(hits)
out := make([]*domain.Post, 0, len(hits))
evaluator := NewCandidateEvaluator(brief)
evaluator.SetHistoricalSeenLookup(func(identity string) (bool, error) {
return s.Repo.HasSeenIdentity(ctx, ownerUID, identity)
})
for i, hit := range hits {
evaluation := evaluator.EvaluateAt(hit, now)
if evaluation.Decision != CandidateEligible {
continue
}
text := evaluation.Text
permalink := evaluation.Permalink
postedAt := evaluation.PostedAt
// created_at保 crawler 回傳序i 越小越前);有發文時間仍寫 PostedAt 供 UI
createdAt := now - int64(i)*1000
if postedAt > 0 && !hasTrack {
// 非 crawler 路徑仍用發文時間當 created 序
createdAt = postedAt
}
p := &domain.Post{
ID: permalinkID(ownerUID, permalink), ExternalID: permalink, Permalink: permalink,
OwnerUID: ownerUID, RunID: runID, BrandID: brief.BrandID, Author: authorFromThreadsURL(permalink), Text: text,
SearchTag: evaluation.SearchTag, Opportunity: "", OutreachStatus: domain.OutreachNew,
Score: evaluation.Score, Classification: evaluation.Classification, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel,
MatchReason: evaluation.Reason, ScoutMode: brief.Mode, IntentSnippet: brief.Intent,
ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, ScanPath: path,
PostedAt: postedAt, CreatedAt: createdAt,
}
if runID == "" {
if err := s.Repo.SavePost(ctx, p); err != nil {
return nil, err
}
}
out = append(out, p)
}
// Score is the primary quality order. Posted/created time and ID only make
// equal scores deterministic; an old but highly relevant post can therefore
// still be returned ahead of a newer weak match.
sortPostsByScore(out)
return out, nil
}
func sortPostsByScore(posts []*domain.Post) {
for i := 0; i < len(posts); i++ {
for j := i + 1; j < len(posts); j++ {
a, b := posts[i], posts[j]
shouldSwap := false
if a.Score != b.Score {
shouldSwap = b.Score > a.Score
} else if (a.PostedAt > 0) != (b.PostedAt > 0) {
shouldSwap = b.PostedAt > 0
} else if a.PostedAt > 0 && a.PostedAt != b.PostedAt {
shouldSwap = b.PostedAt > a.PostedAt
} else if a.CreatedAt != b.CreatedAt {
shouldSwap = b.CreatedAt > a.CreatedAt
} else {
shouldSwap = b.ID > a.ID
}
if shouldSwap {
posts[i], posts[j] = posts[j], posts[i]
}
}
}
}
// sortPostsByResultTime is kept as a package-local compatibility name for
// older tests/callers; result ordering now intentionally delegates to score.
func sortPostsByResultTime(posts []*domain.Post) { sortPostsByScore(posts) }
func hitsHaveTrack(hits []ThreadSearchResult) bool {
for _, h := range hits {
if h.Track != "" {
return true
}
}
return false
}
// searchAnchors 是產詞常用的口語後綴,不能單獨當「主題相關」依據。
var searchAnchors = map[string]bool{
"求推薦": true, "推薦": true, "分享": true, "心得": true,
"活動": true, "怎麼辦": true, "詢問": true, "討論": true,
"有人知道": true, "請問": true,
"熱門": true, "最新": true, "近期": true,
}
// textMatchesSearchTerm正文須含查詢的主題核非口語錨點
// 「外包 求推薦」→ 必須含「外包」;純「求推薦」才允許只命中錨點。
func textMatchesSearchTerm(text, term string) bool {
body := normalizeSignal(text)
if body == "" || strings.TrimSpace(term) == "" {
return false
}
tokens := strings.Fields(strings.TrimSpace(term))
if len(tokens) == 0 {
tokens = []string{strings.TrimSpace(term)}
}
var content, anchors []string
for _, tok := range tokens {
t := strings.ToLower(strings.TrimSpace(tok))
if t == "" || utf8.RuneCountInString(t) < 2 {
continue
}
if searchAnchors[t] {
anchors = append(anchors, t)
} else {
content = append(content, strings.ReplaceAll(t, " ", ""))
}
}
// 有主題核:每個主題核都要命中;錨點只是搜尋修飾,不要求正文出現。
// 「後端 外包」不能因為只提到「外包」就混入其他職種。
if len(content) > 0 {
for _, c := range content {
if !strings.Contains(body, c) {
return false
}
}
return true
}
// 只有錨點:放寬(使用者刻意只搜「求推薦」)
for _, a := range anchors {
if strings.Contains(body, strings.ReplaceAll(a, " ", "")) {
return true
}
}
// 無可用 token不誤殺
return true
}
func matchingSearchTerm(text string, terms []string) string {
for _, term := range terms {
if term = strings.TrimSpace(term); term != "" && textMatchesSearchTerm(text, term) {
return term
}
}
return ""
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func sortHitsByTrackAndPostedAt(hits []ThreadSearchResult) {
trackRank := func(t string) int {
switch t {
case "both":
return 0
case "recent":
return 1
case "top":
return 2
default:
return 3
}
}
for i := 0; i < len(hits); i++ {
for j := i + 1; j < len(hits); j++ {
ri, rj := trackRank(hits[i].Track), trackRank(hits[j].Track)
if rj < ri {
hits[i], hits[j] = hits[j], hits[i]
continue
}
if rj > ri {
continue
}
ai, aj := hits[i].PublishedAt, hits[j].PublishedAt
if ai == 0 && aj == 0 {
continue
}
if ai == 0 || (aj > 0 && aj > ai) {
hits[i], hits[j] = hits[j], hits[i]
}
}
}
}
// sortPostsByTrackAndPostedAt 保留相容:目前先 track 再時間在 sortHits 已做;此函式 no-op 佔位避免誤用。
func sortPostsByTrackAndPostedAt(_ []*domain.Post, _ []ThreadSearchResult) {}
func sortHitsByPostedAt(hits []ThreadSearchResult) {
// newest first; unknown published time last
for i := 0; i < len(hits); i++ {
for j := i + 1; j < len(hits); j++ {
ai, aj := hits[i].PublishedAt, hits[j].PublishedAt
if ai == 0 && aj == 0 {
continue
}
if ai == 0 || (aj > 0 && aj > ai) {
hits[i], hits[j] = hits[j], hits[i]
}
}
}
}
func sortPostsByPostedAt(posts []*domain.Post) {
for i := 0; i < len(posts); i++ {
for j := i + 1; j < len(posts); j++ {
ai := posts[i].PostedAt
if ai == 0 {
ai = posts[i].CreatedAt
}
aj := posts[j].PostedAt
if aj == 0 {
aj = posts[j].CreatedAt
}
if aj > ai {
posts[i], posts[j] = posts[j], posts[i]
}
}
}
}
func sortActivityPostsByMomentum(posts []*domain.Post) {
for i := 0; i < len(posts); i++ {
for j := i + 1; j < len(posts); j++ {
if posts[j].Score > posts[i].Score ||
(posts[j].Score == posts[i].Score && postTime(posts[j]) > postTime(posts[i])) {
posts[i], posts[j] = posts[j], posts[i]
}
}
}
}
func postTime(post *domain.Post) int64 {
if post.PostedAt > 0 {
return post.PostedAt
}
return post.CreatedAt
}
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) {
posts, err := s.Repo.ListPosts(ctx, ownerUID, brandID)
if err != nil {
return nil, err
}
return dedupePostsByIdentity(posts), nil
}
// dedupePostsByIdentity hides legacy duplicates that were stored before IDs
// were based on Threads post shortcodes. It is read-only: no user history is deleted.
func dedupePostsByIdentity(posts []*domain.Post) []*domain.Post {
seen := make(map[string]int, len(posts))
out := make([]*domain.Post, 0, len(posts))
for _, post := range posts {
if post == nil {
continue
}
key := canonicalPostIdentity(post.Permalink)
if key == "" {
key = canonicalPostIdentity(post.ExternalID)
}
if key == "" {
key = "id:" + post.ID
}
if idx, ok := seen[key]; ok {
if preferScoutPost(post, out[idx]) {
out[idx] = post
}
continue
}
seen[key] = len(out)
out = append(out, post)
}
sortPostsByScore(out)
return out
}
func preferScoutPost(candidate, current *domain.Post) bool {
statusRank := func(status string) int {
switch status {
case domain.OutreachPublished:
return 5
case domain.OutreachQueued:
return 4
case domain.OutreachDrafted, domain.OutreachSkipped:
return 3
case domain.OutreachNew:
return 1
default:
return 0
}
}
if a, b := statusRank(candidate.OutreachStatus), statusRank(current.OutreachStatus); a != b {
return a > b
}
if candidate.CreatedAt != current.CreatedAt {
return candidate.CreatedAt > current.CreatedAt
}
return candidate.Score > current.Score
}
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
}
if p.ScoutMode == domain.ModeProvider {
return nil, fmt.Errorf("%w: provider matches do not support outreach drafts", domain.ErrValidation)
}
draft := "嗨 @" + p.Author + ",看到你提到「" + p.SearchTag + "」,我也遇過類似情況。若你願意,想聽聽你後來怎麼處理。"
if p.ScoutMode == domain.ModeActivity {
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) {
p, err := s.getPostOwned(ctx, ownerUID, postID)
if err != nil {
return nil, err
}
if p.ScoutMode == domain.ModeProvider {
return nil, fmt.Errorf("%w: provider matches cannot be marked as outreach", domain.ErrValidation)
}
p.OutreachStatus = domain.OutreachPublished
if err := s.Repo.SavePost(ctx, p); err != nil {
return nil, err
}
// growth-loop: brand learning bump when brand known
if p.BrandID != "" {
if b, berr := s.Repo.GetBrand(ctx, p.BrandID); berr == nil && b != nil && b.OwnerUID == ownerUID {
b.LearningVersion++
b.LearnedFromPostsCount++
b.LastLearnedAt = domain.NowNano()
b.LearningSummary = fmt.Sprintf("海巡外展累積 %d 則 · 知識 v%d", b.LearnedFromPostsCount, b.LearningVersion)
b.UpdatedAt = domain.NowNano()
_ = s.Repo.SaveBrand(ctx, b)
}
}
if s.OnOutreachPublished != nil {
s.OnOutreachPublished(ctx, ownerUID, postID, "")
}
return p, nil
}
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
}
if p.ScoutMode == domain.ModeProvider {
return nil, fmt.Errorf("%w: provider matches do not support outreach", domain.ErrValidation)
}
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
}
/*
ResolveMediaID turns a Threads permalink into a numeric Graph media ID via the
configured Chrome crawler. Already-numeric input is returned unchanged.
Exported so other modules (radar 商機回覆一鍵送出) can reuse the same resolver
instead of re-implementing crawler session handling.
*/
func (s *Service) ResolveMediaID(ctx context.Context, ownerUID int64, permalink string) (string, error) {
if isNumericMediaID(permalink) {
return permalink, nil
}
if s.Crawler == nil {
return "", 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 "", domain.ErrNoCrawlerSession
}
mediaID, err := s.Crawler.ResolveMediaID(ctx, state, permalink)
if err != nil {
return "", fmt.Errorf("%w: unable to resolve the target Threads post: %v", domain.ErrValidation, err)
}
return mediaID, 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
}
// GetPost returns one owned scout post (promote / detail).
func (s *Service) GetPost(ctx context.Context, ownerUID int64, id string) (*domain.Post, error) {
return s.getPostOwned(ctx, ownerUID, id)
}
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])
}