package usecase import ( "context" "errors" "fmt" "apps/backend/internal/module/radar/domain" ) /* PlanQuota 提供會員方案的雷達上限(spec §4.9)。 介面留在 radar module 是為了不讓 radar 依賴 usage module;正式環境的實作在 internal/svc 橋接既有方案定義,測試直接給固定值。 */ type PlanQuota interface { RadarQuota(ctx context.Context, ownerUID int64) (maxActiveWatches, maxDailyOpportunities int, err error) } // FixedQuota 是測試與離線工具用的固定上限。 type FixedQuota struct { MaxActiveWatches int MaxDailyOpportunities int } func (q FixedQuota) RadarQuota(context.Context, int64) (int, int, error) { return q.MaxActiveWatches, q.MaxDailyOpportunities, nil } /* 沒接上 PlanQuota 時的保守預設 = 最低方案。 失敗方向要選「少給」而不是「白送」:漏接線的結果是使用者看到上限提示來問客服, 而不是所有人都拿到 Pro 的常駐監控量。 */ var fallbackQuota = FixedQuota{MaxActiveWatches: 1, MaxDailyOpportunities: 5} func (s *Service) quota(ctx context.Context, ownerUID int64) (FixedQuota, error) { if s.Quota == nil { return fallbackQuota, nil } maxActive, maxDaily, err := s.Quota.RadarQuota(ctx, ownerUID) if err != nil { return FixedQuota{}, err } if maxActive <= 0 { maxActive = fallbackQuota.MaxActiveWatches } if maxDaily <= 0 { maxDaily = fallbackQuota.MaxDailyOpportunities } return FixedQuota{MaxActiveWatches: maxActive, MaxDailyOpportunities: maxDaily}, nil } func (s *Service) MaxActiveWatches(ctx context.Context, ownerUID int64) (int, error) { q, err := s.quota(ctx, ownerUID) if err != nil { return 0, err } return q.MaxActiveWatches, nil } func (s *Service) MaxDailyOpportunities(ctx context.Context, ownerUID int64) (int, error) { q, err := s.quota(ctx, ownerUID) if err != nil { return 0, err } return q.MaxDailyOpportunities, nil } /* assertCanActivate 是「變成 active」的兩道閘(SP-01、RW-01)。 exceptWatchID 是正在恢復的那一筆:它目前不是 active,所以不會被算進 CountActive, 帶進來只為了在訊息與計算上表達清楚。 既有超額者不強制降級(spec §3.1):這裡只擋「再多一個」。 */ func (s *Service) assertCanActivate(ctx context.Context, ownerUID int64, exceptWatchID string) error { hasProfile, err := s.HasServiceProfile(ctx, ownerUID) if err != nil { return err } if !hasProfile { return fmt.Errorf( "%w: service profile required before activating a radar watch; fill in /api/v1/radar/service-profile first", domain.ErrValidation, ) } maxActive, err := s.MaxActiveWatches(ctx, ownerUID) if err != nil { return err } active, err := s.Repo.CountActiveWatches(ctx, ownerUID) if err != nil { return err } if exceptWatchID != "" { if w, err := s.Repo.GetWatch(ctx, exceptWatchID); err == nil && w.Status == domain.WatchActive { active-- } else if err != nil && !errors.Is(err, domain.ErrNotFound) { return err } } if active >= int64(maxActive) { return fmt.Errorf( "%w: active watch limit reached (%d of %d on your plan); pause an existing watch or upgrade your plan", domain.ErrValidation, active, maxActive, ) } return nil }