489 lines
14 KiB
Go
489 lines
14 KiB
Go
|
|
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])
|
|||
|
|
}
|