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

489 lines
14 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"
"fmt"
"net/url"
"strings"
"apps/backend/internal/module/ai"
"apps/backend/internal/module/scout/domain"
studioDomain "apps/backend/internal/module/studio/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)
}
// AccountLookup for send outreach
type AccountLookup interface {
Get(ctx context.Context, id string) (*threadsDomain.Account, error)
AccessToken(ctx context.Context, a *threadsDomain.Account) (string, error)
}
type Service struct {
Repo domain.Repository
Settings SettingsReader
Transport studioPublish.Transport
Accounts AccountLookup
AI ai.Client
}
func New(repo domain.Repository) *Service {
return &Service{Repo: repo}
}
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{"使用情境", "替代方案", "成分/規格"}
seen := map[string]bool{}
for _, t := range append(brief.Pains, brief.Tags...) {
t = strings.TrimSpace(t)
if t == "" || seen[t] {
continue
}
seen[t] = true
brief.ScanTerms = append(brief.ScanTerms, t)
}
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 {
sess, err := s.Repo.GetCrawlerSession(ctx, ownerUID)
if err != nil || sess == nil || sess.Token == "" {
return nil, domain.ErrNoCrawlerSession
}
path = domain.PathCrawler
}
now := domain.NowNano()
var out []*domain.Post
authors := []string{"itchy_days", "new_mom_tw", "remote_worker", "curious_one"}
limit := len(brief.ScanTerms)
if limit > 5 {
limit = 5
}
for i := 0; i < limit; i++ {
term := brief.ScanTerms[i]
p := &domain.Post{
ID: "sp_" + uuid.NewString()[:10], OwnerUID: ownerUID, BrandID: brief.BrandID,
Author: authors[i%len(authors)], Text: fmt.Sprintf("有人懂「%s」嗎最近超煩…", term),
SearchTag: term, Opportunity: "可接話分享經驗", OutreachStatus: domain.OutreachNew,
Score: 55 + (i*11)%40, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel,
MatchReason: "命中掃描詞 " + term, 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 (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 + "」,我也遇過類似情況…"
if s.AI != nil {
if out, aerr := s.AI.Complete(ctx, "fake", "grok-3", "outreach: "+p.Text); aerr == nil && out != "" {
draft = out
}
}
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
}
p.OutreachStatus = domain.OutreachPublished
if err := s.Repo.SavePost(ctx, p); err != nil {
return nil, err
}
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
}
text = strings.TrimSpace(text)
if text == "" {
text = p.DraftText
}
if text == "" {
return nil, fmt.Errorf("%w: empty outreach text", domain.ErrValidation)
}
if s.Transport != nil {
token := "fake"
if s.Accounts != nil && accountID != "" {
if acc, aerr := s.Accounts.Get(ctx, accountID); aerr == nil && acc != nil && acc.OwnerUID == ownerUID {
if t, terr := s.Accounts.AccessToken(ctx, acc); terr == nil && t != "" {
token = t
}
}
}
if _, perr := s.Transport.Publish(ctx, studioDomain.PublishRequest{
AccessToken: token, AccountID: accountID, Text: text,
}); perr != nil {
return nil, perr
}
}
p.DraftText = text
p.OutreachStatus = domain.OutreachPublished
if err := s.Repo.SavePost(ctx, p); err != nil {
return nil, err
}
return p, nil
}
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 {
return s.Repo.DeletePostsByTheme(ctx, ownerUID, themeKey)
}
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, token string) error {
return s.Repo.SetCrawlerSession(ctx, &domain.CrawlerSession{
OwnerUID: ownerUID, Token: token, UpdatedAt: domain.NowNano(),
})
}
// GetCrawlerSessionToken returns Playwright storageState JSON for browser crawl (may be empty).
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 {
return "", nil
}
return sess.Token, nil
}
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])
}