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

274 lines
7.4 KiB
Go
Raw Normal View History

2026-07-13 01:15:30 +00:00
package usecase
import (
"context"
"fmt"
"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
}
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.DefaultCreditCost[meter]
if credits == 0 {
credits = 1
}
prefs, _ := s.ensurePrefs(ctx, uid)
if !prefs.Unlimited {
sum, err := s.GetSummary(ctx, uid, domain.CurrentMonthKey())
if err != nil {
return nil, err
}
if sum.Platform.CreditsRemaining < credits {
return nil, domain.ErrQuotaExceeded
}
}
}
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.
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 {
prefs, _ := s.ensurePrefs(ctx, uid)
if !prefs.Unlimited {
sum, err := s.GetSummary(ctx, uid, domain.CurrentMonthKey())
if err != nil {
return "", err
}
cost := domain.DefaultCreditCost[meter]
if cost == 0 {
cost = 1
}
if sum.Platform.CreditsRemaining < cost {
return "", domain.ErrQuotaExceeded
}
}
}
return mode, 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)
}
if p.PlanID == "" {
p.PlanID = domain.PlanFree
}
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.Plans[prefs.PlanID]
if plan.ID == "" {
plan = domain.Plans[domain.PlanFree]
}
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
}
if prefs.Unlimited {
remaining = plan.MonthlyCredits // display full; not enforced
}
return &domain.MonthSummary{
MonthKey: monthKey, UID: uid, PlanID: prefs.PlanID, 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: 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 != "" {
if _, ok := domain.Plans[planID]; !ok {
return nil, domain.ErrInvalidPlan
}
// member self can only change via purchase
if !isAdmin && actorUID == targetUID {
return nil, domain.ErrForbidden
}
p.PlanID = planID
}
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) {
if _, ok := domain.Plans[planID]; !ok {
return nil, domain.ErrInvalidPlan
}
p, _ := s.ensurePrefs(ctx, uid)
p.PlanID = planID
p.UpdatedAt = domain.NowNano()
_ = s.Repo.SavePrefs(ctx, p)
pur := &domain.Purchase{
ID: uuid.NewString(), UID: uid, PlanID: planID, 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
}