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

306 lines
8.7 KiB
Go
Raw Normal View History

2026-07-13 01:15:30 +00:00
package usecase
import (
"context"
"fmt"
2026-07-13 03:18:08 +00:00
"strings"
"time"
2026-07-13 01:15:30 +00:00
"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
2026-07-13 03:18:08 +00:00
// Platform capacity / rate limit. nil = always allow platform.
// When denied, only BYOK (already preferred in Resolver) can proceed.
Gate PlatformGate
2026-07-13 01:15:30 +00:00
}
func New(repo domain.Repository, resolver KeyResolver) *Service {
return &Service{Repo: repo, Resolver: resolver}
}
2026-07-15 15:23:59 +00:00
// RecordCall preserves the audit event after a call. Platform credits were already
// reserved by PrepareCall; this method must not charge them a second time.
2026-07-13 01:15:30 +00:00
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 {
2026-07-13 03:18:08 +00:00
credits = domain.MeterCost(meter)
2026-07-13 01:15:30 +00:00
}
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.
2026-07-13 03:18:08 +00:00
// Platform path: capacity gate first, then plan credits. BYOK skips both.
2026-07-13 01:15:30 +00:00
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 {
2026-07-13 03:18:08 +00:00
if s.Gate != nil && !s.Gate.AllowPlatform(meter) {
return "", domain.ErrPlatformCapacity
}
2026-07-15 15:23:59 +00:00
if err := s.reservePlatform(ctx, uid, meter); err != nil {
2026-07-13 03:18:08 +00:00
return "", err
2026-07-13 01:15:30 +00:00
}
}
return mode, nil
}
2026-07-13 03:18:08 +00:00
// 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)
}
2026-07-15 15:23:59 +00:00
func (s *Service) reservePlatform(ctx context.Context, uid int64, meter string) error {
prefs, err := s.ensurePrefs(ctx, uid)
2026-07-13 03:18:08 +00:00
if err != nil {
return err
}
plan := domain.ResolvePlan(prefs.PlanID)
2026-07-15 15:23:59 +00:00
totalLimit, meterLimit := plan.MonthlyCredits, -1
2026-07-13 03:18:08 +00:00
if capN, ok := plan.SoftCaps[meter]; ok && capN > 0 {
2026-07-15 15:23:59 +00:00
meterLimit = capN
}
if prefs.Unlimited {
totalLimit, meterLimit = -1, -1
2026-07-13 03:18:08 +00:00
}
2026-07-15 15:23:59 +00:00
return s.Repo.ReservePlatform(ctx, uid, domain.CurrentMonthKey(), meter, domain.MeterCost(meter), totalLimit, meterLimit)
2026-07-13 03:18:08 +00:00
}
2026-07-13 01:15:30 +00:00
func (s *Service) ensurePrefs(ctx context.Context, uid int64) (*domain.MemberPrefs, error) {
p, err := s.Repo.GetPrefs(ctx, uid)
2026-07-15 15:23:59 +00:00
if err != nil {
return nil, err
}
if p == nil {
2026-07-13 01:15:30 +00:00
p = &domain.MemberPrefs{UID: uid, PlanID: domain.PlanFree, UpdatedAt: domain.NowNano()}
2026-07-15 15:23:59 +00:00
if err := s.Repo.SavePrefs(ctx, p); err != nil {
return nil, err
}
2026-07-13 01:15:30 +00:00
}
2026-07-13 03:18:08 +00:00
// 正規化 plan_id小寫有效方案
norm := domain.ResolvePlan(p.PlanID).ID
if p.PlanID != norm {
p.PlanID = norm
p.UpdatedAt = domain.NowNano()
2026-07-15 15:23:59 +00:00
if err := s.Repo.SavePrefs(ctx, p); err != nil {
return nil, err
}
2026-07-13 01:15:30 +00:00
}
return p, nil
}
func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (*domain.MonthSummary, error) {
if monthKey == "" {
monthKey = domain.CurrentMonthKey()
}
2026-07-15 15:23:59 +00:00
prefs, err := s.ensurePrefs(ctx, uid)
if err != nil {
return nil, err
}
2026-07-13 03:18:08 +00:00
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)
2026-07-13 01:15:30 +00:00
}
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
}
}
2026-07-15 15:23:59 +00:00
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
}
2026-07-13 01:15:30 +00:00
remaining := plan.MonthlyCredits - platUsed
if remaining < 0 {
remaining = 0
}
2026-07-13 03:18:08 +00:00
// Unlimited 只影響是否擋用,顯示仍報真實已用/剩餘(前端以 unlimited 旗標顯示 ∞)
2026-07-13 01:15:30 +00:00
return &domain.MonthSummary{
2026-07-13 03:18:08 +00:00
MonthKey: monthKey, UID: uid, PlanID: plan.ID, Unlimited: prefs.Unlimited,
2026-07-13 01:15:30 +00:00
Platform: domain.PlatformBlock{
CreditsUsed: platUsed, CreditsRemaining: remaining, CreditsTotal: plan.MonthlyCredits,
CallCount: platCalls, ByMeter: platBy,
},
Byok: domain.ByokBlock{CallCount: byokCalls, ByMeter: byokBy},
2026-07-13 03:18:08 +00:00
// TotalCredits = 方案配給legacy 欄位名);已用請看 platform.credits_used
2026-07-13 01:15:30 +00:00
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
}
2026-07-15 15:23:59 +00:00
p, err := s.ensurePrefs(ctx, targetUID)
if err != nil {
return nil, err
}
2026-07-13 01:15:30 +00:00
if planID != "" {
2026-07-13 03:18:08 +00:00
id := strings.ToLower(strings.TrimSpace(planID))
if _, ok := domain.Plans[id]; !ok {
2026-07-13 01:15:30 +00:00
return nil, domain.ErrInvalidPlan
}
// member self can only change via purchase
if !isAdmin && actorUID == targetUID {
return nil, domain.ErrForbidden
}
2026-07-13 03:18:08 +00:00
p.PlanID = id
2026-07-15 15:23:59 +00:00
p.AdminOverride = true
2026-07-13 01:15:30 +00:00
}
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) {
2026-07-15 15:23:59 +00:00
return nil, domain.ErrPurchaseDisabled
2026-07-13 01:15:30 +00:00
}
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 {
2026-07-15 15:23:59 +00:00
prefs, err := s.ensurePrefs(ctx, uid)
if err != nil {
return nil, err
}
2026-07-13 01:15:30 +00:00
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
}