100 lines
3.3 KiB
Go
100 lines
3.3 KiB
Go
|
|
package usecase
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"errors"
|
|||
|
|
"fmt"
|
|||
|
|
|
|||
|
|
"apps/backend/internal/module/ai"
|
|||
|
|
"apps/backend/internal/module/radar/domain"
|
|||
|
|
usageUC "apps/backend/internal/module/usage/usecase"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
type Service struct {
|
|||
|
|
Repo domain.Repository
|
|||
|
|
// 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
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/*
|
|||
|
|
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)
|
|||
|
|
}
|