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

239 lines
7.3 KiB
Go
Raw Normal View History

2026-07-15 15:23:59 +00:00
package usecase
import (
"context"
"fmt"
"math"
"sort"
"time"
"apps/backend/internal/module/usage/domain"
)
const analyticsDateLayout = "2006-01-02"
func ParseAnalyticsQuery(granularity, fromRaw, toRaw string) (domain.AnalyticsQuery, error) {
if granularity != "day" && granularity != "month" && granularity != "year" {
return domain.AnalyticsQuery{}, fmt.Errorf("%w: granularity must be day, month, or year", domain.ErrInvalidRange)
}
from, err := time.Parse(analyticsDateLayout, fromRaw)
if err != nil {
return domain.AnalyticsQuery{}, fmt.Errorf("%w: from must be YYYY-MM-DD", domain.ErrInvalidRange)
}
to, err := time.Parse(analyticsDateLayout, toRaw)
if err != nil {
return domain.AnalyticsQuery{}, fmt.Errorf("%w: to must be YYYY-MM-DD", domain.ErrInvalidRange)
}
if from.After(to) {
return domain.AnalyticsQuery{}, fmt.Errorf("%w: from must not be after to", domain.ErrInvalidRange)
}
q := domain.AnalyticsQuery{Granularity: granularity, From: from.UTC(), To: to.UTC()}
if len(analyticsBuckets(q)) > map[string]int{"day": 93, "month": 36, "year": 8}[granularity] {
return domain.AnalyticsQuery{}, fmt.Errorf("%w: range exceeds %s limit", domain.ErrInvalidRange, granularity)
}
return q, nil
}
type analyticsBucketRange struct {
key string
label string
from time.Time
to time.Time
}
func analyticsBuckets(q domain.AnalyticsQuery) []analyticsBucketRange {
var buckets []analyticsBucketRange
switch q.Granularity {
case "day":
for cur := q.From; !cur.After(q.To); cur = cur.AddDate(0, 0, 1) {
buckets = append(buckets, analyticsBucketRange{key: cur.Format(analyticsDateLayout), label: cur.Format("01/02"), from: cur, to: cur})
}
case "month":
for cur := time.Date(q.From.Year(), q.From.Month(), 1, 0, 0, 0, 0, time.UTC); !cur.After(q.To); cur = cur.AddDate(0, 1, 0) {
end := cur.AddDate(0, 1, -1)
buckets = append(buckets, analyticsBucketRange{key: cur.Format("2006-01"), label: cur.Format("2006/01"), from: maxTime(cur, q.From), to: minTime(end, q.To)})
}
case "year":
for year := q.From.Year(); year <= q.To.Year(); year++ {
start := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(year, 12, 31, 0, 0, 0, 0, time.UTC)
key := fmt.Sprintf("%04d", year)
buckets = append(buckets, analyticsBucketRange{key: key, label: key, from: maxTime(start, q.From), to: minTime(end, q.To)})
}
}
return buckets
}
func maxTime(a, b time.Time) time.Time {
if a.After(b) {
return a
}
return b
}
func minTime(a, b time.Time) time.Time {
if a.Before(b) {
return a
}
return b
}
func proratedCredits(monthly int, from, to time.Time) int {
credits := 0.0
for cur := from; !cur.After(to); cur = cur.AddDate(0, 0, 1) {
days := time.Date(cur.Year(), cur.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day()
credits += float64(monthly) / float64(days)
}
return int(math.Round(credits))
}
func isAIMeter(meter string) bool {
return meter == domain.MeterAICopy || meter == domain.MeterAIResearch || meter == domain.MeterAIImage
}
func emptyMeterCounts() map[string]domain.MeterCount {
return map[string]domain.MeterCount{
domain.MeterAICopy: {}, domain.MeterAIResearch: {}, domain.MeterWebSearch: {}, domain.MeterAIImage: {},
}
}
func (s *Service) TenantAnalytics(ctx context.Context, q domain.AnalyticsQuery, tenantMembers []domain.AnalyticsMember) (*domain.TenantAnalytics, error) {
events, err := s.Repo.ListEventsRange(ctx, q.From.UnixNano(), q.To.AddDate(0, 0, 1).UnixNano())
if err != nil {
return nil, err
}
type memberAcc struct {
row domain.AnalyticsMemberRow
}
members := make(map[int64]*memberAcc, len(tenantMembers))
for _, member := range tenantMembers {
members[member.UID] = &memberAcc{row: domain.AnalyticsMemberRow{UID: member.UID, Email: member.Email, DisplayName: member.DisplayName}}
}
for _, event := range events {
if members[event.UID] == nil {
members[event.UID] = &memberAcc{row: domain.AnalyticsMemberRow{UID: event.UID}}
}
}
buckets := analyticsBuckets(q)
bucketByKey := make(map[string]int, len(buckets))
result := &domain.TenantAnalytics{
Granularity: q.Granularity, From: q.From.Format(analyticsDateLayout), To: q.To.Format(analyticsDateLayout),
RangeLabel: fmt.Sprintf("%s - %s", q.From.Format(analyticsDateLayout), q.To.Format(analyticsDateLayout)),
ByMeter: emptyMeterCounts(), Byok: domain.ByokBlock{ByMeter: emptyMeterCounts()},
}
for _, bucket := range buckets {
row := domain.AnalyticsBucket{Key: bucket.key, Label: bucket.label}
result.Series = append(result.Series, row)
bucketByKey[bucket.key] = len(result.Series) - 1
}
for _, acc := range members {
prefs, prefsErr := s.ensurePrefs(ctx, acc.row.UID)
if prefsErr != nil {
return nil, prefsErr
}
plan := domain.ResolvePlan(prefs.PlanID)
acc.row.PlanID, acc.row.Unlimited = plan.ID, prefs.Unlimited
acc.row.PurchasedCredits = proratedCredits(plan.MonthlyCredits, q.From, q.To)
result.PurchasedCredits += acc.row.PurchasedCredits
for i, bucket := range buckets {
result.Series[i].PurchasedCredits += proratedCredits(plan.MonthlyCredits, bucket.from, bucket.to)
}
}
for _, event := range events {
bucketIndex, hasBucket := bucketByKey[eventBucketKey(event.CreatedAt, q.Granularity)]
acc := members[event.UID]
if event.KeyMode == domain.KeyModeByok {
result.Byok.CallCount++
meter := result.Byok.ByMeter[event.Meter]
meter.Count++
result.Byok.ByMeter[event.Meter] = meter
acc.row.ByokCallCount++
if hasBucket {
result.Series[bucketIndex].ByokCallCount++
}
continue
}
if event.KeyMode != domain.KeyModePlatform {
continue
}
credits := event.Credits
if credits < 0 {
credits = 0
}
result.ConsumedCredits += credits
meter := result.ByMeter[event.Meter]
meter.Count++
meter.Credits += credits
result.ByMeter[event.Meter] = meter
acc.row.TotalCredits += credits
if isAIMeter(event.Meter) {
result.AICalls++
acc.row.AICalls++
if hasBucket {
result.Series[bucketIndex].AICalls++
}
} else if event.Meter == domain.MeterWebSearch {
result.SearchCalls++
acc.row.SearchCalls++
if hasBucket {
result.Series[bucketIndex].SearchCalls++
}
}
if hasBucket {
result.Series[bucketIndex].ConsumedCredits += credits
}
}
result.RemainingCredits = maxInt(0, result.PurchasedCredits-result.ConsumedCredits)
result.Pct = percentage(result.ConsumedCredits, result.PurchasedCredits)
for _, acc := range members {
acc.row.RemainingCredits = maxInt(0, acc.row.PurchasedCredits-acc.row.TotalCredits)
acc.row.Pct = percentage(acc.row.TotalCredits, acc.row.PurchasedCredits)
result.Members = append(result.Members, acc.row)
}
sort.Slice(result.Members, func(i, j int) bool {
if result.Members[i].TotalCredits == result.Members[j].TotalCredits {
return result.Members[i].UID < result.Members[j].UID
}
return result.Members[i].TotalCredits > result.Members[j].TotalCredits
})
return result, nil
}
func eventBucketKey(nano int64, granularity string) string {
t := time.Unix(0, nano).UTC()
switch granularity {
case "day":
return t.Format(analyticsDateLayout)
case "month":
return t.Format("2006-01")
default:
return t.Format("2006")
}
}
func percentage(used, total int) int {
if total <= 0 {
return 0
}
return minInt(999, int(math.Round(float64(used)*100/float64(total))))
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}