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

115 lines
3.9 KiB
Go
Raw Normal View History

2026-08-03 05:52:02 +00:00
package usecase
import (
"context"
"errors"
"fmt"
2026-08-13 02:22:24 +00:00
"sync"
2026-08-03 05:52:02 +00:00
"apps/backend/internal/module/ai"
"apps/backend/internal/module/radar/domain"
usageUC "apps/backend/internal/module/usage/usecase"
)
type Service struct {
2026-08-13 02:22:24 +00:00
Repo domain.Repository
ProductSource ProductContextSource
2026-08-03 05:52:02 +00:00
// 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).
2026-08-13 02:22:24 +00:00
Health HealthGate
judgeCacheMu sync.Mutex
judgeCache map[string]cachedJudge
2026-08-09 07:57:35 +00:00
// ReplyQueue 是既有 Outbox 佇列studio.QueueExternalReplynil 時 outbox 標記
// 只記錄不真送讓離線測試demo 環境仍能跑(見 MarkReplyUsed
ReplyQueue ReplyQueue
// MediaResolver 把商機的 permalink 解成可送出的 Threads 數字 media id
// (商機 external_id 本身是 permalink不是 media id借用海巡既有 crawler 解析,不重造第二套)。
MediaResolver MediaResolver
2026-08-03 05:52:02 +00:00
}
2026-08-13 02:22:24 +00:00
type cachedJudge struct {
result *JudgeResult
createdAt int64
}
2026-08-03 05:52:02 +00:00
// 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 {
2026-08-13 02:22:24 +00:00
return &Service{Repo: repo, judgeCache: map[string]cachedJudge{}}
2026-08-03 05:52:02 +00:00
}
/*
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)
}