Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions backend/internal/app/account/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -780,8 +780,12 @@ func (s *Service) GetSingleAccountUsage(ctx context.Context, id int) (map[string

key := strconv.Itoa(item.ID)
accountUsage := map[string]any{}
var cachedInfo AccountUsageInfo
var hasCachedInfo bool
if cached, _, ok := s.getUsageCacheForRead(ctx, item.Platform); ok {
if cachedInfo, exists := cached[key]; exists {
if info, exists := cached[key]; exists {
cachedInfo = info
hasCachedInfo = true
accountUsage = accountUsageInfoToMap(cachedInfo)
}
}
Expand All @@ -792,6 +796,9 @@ func (s *Service) GetSingleAccountUsage(ctx context.Context, id int) (map[string
s.handleSingleAccountUsageErrors(ctx, item, usageErrors)
if ok {
normalized := normalizeAccountUsageInfo(info)
if hasCachedInfo {
normalized = mergeAccountUsageInfo(cachedInfo, normalized, s.now())
}
accountUsage = accountUsageInfoToMap(normalized)
s.updateSingleAccountUsageCache(ctx, item.Platform, key, normalized)
if item.State != "disabled" {
Expand Down Expand Up @@ -1089,6 +1096,7 @@ func (s *Service) updateSingleAccountUsageCache(ctx context.Context, platform, a
snapshot map[string]AccountUsageInfo
}
var pending []pendingWrite
now := s.now()

s.usageMu.Lock()
for _, raw := range usageCacheKeysForInvalidation(platform) {
Expand All @@ -1098,8 +1106,16 @@ func (s *Service) updateSingleAccountUsageCache(ctx context.Context, platform, a
continue
}
next := cloneAccountUsageInfoMap(entry.data)
next[accountKey] = info
s.usageCache[cacheKey] = &usageCacheEntry{data: next, expiresAt: entry.expiresAt}
if existing, ok := next[accountKey]; ok {
next[accountKey] = mergeAccountUsageInfo(existing, info, now)
} else {
next[accountKey] = info
}
expiresAt := usageCacheExpiresAt(next, now)
if expiresAt.Sub(now) < usageCacheMinimumTTL {
expiresAt = now.Add(usageCacheMinimumTTL)
}
s.usageCache[cacheKey] = &usageCacheEntry{data: next, expiresAt: expiresAt}
pending = append(pending, pendingWrite{cacheKey: cacheKey, snapshot: next})
}
s.usageMu.Unlock()
Expand Down Expand Up @@ -1211,6 +1227,7 @@ func (s *Service) getUsageCacheFromRedis(ctx context.Context, cacheKey string) (
func (s *Service) setUsageCache(ctx context.Context, cacheKey string, accounts map[string]AccountUsageInfo) {
cacheKey = usageCachePlatformKey(cacheKey)
now := s.now()
accounts = s.mergeUsageCacheAccounts(cacheKey, accounts, now)
expiresAt := usageCacheExpiresAt(accounts, now)
ttl := expiresAt.Sub(now)
if ttl < usageCacheMinimumTTL {
Expand All @@ -1231,6 +1248,25 @@ func (s *Service) setUsageCache(ctx context.Context, cacheKey string, accounts m
}
}

func (s *Service) mergeUsageCacheAccounts(cacheKey string, accounts map[string]AccountUsageInfo, now time.Time) map[string]AccountUsageInfo {
s.usageMu.RLock()
entry, ok := s.usageCache[usageCachePlatformKey(cacheKey)]
if !ok {
s.usageMu.RUnlock()
return accounts
}
existing := entry.data
s.usageMu.RUnlock()

merged := cloneAccountUsageInfoMap(accounts)
for key, info := range accounts {
if existingInfo, ok := existing[key]; ok {
merged[key] = mergeAccountUsageInfo(existingInfo, info, now)
}
}
return merged
}

func (s *Service) setUsageMemoryCache(cacheKey string, accounts map[string]AccountUsageInfo, expiresAt time.Time) {
s.usageMu.Lock()
s.usageCache[usageCachePlatformKey(cacheKey)] = &usageCacheEntry{data: accounts, expiresAt: expiresAt}
Expand Down
120 changes: 119 additions & 1 deletion backend/internal/app/account/usage_contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"time"
)

const accountUsageCacheVersion = 1
const accountUsageCacheVersion = 2

type AccountUsageWindow struct {
Key string `json:"key,omitempty"`
Expand Down Expand Up @@ -156,6 +156,124 @@ func normalizeAccountUsageWindow(window AccountUsageWindow) (AccountUsageWindow,
return window, true
}

func mergeAccountUsageInfo(existing, incoming AccountUsageInfo, now time.Time) AccountUsageInfo {
merged := incoming
if merged.UpdatedAt == "" {
merged.UpdatedAt = existing.UpdatedAt
}
if merged.Credits == nil {
merged.Credits = existing.Credits
}
if len(existing.Windows) == 0 {
return merged
}
if len(merged.Windows) == 0 {
merged.Windows = liveAccountUsageWindows(existing.Windows, now)
return merged
}

existingByID := make(map[string]AccountUsageWindow, len(existing.Windows))
for _, window := range existing.Windows {
id := accountUsageWindowIdentity(window)
if id == "" {
continue
}
existingByID[id] = window
}

windows := make([]AccountUsageWindow, 0, len(merged.Windows)+len(existingByID))
seen := make(map[string]struct{}, len(merged.Windows))
for _, window := range merged.Windows {
id := accountUsageWindowIdentity(window)
if id != "" {
if cached, ok := existingByID[id]; ok {
window = mergeAccountUsageWindow(cached, window, now)
}
seen[id] = struct{}{}
}
windows = append(windows, window)
}
for _, window := range existing.Windows {
id := accountUsageWindowIdentity(window)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
resetAt, ok := accountUsageWindowResetAt(window, now)
if !ok || !resetAt.After(now) {
continue
}
windows = append(windows, windowWithResetAt(window, resetAt, now))
}
merged.Windows = windows
return merged
}

func liveAccountUsageWindows(windows []AccountUsageWindow, now time.Time) []AccountUsageWindow {
result := make([]AccountUsageWindow, 0, len(windows))
for _, window := range windows {
resetAt, ok := accountUsageWindowResetAt(window, now)
if !ok || !resetAt.After(now) {
continue
}
result = append(result, windowWithResetAt(window, resetAt, now))
}
return result
}

func mergeAccountUsageWindow(existing, incoming AccountUsageWindow, now time.Time) AccountUsageWindow {
merged := incoming
if merged.Label == "" {
merged.Label = existing.Label
}
if merged.DisplayLabel == "" {
merged.DisplayLabel = existing.DisplayLabel
}
if merged.Slot == "" {
merged.Slot = existing.Slot
}
if merged.Group == "" {
merged.Group = existing.Group
}
if merged.UpdatedAt == "" {
merged.UpdatedAt = existing.UpdatedAt
}
if merged.ResetAt == "" && merged.ResetSeconds <= 0 && merged.ResetAfterSeconds <= 0 {
if resetAt, ok := accountUsageWindowResetAt(existing, now); ok && resetAt.After(now) {
merged = windowWithResetAt(merged, resetAt, now)
}
}
return merged
}

func windowWithResetAt(window AccountUsageWindow, resetAt, now time.Time) AccountUsageWindow {
window.ResetAt = resetAt.UTC().Format(time.RFC3339)
remaining := resetAt.Sub(now)
if remaining > 0 {
window.ResetSeconds = int64(remaining.Seconds())
window.ResetAfterSeconds = window.ResetSeconds
}
return window
}

func accountUsageWindowIdentity(window AccountUsageWindow) string {
if key := strings.TrimSpace(window.Key); key != "" {
return key
}
group := strings.TrimSpace(window.Group)
slot := normalizeUsageWindowToken(window.Slot)
if group != "" || slot != "" {
label := strings.TrimSpace(window.DisplayLabel)
if label == "" {
label = strings.TrimSpace(window.Label)
}
return group + ":" + slot + ":" + label
}
return strings.TrimSpace(window.Label)
}

func inferUsageWindowDisplayLabel(key, label, slot string) string {
if slot == "monthly" && strings.HasPrefix(strings.ToLower(strings.TrimSpace(label)), "cr ") {
return "Cr"
Expand Down
71 changes: 71 additions & 0 deletions backend/internal/app/account/usage_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,74 @@ func TestUsageCacheExpiresAtUsesEarlierResetOrFiveHours(t *testing.T) {
t.Fatalf("expiresAt = %s, want %s", got, want)
}
}

func TestMergeAccountUsageInfoPreservesLiveMissingWindows(t *testing.T) {
now := time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)
existing := AccountUsageInfo{
UpdatedAt: "2026-05-20T11:55:00Z",
Windows: []AccountUsageWindow{
{
Key: "5h",
Label: "5h",
DisplayLabel: "5h",
Slot: "5h",
Group: "base",
UsedPercent: 31,
ResetAt: now.Add(2 * time.Hour).Format(time.RFC3339),
},
{
Key: "7d",
Label: "7d",
DisplayLabel: "7d",
Slot: "7d",
Group: "base",
UsedPercent: 44,
ResetAt: now.Add(48 * time.Hour).Format(time.RFC3339),
},
},
}
incoming := AccountUsageInfo{
UpdatedAt: "2026-05-20T12:00:00Z",
Windows: []AccountUsageWindow{
{
Key: "7d",
Label: "7d",
DisplayLabel: "7d",
Slot: "7d",
Group: "base",
UsedPercent: 55,
},
},
}

merged := mergeAccountUsageInfo(existing, incoming, now)
if len(merged.Windows) != 2 {
t.Fatalf("len(windows) = %d, want 2: %+v", len(merged.Windows), merged.Windows)
}
if got := merged.Windows[0]; got.Key != "7d" || got.UsedPercent != 55 || got.ResetSeconds <= 0 {
t.Fatalf("merged 7d window = %+v, want incoming usage with preserved reset", got)
}
if got := merged.Windows[1]; got.Key != "5h" || got.UsedPercent != 31 || got.ResetSeconds <= 0 {
t.Fatalf("preserved 5h window = %+v, want live cached 5h", got)
}
}

func TestMergeAccountUsageInfoDropsExpiredMissingWindows(t *testing.T) {
now := time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)
existing := AccountUsageInfo{
Windows: []AccountUsageWindow{
{
Key: "5h",
Label: "5h",
UsedPercent: 31,
ResetAt: now.Add(-time.Minute).Format(time.RFC3339),
},
},
}
incoming := AccountUsageInfo{Windows: []AccountUsageWindow{{Key: "7d", Label: "7d", UsedPercent: 55}}}

merged := mergeAccountUsageInfo(existing, incoming, now)
if len(merged.Windows) != 1 || merged.Windows[0].Key != "7d" {
t.Fatalf("windows = %+v, want only incoming 7d", merged.Windows)
}
}
1 change: 1 addition & 0 deletions backend/internal/app/usage/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type LogRecord struct {
APIKeyDeleted bool
AccountID int64
AccountName string
AccountEmail string
GroupID int64
Platform string
Model string
Expand Down
5 changes: 2 additions & 3 deletions backend/internal/infra/store/usage_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -571,10 +571,9 @@ func mapUsageLog(item *ent.UsageLog) appusage.LogRecord {
if item.Edges.Account != nil {
record.AccountID = int64(item.Edges.Account.ID)
if email, ok := item.Edges.Account.Credentials["email"]; ok && email != "" {
record.AccountName = email
} else {
record.AccountName = item.Edges.Account.Name
record.AccountEmail = email
}
record.AccountName = item.Edges.Account.Name
} else {
record.AccountName = "-"
}
Expand Down
1 change: 1 addition & 0 deletions backend/internal/server/dto/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type UsageLogResp struct {
APIKeyDeleted bool `json:"api_key_deleted"`
AccountID int64 `json:"account_id"`
AccountName string `json:"account_name,omitempty"`
AccountEmail string `json:"account_email,omitempty"`
GroupID int64 `json:"group_id"`
Platform string `json:"platform"`
Model string `json:"model"`
Expand Down
1 change: 1 addition & 0 deletions backend/internal/server/handler/usage_handler_mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func toUsageLogResp(record appusage.LogRecord) dto.UsageLogResp {
APIKeyDeleted: record.APIKeyDeleted,
AccountID: record.AccountID,
AccountName: record.AccountName,
AccountEmail: record.AccountEmail,
GroupID: record.GroupID,
Platform: record.Platform,
Model: record.Model,
Expand Down
7 changes: 7 additions & 0 deletions web/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,8 @@
"group": "Group",
"select_group": "Select group",
"quota_used": "Quota / Used",
"quota_used_short": "Used",
"quota_total_short": "Total",
"expire_time": "Expires",
"name_placeholder": "Name your key",
"quota_label": "Quota (USD)",
Expand Down Expand Up @@ -951,6 +953,8 @@
"quota_label": "Quota (USD)",
"quota_hint": "Set to 0 or leave empty for unlimited",
"quota_unlimited_hint": "Leave empty for unlimited",
"quota_used_short": "Used",
"quota_total_short": "Total",
"expires_at": "Expires At",
"expire_hint": "Leave empty for no expiry",
"never_expire": "Never expires",
Expand All @@ -974,7 +978,10 @@
"group_rate_short": "Group Rate",
"group_rate_default": "Group default",
"user_override_tag": "override",
"markup_title": "Sales/Cost",
"sell_rate_short": "Sell Rate",
"cost_actual": "Cost",
"profit": "Profit",
"copy": "Copy",
"copied": "Copied",
"reveal_failed": "Failed to retrieve key",
Expand Down
7 changes: 7 additions & 0 deletions web/src/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,8 @@
"group": "分组",
"select_group": "请选择分组",
"quota_used": "配额/已用",
"quota_used_short": "已使用",
"quota_total_short": "总配额",
"expire_time": "过期时间",
"name_placeholder": "给密钥起个名字",
"quota_label": "配额 (USD)",
Expand Down Expand Up @@ -954,6 +956,8 @@
"quota_label": "配额 (USD)",
"quota_hint": "设为 0 或留空表示不限配额",
"quota_unlimited_hint": "留空为无限制",
"quota_used_short": "已使用",
"quota_total_short": "总配额",
"expires_at": "过期时间",
"expire_hint": "留空表示永不过期",
"never_expire": "永不过期",
Expand All @@ -977,7 +981,10 @@
"group_rate_short": "分组倍率",
"group_rate_default": "分组默认",
"user_override_tag": "专属",
"markup_title": "销售/成本",
"sell_rate_short": "销售倍率",
"cost_actual": "成本",
"profit": "利润",
"copy": "复制",
"copied": "已复制",
"reveal_failed": "无法获取密钥原文",
Expand Down
Loading
Loading