2025-12-18 13:50:39 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"log"
|
|
|
|
|
|
"sync"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
2025-12-25 17:15:01 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
2025-12-24 21:07:21 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-25 17:15:01 +08:00
|
|
|
|
type UsageLogRepository interface {
|
2026-01-03 17:10:32 -08:00
|
|
|
|
// Create creates a usage log and returns whether it was actually inserted.
|
|
|
|
|
|
// inserted is false when the insert was skipped due to conflict (idempotent retries).
|
|
|
|
|
|
Create(ctx context.Context, log *UsageLog) (inserted bool, err error)
|
2025-12-26 15:40:24 +08:00
|
|
|
|
GetByID(ctx context.Context, id int64) (*UsageLog, error)
|
2025-12-25 17:15:01 +08:00
|
|
|
|
Delete(ctx context.Context, id int64) error
|
|
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
ListByUser(ctx context.Context, userID int64, params pagination.PaginationParams) ([]UsageLog, *pagination.PaginationResult, error)
|
2026-01-04 19:27:53 +08:00
|
|
|
|
ListByAPIKey(ctx context.Context, apiKeyID int64, params pagination.PaginationParams) ([]UsageLog, *pagination.PaginationResult, error)
|
2025-12-26 15:40:24 +08:00
|
|
|
|
ListByAccount(ctx context.Context, accountID int64, params pagination.PaginationParams) ([]UsageLog, *pagination.PaginationResult, error)
|
2025-12-25 17:15:01 +08:00
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
ListByUserAndTimeRange(ctx context.Context, userID int64, startTime, endTime time.Time) ([]UsageLog, *pagination.PaginationResult, error)
|
2026-01-04 19:27:53 +08:00
|
|
|
|
ListByAPIKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]UsageLog, *pagination.PaginationResult, error)
|
2025-12-26 15:40:24 +08:00
|
|
|
|
ListByAccountAndTimeRange(ctx context.Context, accountID int64, startTime, endTime time.Time) ([]UsageLog, *pagination.PaginationResult, error)
|
|
|
|
|
|
ListByModelAndTimeRange(ctx context.Context, modelName string, startTime, endTime time.Time) ([]UsageLog, *pagination.PaginationResult, error)
|
2025-12-25 17:15:01 +08:00
|
|
|
|
|
|
|
|
|
|
GetAccountWindowStats(ctx context.Context, accountID int64, startTime time.Time) (*usagestats.AccountStats, error)
|
|
|
|
|
|
GetAccountTodayStats(ctx context.Context, accountID int64) (*usagestats.AccountStats, error)
|
|
|
|
|
|
|
|
|
|
|
|
// Admin dashboard stats
|
|
|
|
|
|
GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error)
|
2026-02-02 22:13:50 +08:00
|
|
|
|
GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, stream *bool, billingType *int8) ([]usagestats.TrendDataPoint, error)
|
|
|
|
|
|
GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, stream *bool, billingType *int8) ([]usagestats.ModelStat, error)
|
2026-01-04 19:27:53 +08:00
|
|
|
|
GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error)
|
2025-12-25 17:15:01 +08:00
|
|
|
|
GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error)
|
|
|
|
|
|
GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error)
|
2026-01-04 19:27:53 +08:00
|
|
|
|
GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchAPIKeyUsageStats, error)
|
2025-12-25 17:15:01 +08:00
|
|
|
|
|
|
|
|
|
|
// User dashboard stats
|
|
|
|
|
|
GetUserDashboardStats(ctx context.Context, userID int64) (*usagestats.UserDashboardStats, error)
|
|
|
|
|
|
GetUserUsageTrendByUserID(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.TrendDataPoint, error)
|
|
|
|
|
|
GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.ModelStat, error)
|
|
|
|
|
|
|
|
|
|
|
|
// Admin usage listing/stats
|
2025-12-26 15:40:24 +08:00
|
|
|
|
ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]UsageLog, *pagination.PaginationResult, error)
|
2025-12-25 17:15:01 +08:00
|
|
|
|
GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
2026-01-06 22:19:07 +08:00
|
|
|
|
GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error)
|
2025-12-25 17:15:01 +08:00
|
|
|
|
|
|
|
|
|
|
// Account stats
|
|
|
|
|
|
GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error)
|
2025-12-27 16:03:57 +08:00
|
|
|
|
|
|
|
|
|
|
// Aggregated stats (optimized)
|
|
|
|
|
|
GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
2026-01-04 19:27:53 +08:00
|
|
|
|
GetAPIKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
2025-12-31 08:50:12 +08:00
|
|
|
|
GetAccountStatsAggregated(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
|
|
|
|
|
GetModelStatsAggregated(ctx context.Context, modelName string, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
|
|
|
|
|
GetDailyStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) ([]map[string]any, error)
|
2025-12-25 17:15:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 23:12:44 +08:00
|
|
|
|
// apiUsageCache 缓存从 Anthropic API 获取的使用率数据(utilization, resets_at)
|
|
|
|
|
|
type apiUsageCache struct {
|
|
|
|
|
|
response *ClaudeUsageResponse
|
|
|
|
|
|
timestamp time.Time
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// windowStatsCache 缓存从本地数据库查询的窗口统计(requests, tokens, cost)
|
|
|
|
|
|
type windowStatsCache struct {
|
|
|
|
|
|
stats *WindowStats
|
2025-12-18 13:50:39 +08:00
|
|
|
|
timestamp time.Time
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-03 13:10:43 +08:00
|
|
|
|
// antigravityUsageCache 缓存 Antigravity 额度数据
|
|
|
|
|
|
type antigravityUsageCache struct {
|
|
|
|
|
|
usageInfo *UsageInfo
|
|
|
|
|
|
timestamp time.Time
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
2026-01-04 14:20:17 +08:00
|
|
|
|
apiCacheTTL = 3 * time.Minute
|
2025-12-28 23:12:44 +08:00
|
|
|
|
windowStatsCacheTTL = 1 * time.Minute
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-03 13:10:43 +08:00
|
|
|
|
// UsageCache 封装账户使用量相关的缓存
|
|
|
|
|
|
type UsageCache struct {
|
|
|
|
|
|
apiCache sync.Map // accountID -> *apiUsageCache
|
|
|
|
|
|
windowStatsCache sync.Map // accountID -> *windowStatsCache
|
|
|
|
|
|
antigravityCache sync.Map // accountID -> *antigravityUsageCache
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewUsageCache 创建 UsageCache 实例
|
|
|
|
|
|
func NewUsageCache() *UsageCache {
|
|
|
|
|
|
return &UsageCache{}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// WindowStats 窗口期统计
|
2026-01-14 16:12:08 +08:00
|
|
|
|
//
|
|
|
|
|
|
// cost: 账号口径费用(total_cost * account_rate_multiplier)
|
|
|
|
|
|
// standard_cost: 标准费用(total_cost,不含倍率)
|
|
|
|
|
|
// user_cost: 用户/API Key 口径费用(actual_cost,受分组倍率影响)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
type WindowStats struct {
|
2026-01-14 16:12:08 +08:00
|
|
|
|
Requests int64 `json:"requests"`
|
|
|
|
|
|
Tokens int64 `json:"tokens"`
|
|
|
|
|
|
Cost float64 `json:"cost"`
|
|
|
|
|
|
StandardCost float64 `json:"standard_cost"`
|
|
|
|
|
|
UserCost float64 `json:"user_cost"`
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// UsageProgress 使用量进度
|
|
|
|
|
|
type UsageProgress struct {
|
2025-12-19 21:26:19 +08:00
|
|
|
|
Utilization float64 `json:"utilization"` // 使用率百分比 (0-100+,100表示100%)
|
|
|
|
|
|
ResetsAt *time.Time `json:"resets_at"` // 重置时间
|
|
|
|
|
|
RemainingSeconds int `json:"remaining_seconds"` // 距重置剩余秒数
|
|
|
|
|
|
WindowStats *WindowStats `json:"window_stats,omitempty"` // 窗口期统计(从窗口开始到当前的使用量)
|
2026-01-04 15:36:00 +08:00
|
|
|
|
UsedRequests int64 `json:"used_requests,omitempty"`
|
|
|
|
|
|
LimitRequests int64 `json:"limit_requests,omitempty"`
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-02 22:41:55 +08:00
|
|
|
|
// AntigravityModelQuota Antigravity 单个模型的配额信息
|
|
|
|
|
|
type AntigravityModelQuota struct {
|
|
|
|
|
|
Utilization int `json:"utilization"` // 使用率 0-100
|
|
|
|
|
|
ResetTime string `json:"reset_time"` // 重置时间 ISO8601
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// UsageInfo 账号使用量信息
|
|
|
|
|
|
type UsageInfo struct {
|
2026-01-04 15:36:00 +08:00
|
|
|
|
UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间
|
|
|
|
|
|
FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口
|
|
|
|
|
|
SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口
|
|
|
|
|
|
SevenDaySonnet *UsageProgress `json:"seven_day_sonnet,omitempty"` // 7天Sonnet窗口
|
|
|
|
|
|
GeminiSharedDaily *UsageProgress `json:"gemini_shared_daily,omitempty"` // Gemini shared pool RPD (Google One / Code Assist)
|
|
|
|
|
|
GeminiProDaily *UsageProgress `json:"gemini_pro_daily,omitempty"` // Gemini Pro 日配额
|
|
|
|
|
|
GeminiFlashDaily *UsageProgress `json:"gemini_flash_daily,omitempty"` // Gemini Flash 日配额
|
|
|
|
|
|
GeminiSharedMinute *UsageProgress `json:"gemini_shared_minute,omitempty"` // Gemini shared pool RPM (Google One / Code Assist)
|
|
|
|
|
|
GeminiProMinute *UsageProgress `json:"gemini_pro_minute,omitempty"` // Gemini Pro RPM
|
|
|
|
|
|
GeminiFlashMinute *UsageProgress `json:"gemini_flash_minute,omitempty"` // Gemini Flash RPM
|
2026-01-02 22:41:55 +08:00
|
|
|
|
|
|
|
|
|
|
// Antigravity 多模型配额
|
|
|
|
|
|
AntigravityQuota map[string]*AntigravityModelQuota `json:"antigravity_quota,omitempty"`
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ClaudeUsageResponse Anthropic API返回的usage结构
|
|
|
|
|
|
type ClaudeUsageResponse struct {
|
|
|
|
|
|
FiveHour struct {
|
|
|
|
|
|
Utilization float64 `json:"utilization"`
|
|
|
|
|
|
ResetsAt string `json:"resets_at"`
|
|
|
|
|
|
} `json:"five_hour"`
|
|
|
|
|
|
SevenDay struct {
|
|
|
|
|
|
Utilization float64 `json:"utilization"`
|
|
|
|
|
|
ResetsAt string `json:"resets_at"`
|
|
|
|
|
|
} `json:"seven_day"`
|
|
|
|
|
|
SevenDaySonnet struct {
|
|
|
|
|
|
Utilization float64 `json:"utilization"`
|
|
|
|
|
|
ResetsAt string `json:"resets_at"`
|
|
|
|
|
|
} `json:"seven_day_sonnet"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// ClaudeUsageFetchOptions 包含获取 Claude 用量数据所需的所有选项
|
|
|
|
|
|
type ClaudeUsageFetchOptions struct {
|
|
|
|
|
|
AccessToken string // OAuth access token
|
|
|
|
|
|
ProxyURL string // 代理 URL(可选)
|
|
|
|
|
|
AccountID int64 // 账号 ID(用于 TLS 指纹选择)
|
|
|
|
|
|
EnableTLSFingerprint bool // 是否启用 TLS 指纹伪装
|
|
|
|
|
|
Fingerprint *Fingerprint // 缓存的指纹信息(User-Agent 等)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:56:11 +08:00
|
|
|
|
// ClaudeUsageFetcher fetches usage data from Anthropic OAuth API
|
|
|
|
|
|
type ClaudeUsageFetcher interface {
|
|
|
|
|
|
FetchUsage(ctx context.Context, accessToken, proxyURL string) (*ClaudeUsageResponse, error)
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// FetchUsageWithOptions 使用完整选项获取用量数据,支持 TLS 指纹和自定义 User-Agent
|
|
|
|
|
|
FetchUsageWithOptions(ctx context.Context, opts *ClaudeUsageFetchOptions) (*ClaudeUsageResponse, error)
|
2025-12-20 11:56:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// AccountUsageService 账号使用量查询服务
|
|
|
|
|
|
type AccountUsageService struct {
|
2026-01-03 00:32:54 +08:00
|
|
|
|
accountRepo AccountRepository
|
|
|
|
|
|
usageLogRepo UsageLogRepository
|
|
|
|
|
|
usageFetcher ClaudeUsageFetcher
|
|
|
|
|
|
geminiQuotaService *GeminiQuotaService
|
|
|
|
|
|
antigravityQuotaFetcher *AntigravityQuotaFetcher
|
2026-01-03 13:10:43 +08:00
|
|
|
|
cache *UsageCache
|
2026-02-02 22:13:50 +08:00
|
|
|
|
identityCache IdentityCache
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewAccountUsageService 创建AccountUsageService实例
|
2026-01-02 22:41:55 +08:00
|
|
|
|
func NewAccountUsageService(
|
|
|
|
|
|
accountRepo AccountRepository,
|
|
|
|
|
|
usageLogRepo UsageLogRepository,
|
|
|
|
|
|
usageFetcher ClaudeUsageFetcher,
|
|
|
|
|
|
geminiQuotaService *GeminiQuotaService,
|
|
|
|
|
|
antigravityQuotaFetcher *AntigravityQuotaFetcher,
|
2026-01-03 13:10:43 +08:00
|
|
|
|
cache *UsageCache,
|
2026-02-02 22:13:50 +08:00
|
|
|
|
identityCache IdentityCache,
|
2026-01-02 22:41:55 +08:00
|
|
|
|
) *AccountUsageService {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return &AccountUsageService{
|
2026-01-03 00:32:54 +08:00
|
|
|
|
accountRepo: accountRepo,
|
|
|
|
|
|
usageLogRepo: usageLogRepo,
|
|
|
|
|
|
usageFetcher: usageFetcher,
|
|
|
|
|
|
geminiQuotaService: geminiQuotaService,
|
|
|
|
|
|
antigravityQuotaFetcher: antigravityQuotaFetcher,
|
2026-01-03 13:10:43 +08:00
|
|
|
|
cache: cache,
|
2026-02-02 22:13:50 +08:00
|
|
|
|
identityCache: identityCache,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetUsage 获取账号使用量
|
2025-12-28 23:12:44 +08:00
|
|
|
|
// OAuth账号: 调用Anthropic API获取真实数据(需要profile scope),API响应缓存10分钟,窗口统计缓存1分钟
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// Setup Token账号: 根据session_window推算5h窗口,7d数据不可用(没有profile scope)
|
|
|
|
|
|
// API Key账号: 不支持usage查询
|
|
|
|
|
|
func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*UsageInfo, error) {
|
2025-12-19 21:26:19 +08:00
|
|
|
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get account failed: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 04:22:39 +08:00
|
|
|
|
if account.Platform == PlatformGemini {
|
|
|
|
|
|
return s.getGeminiUsage(ctx, account)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-02 22:41:55 +08:00
|
|
|
|
// Antigravity 平台:使用 AntigravityQuotaFetcher 获取额度
|
|
|
|
|
|
if account.Platform == PlatformAntigravity {
|
|
|
|
|
|
return s.getAntigravityUsage(ctx, account)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// 只有oauth类型账号可以通过API获取usage(有profile scope)
|
|
|
|
|
|
if account.CanGetUsage() {
|
2025-12-28 23:12:44 +08:00
|
|
|
|
var apiResp *ClaudeUsageResponse
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 检查 API 缓存(10 分钟)
|
2026-01-03 13:10:43 +08:00
|
|
|
|
if cached, ok := s.cache.apiCache.Load(accountID); ok {
|
2025-12-28 23:12:44 +08:00
|
|
|
|
if cache, ok := cached.(*apiUsageCache); ok && time.Since(cache.timestamp) < apiCacheTTL {
|
|
|
|
|
|
apiResp = cache.response
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 23:12:44 +08:00
|
|
|
|
// 2. 如果没有缓存,从 API 获取
|
|
|
|
|
|
if apiResp == nil {
|
|
|
|
|
|
apiResp, err = s.fetchOAuthUsageRaw(ctx, account)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
// 缓存 API 响应
|
2026-01-03 13:10:43 +08:00
|
|
|
|
s.cache.apiCache.Store(accountID, &apiUsageCache{
|
2025-12-28 23:12:44 +08:00
|
|
|
|
response: apiResp,
|
|
|
|
|
|
timestamp: time.Now(),
|
|
|
|
|
|
})
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 23:12:44 +08:00
|
|
|
|
// 3. 构建 UsageInfo(每次都重新计算 RemainingSeconds)
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
usage := s.buildUsageInfo(apiResp, &now)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
2025-12-28 23:12:44 +08:00
|
|
|
|
// 4. 添加窗口统计(有独立缓存,1 分钟)
|
|
|
|
|
|
s.addWindowStats(ctx, account, usage)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
return usage, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Setup Token账号:根据session_window推算(没有profile scope,无法调用usage API)
|
2025-12-26 15:40:24 +08:00
|
|
|
|
if account.Type == AccountTypeSetupToken {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
usage := s.estimateSetupTokenUsage(account)
|
|
|
|
|
|
// 添加窗口统计
|
|
|
|
|
|
s.addWindowStats(ctx, account, usage)
|
|
|
|
|
|
return usage, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// API Key账号不支持usage查询
|
|
|
|
|
|
return nil, fmt.Errorf("account type %s does not support usage query", account.Type)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 04:22:39 +08:00
|
|
|
|
func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Account) (*UsageInfo, error) {
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
usage := &UsageInfo{
|
|
|
|
|
|
UpdatedAt: &now,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 08:29:57 +08:00
|
|
|
|
if s.geminiQuotaService == nil || s.usageLogRepo == nil {
|
|
|
|
|
|
return usage, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 04:22:39 +08:00
|
|
|
|
quota, ok := s.geminiQuotaService.QuotaForAccount(ctx, account)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return usage, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 15:36:00 +08:00
|
|
|
|
dayStart := geminiDailyWindowStart(now)
|
2026-02-02 22:13:50 +08:00
|
|
|
|
stats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, dayStart, now, 0, 0, account.ID, 0, nil, nil)
|
2026-01-01 08:29:57 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get gemini usage stats failed: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 15:36:00 +08:00
|
|
|
|
dayTotals := geminiAggregateUsage(stats)
|
|
|
|
|
|
dailyResetAt := geminiDailyResetTime(now)
|
2026-01-01 04:22:39 +08:00
|
|
|
|
|
2026-01-04 15:36:00 +08:00
|
|
|
|
// Daily window (RPD)
|
|
|
|
|
|
if quota.SharedRPD > 0 {
|
|
|
|
|
|
totalReq := dayTotals.ProRequests + dayTotals.FlashRequests
|
|
|
|
|
|
totalTokens := dayTotals.ProTokens + dayTotals.FlashTokens
|
|
|
|
|
|
totalCost := dayTotals.ProCost + dayTotals.FlashCost
|
|
|
|
|
|
usage.GeminiSharedDaily = buildGeminiUsageProgress(totalReq, quota.SharedRPD, dailyResetAt, totalTokens, totalCost, now)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
usage.GeminiProDaily = buildGeminiUsageProgress(dayTotals.ProRequests, quota.ProRPD, dailyResetAt, dayTotals.ProTokens, dayTotals.ProCost, now)
|
|
|
|
|
|
usage.GeminiFlashDaily = buildGeminiUsageProgress(dayTotals.FlashRequests, quota.FlashRPD, dailyResetAt, dayTotals.FlashTokens, dayTotals.FlashCost, now)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Minute window (RPM) - fixed-window approximation: current minute [truncate(now), truncate(now)+1m)
|
|
|
|
|
|
minuteStart := now.Truncate(time.Minute)
|
|
|
|
|
|
minuteResetAt := minuteStart.Add(time.Minute)
|
2026-02-02 22:13:50 +08:00
|
|
|
|
minuteStats, err := s.usageLogRepo.GetModelStatsWithFilters(ctx, minuteStart, now, 0, 0, account.ID, 0, nil, nil)
|
2026-01-04 15:36:00 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get gemini minute usage stats failed: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
minuteTotals := geminiAggregateUsage(minuteStats)
|
|
|
|
|
|
|
|
|
|
|
|
if quota.SharedRPM > 0 {
|
|
|
|
|
|
totalReq := minuteTotals.ProRequests + minuteTotals.FlashRequests
|
|
|
|
|
|
totalTokens := minuteTotals.ProTokens + minuteTotals.FlashTokens
|
|
|
|
|
|
totalCost := minuteTotals.ProCost + minuteTotals.FlashCost
|
|
|
|
|
|
usage.GeminiSharedMinute = buildGeminiUsageProgress(totalReq, quota.SharedRPM, minuteResetAt, totalTokens, totalCost, now)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
usage.GeminiProMinute = buildGeminiUsageProgress(minuteTotals.ProRequests, quota.ProRPM, minuteResetAt, minuteTotals.ProTokens, minuteTotals.ProCost, now)
|
|
|
|
|
|
usage.GeminiFlashMinute = buildGeminiUsageProgress(minuteTotals.FlashRequests, quota.FlashRPM, minuteResetAt, minuteTotals.FlashTokens, minuteTotals.FlashCost, now)
|
|
|
|
|
|
}
|
2026-01-01 04:22:39 +08:00
|
|
|
|
|
|
|
|
|
|
return usage, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-02 22:41:55 +08:00
|
|
|
|
// getAntigravityUsage 获取 Antigravity 账户额度
|
|
|
|
|
|
func (s *AccountUsageService) getAntigravityUsage(ctx context.Context, account *Account) (*UsageInfo, error) {
|
|
|
|
|
|
if s.antigravityQuotaFetcher == nil || !s.antigravityQuotaFetcher.CanFetch(account) {
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
return &UsageInfo{UpdatedAt: &now}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 检查缓存(10 分钟)
|
2026-01-03 13:10:43 +08:00
|
|
|
|
if cached, ok := s.cache.antigravityCache.Load(account.ID); ok {
|
2026-01-02 22:41:55 +08:00
|
|
|
|
if cache, ok := cached.(*antigravityUsageCache); ok && time.Since(cache.timestamp) < apiCacheTTL {
|
|
|
|
|
|
// 重新计算 RemainingSeconds
|
|
|
|
|
|
usage := cache.usageInfo
|
|
|
|
|
|
if usage.FiveHour != nil && usage.FiveHour.ResetsAt != nil {
|
|
|
|
|
|
usage.FiveHour.RemainingSeconds = int(time.Until(*usage.FiveHour.ResetsAt).Seconds())
|
|
|
|
|
|
}
|
|
|
|
|
|
return usage, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 获取代理 URL
|
|
|
|
|
|
proxyURL := s.antigravityQuotaFetcher.GetProxyURL(ctx, account)
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 调用 API 获取额度
|
|
|
|
|
|
result, err := s.antigravityQuotaFetcher.FetchQuota(ctx, account, proxyURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("fetch antigravity quota failed: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 缓存结果
|
2026-01-03 13:10:43 +08:00
|
|
|
|
s.cache.antigravityCache.Store(account.ID, &antigravityUsageCache{
|
2026-01-02 22:41:55 +08:00
|
|
|
|
usageInfo: result.UsageInfo,
|
|
|
|
|
|
timestamp: time.Now(),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return result.UsageInfo, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 23:12:44 +08:00
|
|
|
|
// addWindowStats 为 usage 数据添加窗口期统计
|
|
|
|
|
|
// 使用独立缓存(1 分钟),与 API 缓存分离
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Account, usage *UsageInfo) {
|
2025-12-28 23:12:44 +08:00
|
|
|
|
// 修复:即使 FiveHour 为 nil,也要尝试获取统计数据
|
|
|
|
|
|
// 因为 SevenDay/SevenDaySonnet 可能需要
|
|
|
|
|
|
if usage.FiveHour == nil && usage.SevenDay == nil && usage.SevenDaySonnet == nil {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 23:12:44 +08:00
|
|
|
|
// 检查窗口统计缓存(1 分钟)
|
|
|
|
|
|
var windowStats *WindowStats
|
2026-01-03 13:10:43 +08:00
|
|
|
|
if cached, ok := s.cache.windowStatsCache.Load(account.ID); ok {
|
2025-12-28 23:12:44 +08:00
|
|
|
|
if cache, ok := cached.(*windowStatsCache); ok && time.Since(cache.timestamp) < windowStatsCacheTTL {
|
|
|
|
|
|
windowStats = cache.stats
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 23:12:44 +08:00
|
|
|
|
// 如果没有缓存,从数据库查询
|
|
|
|
|
|
if windowStats == nil {
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 使用统一的窗口开始时间计算逻辑(考虑窗口过期情况)
|
|
|
|
|
|
startTime := account.GetCurrentWindowStartTime()
|
2025-12-28 23:12:44 +08:00
|
|
|
|
|
|
|
|
|
|
stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, startTime)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("Failed to get window stats for account %d: %v", account.ID, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
windowStats = &WindowStats{
|
2026-01-14 16:12:08 +08:00
|
|
|
|
Requests: stats.Requests,
|
|
|
|
|
|
Tokens: stats.Tokens,
|
|
|
|
|
|
Cost: stats.Cost,
|
|
|
|
|
|
StandardCost: stats.StandardCost,
|
|
|
|
|
|
UserCost: stats.UserCost,
|
2025-12-28 23:12:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 缓存窗口统计(1 分钟)
|
2026-01-03 13:10:43 +08:00
|
|
|
|
s.cache.windowStatsCache.Store(account.ID, &windowStatsCache{
|
2025-12-28 23:12:44 +08:00
|
|
|
|
stats: windowStats,
|
|
|
|
|
|
timestamp: time.Now(),
|
|
|
|
|
|
})
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 23:12:44 +08:00
|
|
|
|
// 为 FiveHour 添加 WindowStats(5h 窗口统计)
|
|
|
|
|
|
if usage.FiveHour != nil {
|
|
|
|
|
|
usage.FiveHour.WindowStats = windowStats
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetTodayStats 获取账号今日统计
|
|
|
|
|
|
func (s *AccountUsageService) GetTodayStats(ctx context.Context, accountID int64) (*WindowStats, error) {
|
2025-12-19 21:26:19 +08:00
|
|
|
|
stats, err := s.usageLogRepo.GetAccountTodayStats(ctx, accountID)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get today stats failed: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &WindowStats{
|
2026-01-14 16:12:08 +08:00
|
|
|
|
Requests: stats.Requests,
|
|
|
|
|
|
Tokens: stats.Tokens,
|
|
|
|
|
|
Cost: stats.Cost,
|
|
|
|
|
|
StandardCost: stats.StandardCost,
|
|
|
|
|
|
UserCost: stats.UserCost,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-24 08:41:31 +08:00
|
|
|
|
func (s *AccountUsageService) GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error) {
|
|
|
|
|
|
stats, err := s.usageLogRepo.GetAccountUsageStats(ctx, accountID, startTime, endTime)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get account usage stats failed: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return stats, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 23:12:44 +08:00
|
|
|
|
// fetchOAuthUsageRaw 从 Anthropic API 获取原始响应(不构建 UsageInfo)
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 如果账号开启了 TLS 指纹,则使用 TLS 指纹伪装
|
|
|
|
|
|
// 如果有缓存的 Fingerprint,则使用缓存的 User-Agent 等信息
|
2025-12-28 23:12:44 +08:00
|
|
|
|
func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *Account) (*ClaudeUsageResponse, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
accessToken := account.GetCredential("access_token")
|
|
|
|
|
|
if accessToken == "" {
|
|
|
|
|
|
return nil, fmt.Errorf("no access token available")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 11:56:11 +08:00
|
|
|
|
var proxyURL string
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if account.ProxyID != nil && account.Proxy != nil {
|
2025-12-20 11:56:11 +08:00
|
|
|
|
proxyURL = account.Proxy.URL()
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 22:13:50 +08:00
|
|
|
|
// 构建完整的选项
|
|
|
|
|
|
opts := &ClaudeUsageFetchOptions{
|
|
|
|
|
|
AccessToken: accessToken,
|
|
|
|
|
|
ProxyURL: proxyURL,
|
|
|
|
|
|
AccountID: account.ID,
|
|
|
|
|
|
EnableTLSFingerprint: account.IsTLSFingerprintEnabled(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 尝试获取缓存的 Fingerprint(包含 User-Agent 等信息)
|
|
|
|
|
|
if s.identityCache != nil {
|
|
|
|
|
|
if fp, err := s.identityCache.GetFingerprint(ctx, account.ID); err == nil && fp != nil {
|
|
|
|
|
|
opts.Fingerprint = fp
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.usageFetcher.FetchUsageWithOptions(ctx, opts)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// parseTime 尝试多种格式解析时间
|
|
|
|
|
|
func parseTime(s string) (time.Time, error) {
|
|
|
|
|
|
formats := []string{
|
|
|
|
|
|
time.RFC3339,
|
|
|
|
|
|
time.RFC3339Nano,
|
|
|
|
|
|
"2006-01-02T15:04:05Z",
|
|
|
|
|
|
"2006-01-02T15:04:05.000Z",
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, format := range formats {
|
|
|
|
|
|
if t, err := time.Parse(format, s); err == nil {
|
|
|
|
|
|
return t, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return time.Time{}, fmt.Errorf("unable to parse time: %s", s)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// buildUsageInfo 构建UsageInfo
|
|
|
|
|
|
func (s *AccountUsageService) buildUsageInfo(resp *ClaudeUsageResponse, updatedAt *time.Time) *UsageInfo {
|
|
|
|
|
|
info := &UsageInfo{
|
|
|
|
|
|
UpdatedAt: updatedAt,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 23:12:44 +08:00
|
|
|
|
// 5小时窗口 - 始终创建对象(即使 ResetsAt 为空)
|
|
|
|
|
|
info.FiveHour = &UsageProgress{
|
|
|
|
|
|
Utilization: resp.FiveHour.Utilization,
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if resp.FiveHour.ResetsAt != "" {
|
|
|
|
|
|
if fiveHourReset, err := parseTime(resp.FiveHour.ResetsAt); err == nil {
|
2025-12-28 23:12:44 +08:00
|
|
|
|
info.FiveHour.ResetsAt = &fiveHourReset
|
|
|
|
|
|
info.FiveHour.RemainingSeconds = int(time.Until(fiveHourReset).Seconds())
|
2025-12-18 13:50:39 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
log.Printf("Failed to parse FiveHour.ResetsAt: %s, error: %v", resp.FiveHour.ResetsAt, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 7天窗口
|
|
|
|
|
|
if resp.SevenDay.ResetsAt != "" {
|
|
|
|
|
|
if sevenDayReset, err := parseTime(resp.SevenDay.ResetsAt); err == nil {
|
|
|
|
|
|
info.SevenDay = &UsageProgress{
|
|
|
|
|
|
Utilization: resp.SevenDay.Utilization,
|
|
|
|
|
|
ResetsAt: &sevenDayReset,
|
|
|
|
|
|
RemainingSeconds: int(time.Until(sevenDayReset).Seconds()),
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log.Printf("Failed to parse SevenDay.ResetsAt: %s, error: %v", resp.SevenDay.ResetsAt, err)
|
|
|
|
|
|
info.SevenDay = &UsageProgress{
|
|
|
|
|
|
Utilization: resp.SevenDay.Utilization,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 7天Sonnet窗口
|
|
|
|
|
|
if resp.SevenDaySonnet.ResetsAt != "" {
|
|
|
|
|
|
if sonnetReset, err := parseTime(resp.SevenDaySonnet.ResetsAt); err == nil {
|
|
|
|
|
|
info.SevenDaySonnet = &UsageProgress{
|
|
|
|
|
|
Utilization: resp.SevenDaySonnet.Utilization,
|
|
|
|
|
|
ResetsAt: &sonnetReset,
|
|
|
|
|
|
RemainingSeconds: int(time.Until(sonnetReset).Seconds()),
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log.Printf("Failed to parse SevenDaySonnet.ResetsAt: %s, error: %v", resp.SevenDaySonnet.ResetsAt, err)
|
|
|
|
|
|
info.SevenDaySonnet = &UsageProgress{
|
|
|
|
|
|
Utilization: resp.SevenDaySonnet.Utilization,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return info
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// estimateSetupTokenUsage 根据session_window推算Setup Token账号的使用量
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *AccountUsageService) estimateSetupTokenUsage(account *Account) *UsageInfo {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
info := &UsageInfo{}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果有session_window信息
|
|
|
|
|
|
if account.SessionWindowEnd != nil {
|
|
|
|
|
|
remaining := int(time.Until(*account.SessionWindowEnd).Seconds())
|
|
|
|
|
|
if remaining < 0 {
|
|
|
|
|
|
remaining = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 根据状态估算使用率 (百分比形式,100 = 100%)
|
|
|
|
|
|
var utilization float64
|
|
|
|
|
|
switch account.SessionWindowStatus {
|
|
|
|
|
|
case "rejected":
|
|
|
|
|
|
utilization = 100.0
|
|
|
|
|
|
case "allowed_warning":
|
|
|
|
|
|
utilization = 80.0
|
|
|
|
|
|
default:
|
|
|
|
|
|
utilization = 0.0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
info.FiveHour = &UsageProgress{
|
|
|
|
|
|
Utilization: utilization,
|
|
|
|
|
|
ResetsAt: account.SessionWindowEnd,
|
|
|
|
|
|
RemainingSeconds: remaining,
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 没有窗口信息,返回空数据
|
|
|
|
|
|
info.FiveHour = &UsageProgress{
|
|
|
|
|
|
Utilization: 0,
|
|
|
|
|
|
RemainingSeconds: 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Setup Token无法获取7d数据
|
|
|
|
|
|
return info
|
|
|
|
|
|
}
|
2026-01-01 04:22:39 +08:00
|
|
|
|
|
|
|
|
|
|
func buildGeminiUsageProgress(used, limit int64, resetAt time.Time, tokens int64, cost float64, now time.Time) *UsageProgress {
|
2026-01-04 15:36:00 +08:00
|
|
|
|
// limit <= 0 means "no local quota window" (unknown or unlimited).
|
2026-01-01 04:22:39 +08:00
|
|
|
|
if limit <= 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
utilization := (float64(used) / float64(limit)) * 100
|
|
|
|
|
|
remainingSeconds := int(resetAt.Sub(now).Seconds())
|
|
|
|
|
|
if remainingSeconds < 0 {
|
|
|
|
|
|
remainingSeconds = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
resetCopy := resetAt
|
|
|
|
|
|
return &UsageProgress{
|
|
|
|
|
|
Utilization: utilization,
|
|
|
|
|
|
ResetsAt: &resetCopy,
|
|
|
|
|
|
RemainingSeconds: remainingSeconds,
|
2026-01-04 15:36:00 +08:00
|
|
|
|
UsedRequests: used,
|
|
|
|
|
|
LimitRequests: limit,
|
2026-01-01 04:22:39 +08:00
|
|
|
|
WindowStats: &WindowStats{
|
|
|
|
|
|
Requests: used,
|
|
|
|
|
|
Tokens: tokens,
|
|
|
|
|
|
Cost: cost,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-16 23:36:52 +08:00
|
|
|
|
|
|
|
|
|
|
// GetAccountWindowStats 获取账号在指定时间窗口内的使用统计
|
|
|
|
|
|
// 用于账号列表页面显示当前窗口费用
|
|
|
|
|
|
func (s *AccountUsageService) GetAccountWindowStats(ctx context.Context, accountID int64, startTime time.Time) (*usagestats.AccountStats, error) {
|
|
|
|
|
|
return s.usageLogRepo.GetAccountWindowStats(ctx, accountID, startTime)
|
|
|
|
|
|
}
|