148 lines
5.0 KiB
Go
148 lines
5.0 KiB
Go
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 client;AIRegistry+ResolveAI 才是正式路徑。
|
||
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.QueueExternalReply);nil 時 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 不能
|
||
自己回一個零值檔案 —— 那樣「沒建檔」與「建了一份空的」就分不出來。
|
||
*/
|
||
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) GetRadarSchedule(ctx context.Context, ownerUID int64) (*domain.RadarSchedule, error) {
|
||
if ownerUID <= 0 {
|
||
return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
|
||
}
|
||
row, err := s.Repo.GetRadarSchedule(ctx, ownerUID)
|
||
if errors.Is(err, domain.ErrNotFound) || row == nil {
|
||
return &domain.RadarSchedule{OwnerUID: ownerUID, Hours: domain.DefaultSweepHours()}, nil
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
hours, nerr := domain.NormalizeSweepHours(row.Hours)
|
||
if nerr != nil {
|
||
hours = domain.DefaultSweepHours()
|
||
}
|
||
row.Hours = hours
|
||
return row, nil
|
||
}
|
||
|
||
func (s *Service) PutRadarSchedule(ctx context.Context, ownerUID int64, hours []int) (*domain.RadarSchedule, error) {
|
||
if ownerUID <= 0 {
|
||
return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
|
||
}
|
||
normalized, err := domain.NormalizeSweepHours(hours)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
row := &domain.RadarSchedule{OwnerUID: ownerUID, Hours: normalized, UpdatedAt: domain.NowNano()}
|
||
if err := s.Repo.SaveRadarSchedule(ctx, row); err != nil {
|
||
return nil, err
|
||
}
|
||
return row, nil
|
||
}
|
||
|
||
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)
|
||
}
|