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

369 lines
11 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 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 err := s.PrepareCallWithMode(ctx, uid, meter, mode); err != nil {
return "", err
}
return mode, nil
}
// PrepareCallWithMode reserves quota for a call whose key mode the caller already resolved.
// Callers that also need the key material must use this rather than PrepareCall: resolving
// twice reads member settings twice, and if those reads disagree the credits get reserved
// against one mode while the call is billed as the other.
func (s *Service) PrepareCallWithMode(ctx context.Context, uid int64, meter, keyMode string) error {
if keyMode != domain.KeyModePlatform && keyMode != domain.KeyModeByok {
return fmt.Errorf("invalid key_mode")
}
if keyMode != domain.KeyModePlatform {
return nil
}
if s.Gate != nil && !s.Gate.AllowPlatform(meter) {
return domain.ErrPlatformCapacity
}
return s.reservePlatform(ctx, uid, meter)
}
// ReleaseCall gives back credits reserved by PrepareCall when the call it was paying for
// never completed. BYOK reserves nothing, so it is a no-op there. Callers should reach for
// this on every failure path between PrepareCall and RecordCall.
func (s *Service) ReleaseCall(ctx context.Context, uid int64, meter, keyMode string) error {
if keyMode != domain.KeyModePlatform {
return nil
}
return s.Repo.ReleasePlatform(ctx, uid, domain.CurrentMonthKey(), meter, domain.MeterCost(meter))
}
// 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+prefs.InviteBonusCredits, -1
if totalLimit < plan.MonthlyCredits {
totalLimit = plan.MonthlyCredits
}
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
}
// PlanFor 回傳會員正規化後的方案定義。給非計點的方案上限用(例如 demand-radar 的
// max_active_watches這些上限不經過 meter但必須跟著方案走。
func (s *Service) PlanFor(ctx context.Context, uid int64) (domain.PlanDef, error) {
prefs, err := s.ensurePrefs(ctx, uid)
if err != nil {
return domain.PlanDef{}, err
}
return domain.ResolvePlan(prefs.PlanID), 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
}
bonus := prefs.InviteBonusCredits
if bonus < 0 {
bonus = 0
}
totalPool := plan.MonthlyCredits + bonus
remaining := totalPool - 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: totalPool,
CallCount: platCalls, ByMeter: platBy,
},
Byok: domain.ByokBlock{CallCount: byokCalls, ByMeter: byokBy},
// TotalCredits = 方案配給 + 邀請贈點;已用請看 platform.credits_used
TotalCredits: totalPool, RemainingCredits: remaining,
}, nil
}
// CreditInviteBonus — 邀請贈點入平台額度池;另記 bonus event不進 platform 消耗計數)
func (s *Service) CreditInviteBonus(ctx context.Context, uid int64, points int, ref string) error {
if uid <= 0 || points <= 0 {
return nil
}
prefs, err := s.ensurePrefs(ctx, uid)
if err != nil {
return err
}
prefs.InviteBonusCredits += points
prefs.UpdatedAt = domain.NowNano()
if err := s.Repo.SavePrefs(ctx, prefs); err != nil {
return err
}
now := domain.NowNano()
_ = s.Repo.InsertEvent(ctx, &domain.Event{
ID: uuid.NewString(), UID: uid, Meter: domain.MeterInviteBonus, Credits: 0,
KeyMode: domain.KeyModeBonus, Label: fmt.Sprintf("邀請贈點 +%d", points),
Source: "invite_reward:" + ref, MonthKey: domain.MonthKey(now), CreatedAt: now,
})
return 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
}