thread-master/apps/backend/internal/module/radar/usecase/service_profile.go

115 lines
3.9 KiB
Go
Raw Permalink 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"
"errors"
"fmt"
"sync"
"apps/backend/internal/module/ai"
"apps/backend/internal/module/radar/domain"
usageUC "apps/backend/internal/module/usage/usecase"
)
type Service struct {
Repo domain.Repository
ProductSource ProductContextSource
// Quota 未接時退回最低方案上限,見 watch_quota.go。
Quota PlanQuota
// Usage 為 nil 時不扣點(單元測試路徑)。
Usage *usageUC.Service
// AI 是測試fallback clientAIRegistryResolveAI 才是正式路徑。
AI ai.Client
AIRegistry *ai.Registry
ResolveAI func(ctx context.Context, uid int64) (provider, model, apiKey string, err error)
ResolveKey func(ctx context.Context, uid int64, meter string) (mode, apiKey string, err error)
// PainTerms 可空:接不上只影響建議品質。
PainTerms PainTermSource
// SweepJobs 每日排程手動觸發共用nil 時 ScheduleDailySweeps 回 ErrNotReady。
SweepJobs SweepJobScheduler
// Dual-path fetch (prefer HitFetch adapter over individual providers).
HitFetch HitFetcher
Search ThreadSearcher
Chrome ChromeSearcher
DevMode DevModeReader
CrawlerSession CrawlerSessionReader
// Notifier for sweep failures (optional).
Notifier SweepNotifier
// CRM bridge for accept → contact (optional until M4).
CRM ContactBinder
// Health gates auto-send of public replies (AccountHealth throttle).
Health HealthGate
judgeCacheMu sync.Mutex
judgeCache map[string]cachedJudge
// ReplyQueue 是既有 Outbox 佇列studio.QueueExternalReplynil 時 outbox 標記
// 只記錄不真送讓離線測試demo 環境仍能跑(見 MarkReplyUsed
ReplyQueue ReplyQueue
// MediaResolver 把商機的 permalink 解成可送出的 Threads 數字 media id
// (商機 external_id 本身是 permalink不是 media id借用海巡既有 crawler 解析,不重造第二套)。
MediaResolver MediaResolver
}
type cachedJudge struct {
result *JudgeResult
createdAt int64
}
// ContactBinder creates or binds a CRM contact when accepting an opportunity.
type ContactBinder interface {
BindOpportunity(ctx context.Context, ownerUID int64, opp *domain.Opportunity) (contactID string, err error)
}
func New(repo domain.Repository) *Service {
return &Service{Repo: repo, judgeCache: map[string]cachedJudge{}}
}
/*
GetServiceProfile 未建檔時回 domain.ErrNotFound由呼叫端決定怎麼表達。
HTTP 層會把它翻成 exists=false 的 200表單本來就要能開空的但 usecase 不能
自己回一個零值檔案 —— 那樣「沒建檔」與「建了一份空的」就分不出來,而 SP-01 的
訂閱閘門正是靠這個差別。
*/
func (s *Service) GetServiceProfile(ctx context.Context, ownerUID int64) (*domain.ServiceProfile, error) {
if ownerUID <= 0 {
return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
}
return s.Repo.GetServiceProfile(ctx, ownerUID)
}
func (s *Service) HasServiceProfile(ctx context.Context, ownerUID int64) (bool, error) {
_, err := s.GetServiceProfile(ctx, ownerUID)
if errors.Is(err, domain.ErrNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// UpsertServiceProfile 整份覆寫。owner_uid 由呼叫端從 JWT 取request 帶的一律忽略。
func (s *Service) UpsertServiceProfile(ctx context.Context, ownerUID int64, in *domain.ServiceProfile) (*domain.ServiceProfile, error) {
if in == nil {
return nil, fmt.Errorf("%w: profile required", domain.ErrValidation)
}
in.OwnerUID = ownerUID
if err := in.Normalize(); err != nil {
return nil, err
}
now := domain.NowNano()
in.UpdatedAt = now
in.CreatedAt = now
if existing, err := s.Repo.GetServiceProfile(ctx, ownerUID); err == nil {
in.CreatedAt = existing.CreatedAt
} else if !errors.Is(err, domain.ErrNotFound) {
return nil, err
}
if err := s.Repo.SaveServiceProfile(ctx, in); err != nil {
return nil, err
}
return s.Repo.GetServiceProfile(ctx, ownerUID)
}