diff --git a/.gitignore b/.gitignore
index 390c8a03..6d636c8d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -77,6 +77,7 @@ temp/
*.temp
*.log
*.bak
+.cache/
# ===================
# 构建产物
diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go
index 0ea08763..dfceac07 100644
--- a/backend/internal/service/account_usage_service.go
+++ b/backend/internal/service/account_usage_service.go
@@ -202,22 +202,25 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Account) (*UsageInfo, error) {
now := time.Now()
- start := geminiDailyWindowStart(now)
-
- stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
- if err != nil {
- return nil, fmt.Errorf("get gemini usage stats failed: %w", err)
- }
-
usage := &UsageInfo{
UpdatedAt: &now,
}
+ if s.geminiQuotaService == nil || s.usageLogRepo == nil {
+ return usage, nil
+ }
+
quota, ok := s.geminiQuotaService.QuotaForAccount(ctx, account)
if !ok {
return usage, nil
}
+ start := geminiDailyWindowStart(now)
+ stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
+ if err != nil {
+ return nil, fmt.Errorf("get gemini usage stats failed: %w", err)
+ }
+
totals := geminiAggregateUsage(stats)
resetAt := geminiDailyResetTime(now)
diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go
index ea83723d..0511d950 100644
--- a/backend/internal/service/ratelimit_service.go
+++ b/backend/internal/service/ratelimit_service.go
@@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"strings"
+ "sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
@@ -17,8 +18,18 @@ type RateLimitService struct {
usageRepo UsageLogRepository
cfg *config.Config
geminiQuotaService *GeminiQuotaService
+ usageCacheMu sync.Mutex
+ usageCache map[int64]*geminiUsageCacheEntry
}
+type geminiUsageCacheEntry struct {
+ windowStart time.Time
+ cachedAt time.Time
+ totals GeminiUsageTotals
+}
+
+const geminiPrecheckCacheTTL = time.Minute
+
// NewRateLimitService 创建RateLimitService实例
func NewRateLimitService(accountRepo AccountRepository, usageRepo UsageLogRepository, cfg *config.Config, geminiQuotaService *GeminiQuotaService) *RateLimitService {
return &RateLimitService{
@@ -26,6 +37,7 @@ func NewRateLimitService(accountRepo AccountRepository, usageRepo UsageLogReposi
usageRepo: usageRepo,
cfg: cfg,
geminiQuotaService: geminiQuotaService,
+ usageCache: make(map[int64]*geminiUsageCacheEntry),
}
}
@@ -73,7 +85,7 @@ func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account,
if account == nil || !account.IsGeminiCodeAssist() || strings.TrimSpace(requestedModel) == "" {
return true, nil
}
- if s.usageRepo == nil {
+ if s.usageRepo == nil || s.geminiQuotaService == nil {
return true, nil
}
@@ -95,11 +107,15 @@ func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account,
now := time.Now()
start := geminiDailyWindowStart(now)
- stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
- if err != nil {
- return true, err
+ totals, ok := s.getGeminiUsageTotals(account.ID, start, now)
+ if !ok {
+ stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, start, now, 0, 0, account.ID)
+ if err != nil {
+ return true, err
+ }
+ totals = geminiAggregateUsage(stats)
+ s.setGeminiUsageTotals(account.ID, start, now, totals)
}
- totals := geminiAggregateUsage(stats)
var used int64
switch geminiModelClassFromName(requestedModel) {
@@ -121,6 +137,40 @@ func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account,
return true, nil
}
+func (s *RateLimitService) getGeminiUsageTotals(accountID int64, windowStart, now time.Time) (GeminiUsageTotals, bool) {
+ s.usageCacheMu.Lock()
+ defer s.usageCacheMu.Unlock()
+
+ if s.usageCache == nil {
+ return GeminiUsageTotals{}, false
+ }
+
+ entry, ok := s.usageCache[accountID]
+ if !ok || entry == nil {
+ return GeminiUsageTotals{}, false
+ }
+ if !entry.windowStart.Equal(windowStart) {
+ return GeminiUsageTotals{}, false
+ }
+ if now.Sub(entry.cachedAt) >= geminiPrecheckCacheTTL {
+ return GeminiUsageTotals{}, false
+ }
+ return entry.totals, true
+}
+
+func (s *RateLimitService) setGeminiUsageTotals(accountID int64, windowStart, now time.Time, totals GeminiUsageTotals) {
+ s.usageCacheMu.Lock()
+ defer s.usageCacheMu.Unlock()
+ if s.usageCache == nil {
+ s.usageCache = make(map[int64]*geminiUsageCacheEntry)
+ }
+ s.usageCache[accountID] = &geminiUsageCacheEntry{
+ windowStart: windowStart,
+ cachedAt: now,
+ totals: totals,
+ }
+}
+
// GeminiCooldown returns the fallback cooldown duration for Gemini 429s based on tier.
func (s *RateLimitService) GeminiCooldown(ctx context.Context, account *Account) time.Duration {
if account == nil {
diff --git a/backend/migrations/017_add_gemini_tier_id.sql b/backend/migrations/017_add_gemini_tier_id.sql
index 0388a412..17d9440d 100644
--- a/backend/migrations/017_add_gemini_tier_id.sql
+++ b/backend/migrations/017_add_gemini_tier_id.sql
@@ -26,5 +26,10 @@ UPDATE accounts
SET credentials = credentials - 'tier_id'
WHERE platform = 'gemini'
AND type = 'oauth'
- AND credentials->>'oauth_type' = 'code_assist';
+ AND jsonb_typeof(credentials) = 'object'
+ AND credentials->>'tier_id' = 'LEGACY'
+ AND (
+ credentials->>'oauth_type' = 'code_assist'
+ OR (credentials->>'oauth_type' IS NULL AND credentials->>'project_id' IS NOT NULL)
+ );
-- +goose StatementEnd
diff --git a/frontend/src/components/account/AccountQuotaInfo.vue b/frontend/src/components/account/AccountQuotaInfo.vue
index 52f1bae7..c20d685d 100644
--- a/frontend/src/components/account/AccountQuotaInfo.vue
+++ b/frontend/src/components/account/AccountQuotaInfo.vue
@@ -10,7 +10,7 @@
v-if="!isRateLimited"
class="text-xs text-gray-400 dark:text-gray-500"
>
- 未限流
+ {{ t('admin.accounts.gemini.rateLimit.ok') }}
- 限流 {{ resetCountdown }}
+ {{ t('admin.accounts.gemini.rateLimit.limited', { time: resetCountdown }) }}