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 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).
|
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.QueueExternalReply);nil 時 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)
|
|
|
|
|
|
}
|