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)
|
||
}
|