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