115 lines
3.2 KiB
Go
115 lines
3.2 KiB
Go
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
|
||
}
|