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

306 lines
8.7 KiB
Go
Raw Permalink 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 preserves the audit event after a call. Platform credits were already
// reserved by PrepareCall; this method must not charge them a second time.
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)
}
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
}
if err := s.reservePlatform(ctx, uid, meter); 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)
}
func (s *Service) reservePlatform(ctx context.Context, uid int64, meter string) error {
prefs, err := s.ensurePrefs(ctx, uid)
if err != nil {
return err
}
plan := domain.ResolvePlan(prefs.PlanID)
totalLimit, meterLimit := plan.MonthlyCredits, -1
if capN, ok := plan.SoftCaps[meter]; ok && capN > 0 {
meterLimit = capN
}
if prefs.Unlimited {
totalLimit, meterLimit = -1, -1
}
return s.Repo.ReservePlatform(ctx, uid, domain.CurrentMonthKey(), meter, domain.MeterCost(meter), totalLimit, meterLimit)
}
func (s *Service) ensurePrefs(ctx context.Context, uid int64) (*domain.MemberPrefs, error) {
p, err := s.Repo.GetPrefs(ctx, uid)
if err != nil {
return nil, err
}
if p == nil {
p = &domain.MemberPrefs{UID: uid, PlanID: domain.PlanFree, UpdatedAt: domain.NowNano()}
if err := s.Repo.SavePrefs(ctx, p); err != nil {
return nil, err
}
}
// 正規化 plan_id小寫有效方案
norm := domain.ResolvePlan(p.PlanID).ID
if p.PlanID != norm {
p.PlanID = norm
p.UpdatedAt = domain.NowNano()
if err := s.Repo.SavePrefs(ctx, p); err != nil {
return nil, err
}
}
return p, nil
}
func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (*domain.MonthSummary, error) {
if monthKey == "" {
monthKey = domain.CurrentMonthKey()
}
prefs, err := s.ensurePrefs(ctx, uid)
if err != nil {
return nil, err
}
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
}
}
if counter, counterErr := s.Repo.GetMonthlyCounter(ctx, uid, monthKey); counterErr == nil {
platUsed = counter.TotalCredits
for meter, credits := range counter.ByMeter {
m := platBy[meter]
m.Credits = credits
platBy[meter] = m
}
} else if counterErr != domain.ErrNotFound {
return nil, counterErr
}
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, err := s.ensurePrefs(ctx, targetUID)
if err != nil {
return nil, err
}
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
p.AdminOverride = true
}
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) {
return nil, domain.ErrPurchaseDisabled
}
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, err := s.ensurePrefs(ctx, uid)
if err != nil {
return nil, err
}
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
}