package usecase import ( "context" "fmt" "strings" "time" "apps/backend/internal/module/usage/domain" "github.com/google/uuid" ) // KeyResolver decides platform vs byok for a meter. type KeyResolver interface { // Resolve returns key_mode and whether a usable key exists. Resolve(ctx context.Context, uid int64, meter string) (keyMode string, hasKey bool, err error) } // StaticResolver for tests / simple wiring. type StaticResolver struct { // ByUIDMeter: "uid:meter" -> keyMode (platform|byok); empty means no key Map map[string]string } func (s *StaticResolver) Resolve(_ context.Context, uid int64, meter string) (string, bool, error) { if s == nil || s.Map == nil { return "", false, nil } k := s.Map[fmt.Sprintf("%d:%s", uid, meter)] if k == "" { return "", false, nil } return k, true, nil } type Service struct { Repo domain.Repository Resolver KeyResolver // Platform capacity / rate limit. nil = always allow platform. // When denied, only BYOK (already preferred in Resolver) can proceed. Gate PlatformGate } func New(repo domain.Repository, resolver KeyResolver) *Service { return &Service{Repo: repo, Resolver: resolver} } // RecordCall after successful external call (or for meter tests). func (s *Service) RecordCall(ctx context.Context, uid int64, meter, keyMode, label, source string) (*domain.Event, error) { if keyMode != domain.KeyModePlatform && keyMode != domain.KeyModeByok { return nil, fmt.Errorf("invalid key_mode") } credits := 0 if keyMode == domain.KeyModePlatform { credits = domain.MeterCost(meter) if err := s.checkPlatformQuota(ctx, uid, meter, credits); err != nil { return nil, err } } now := domain.NowNano() e := &domain.Event{ ID: uuid.NewString(), UID: uid, Meter: meter, Credits: credits, KeyMode: keyMode, Label: label, Source: source, MonthKey: domain.MonthKey(now), CreatedAt: now, } if err := s.Repo.InsertEvent(ctx, e); err != nil { return nil, err } return e, nil } // PrepareCall checks quota / key before external call; returns key_mode. // Platform path: capacity gate first, then plan credits. BYOK skips both. func (s *Service) PrepareCall(ctx context.Context, uid int64, meter string) (keyMode string, err error) { if s.Resolver == nil { return "", domain.ErrNoKey } mode, has, err := s.Resolver.Resolve(ctx, uid, meter) if err != nil { return "", err } if !has { return "", domain.ErrNoKey } if mode == domain.KeyModePlatform { if s.Gate != nil && !s.Gate.AllowPlatform(meter) { return "", domain.ErrPlatformCapacity } cost := domain.MeterCost(meter) if err := s.checkPlatformQuota(ctx, uid, meter, cost); err != nil { return "", err } } return mode, nil } // MarkPlatformLimited cools down platform keys (e.g. after upstream 429). // BYOK users are unaffected. until zero clears the cool-down. func (s *Service) MarkPlatformLimited(until time.Time) { if s == nil || s.Gate == nil { return } s.Gate.MarkLimited(until) } // checkPlatformQuota — 總池 + 分項點數上限;unlimited 略過。 func (s *Service) checkPlatformQuota(ctx context.Context, uid int64, meter string, cost int) error { if cost < 0 { cost = 0 } prefs, _ := s.ensurePrefs(ctx, uid) if prefs.Unlimited { return nil } sum, err := s.GetSummary(ctx, uid, domain.CurrentMonthKey()) if err != nil { return err } // 1) 整月 platform 總點 if sum.Platform.CreditsRemaining < cost { return domain.ErrQuotaExceeded } // 2) 分項點數硬上限(platform only;加總 = MonthlyCredits) plan := domain.ResolvePlan(prefs.PlanID) if capN, ok := plan.SoftCaps[meter]; ok && capN > 0 { usedCredits := 0 usedCount := 0 if m, ok := sum.Platform.ByMeter[meter]; ok { usedCredits = m.Credits usedCount = m.Count // 舊資料若 credits 漏記,用次數 × 單價估 if usedCredits < usedCount*domain.MeterCost(meter) { usedCredits = usedCount * domain.MeterCost(meter) } } if usedCredits+cost > capN { return domain.ErrMeterCapExceeded } } return nil } func (s *Service) ensurePrefs(ctx context.Context, uid int64) (*domain.MemberPrefs, error) { p, err := s.Repo.GetPrefs(ctx, uid) if err != nil || p == nil { p = &domain.MemberPrefs{UID: uid, PlanID: domain.PlanFree, UpdatedAt: domain.NowNano()} _ = s.Repo.SavePrefs(ctx, p) } // 正規化 plan_id(小寫/有效方案) norm := domain.ResolvePlan(p.PlanID).ID if p.PlanID != norm { p.PlanID = norm p.UpdatedAt = domain.NowNano() _ = s.Repo.SavePrefs(ctx, p) } return p, nil } func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (*domain.MonthSummary, error) { if monthKey == "" { monthKey = domain.CurrentMonthKey() } prefs, _ := s.ensurePrefs(ctx, uid) plan := domain.ResolvePlan(prefs.PlanID) // 回寫正規化 plan_id,避免 DB 殘留大小寫/空白導致永遠 fallback Free if prefs.PlanID != plan.ID { prefs.PlanID = plan.ID prefs.UpdatedAt = domain.NowNano() _ = s.Repo.SavePrefs(ctx, prefs) } events, err := s.Repo.ListEvents(ctx, uid, monthKey, "all", 0) if err != nil { return nil, err } platBy := map[string]domain.MeterCount{} byokBy := map[string]domain.MeterCount{} platUsed, platCalls, byokCalls := 0, 0, 0 for _, e := range events { if e.KeyMode == domain.KeyModePlatform { platUsed += e.Credits platCalls++ m := platBy[e.Meter] m.Count++ m.Credits += e.Credits platBy[e.Meter] = m } else if e.KeyMode == domain.KeyModeByok { byokCalls++ m := byokBy[e.Meter] m.Count++ byokBy[e.Meter] = m } } remaining := plan.MonthlyCredits - platUsed if remaining < 0 { remaining = 0 } // Unlimited 只影響是否擋用,顯示仍報真實已用/剩餘(前端以 unlimited 旗標顯示 ∞) return &domain.MonthSummary{ MonthKey: monthKey, UID: uid, PlanID: plan.ID, Unlimited: prefs.Unlimited, Platform: domain.PlatformBlock{ CreditsUsed: platUsed, CreditsRemaining: remaining, CreditsTotal: plan.MonthlyCredits, CallCount: platCalls, ByMeter: platBy, }, Byok: domain.ByokBlock{CallCount: byokCalls, ByMeter: byokBy}, // TotalCredits = 方案配給(legacy 欄位名);已用請看 platform.credits_used TotalCredits: plan.MonthlyCredits, RemainingCredits: remaining, }, nil } func (s *Service) ListEvents(ctx context.Context, uid int64, monthKey, keyMode string, limit int) ([]*domain.Event, error) { if limit <= 0 { limit = 100 } return s.Repo.ListEvents(ctx, uid, monthKey, keyMode, limit) } func (s *Service) GetPrefs(ctx context.Context, uid int64) (*domain.MemberPrefs, error) { return s.ensurePrefs(ctx, uid) } func (s *Service) SetPrefs(ctx context.Context, actorUID int64, targetUID int64, isAdmin bool, planID string, unlimited *bool) (*domain.MemberPrefs, error) { if !isAdmin && actorUID != targetUID { return nil, domain.ErrForbidden } if !isAdmin && unlimited != nil { // members cannot set unlimited return nil, domain.ErrForbidden } p, _ := s.ensurePrefs(ctx, targetUID) if planID != "" { id := strings.ToLower(strings.TrimSpace(planID)) if _, ok := domain.Plans[id]; !ok { return nil, domain.ErrInvalidPlan } // member self can only change via purchase if !isAdmin && actorUID == targetUID { return nil, domain.ErrForbidden } p.PlanID = id } if unlimited != nil && isAdmin { p.Unlimited = *unlimited } p.UpdatedAt = domain.NowNano() if err := s.Repo.SavePrefs(ctx, p); err != nil { return nil, err } return p, nil } func (s *Service) PurchasePlan(ctx context.Context, uid int64, planID, mockRef string) (*domain.Purchase, error) { id := strings.ToLower(strings.TrimSpace(planID)) plan, ok := domain.Plans[id] if !ok || plan.ID == "" { return nil, domain.ErrInvalidPlan } p, _ := s.ensurePrefs(ctx, uid) p.PlanID = plan.ID p.UpdatedAt = domain.NowNano() if err := s.Repo.SavePrefs(ctx, p); err != nil { return nil, err } pur := &domain.Purchase{ ID: uuid.NewString(), UID: uid, PlanID: plan.ID, MockRef: mockRef, CreatedAt: domain.NowNano(), } if err := s.Repo.InsertPurchase(ctx, pur); err != nil { return nil, err } return pur, nil } func (s *Service) ListMyPurchases(ctx context.Context, uid int64, limit int) ([]*domain.Purchase, error) { if limit <= 0 { limit = 50 } return s.Repo.ListPurchases(ctx, uid, limit) } func (s *Service) TenantSummary(ctx context.Context, monthKey string) (*domain.TenantSummary, error) { if monthKey == "" { monthKey = domain.CurrentMonthKey() } events, err := s.Repo.ListEventsAll(ctx, monthKey, 0) if err != nil { return nil, err } type acc struct { plat int byok int } byUID := map[int64]*acc{} platTotal, byokTotal := 0, 0 for _, e := range events { a := byUID[e.UID] if a == nil { a = &acc{} byUID[e.UID] = a } if e.KeyMode == domain.KeyModePlatform { a.plat += e.Credits platTotal += e.Credits } else if e.KeyMode == domain.KeyModeByok { a.byok++ byokTotal++ } } var members []domain.TenantRow for uid, a := range byUID { prefs, _ := s.ensurePrefs(ctx, uid) members = append(members, domain.TenantRow{ UID: uid, PlanID: prefs.PlanID, Unlimited: prefs.Unlimited, PlatformCreditsUsed: a.plat, ByokCallCount: a.byok, }) } return &domain.TenantSummary{ MonthKey: monthKey, PlatformCreditsUsed: platTotal, ByokCallCount: byokTotal, Members: members, }, nil }