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