thread-master/apps/backend/internal/module/usage/usecase/service.go

320 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}