mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-07 08:50:22 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
870b21916c | ||
|
|
fb119f9a67 | ||
|
|
ad54795a24 | ||
|
|
0abe322cca | ||
|
|
b071511676 | ||
|
|
7d9a757a26 | ||
|
|
bbf4024dc7 |
@@ -17,10 +17,17 @@ linters:
|
|||||||
service-no-repository:
|
service-no-repository:
|
||||||
list-mode: original
|
list-mode: original
|
||||||
files:
|
files:
|
||||||
- internal/service/**
|
- "**/internal/service/**"
|
||||||
deny:
|
deny:
|
||||||
- pkg: sub2api/internal/repository
|
- pkg: sub2api/internal/repository
|
||||||
desc: "service must not import repository"
|
desc: "service must not import repository"
|
||||||
|
handler-no-repository:
|
||||||
|
list-mode: original
|
||||||
|
files:
|
||||||
|
- "**/internal/handler/**"
|
||||||
|
deny:
|
||||||
|
- pkg: sub2api/internal/repository
|
||||||
|
desc: "handler must not import repository"
|
||||||
errcheck:
|
errcheck:
|
||||||
# Report about not checking of errors in type assertions: `a := b.(MyStruct)`.
|
# Report about not checking of errors in type assertions: `a := b.(MyStruct)`.
|
||||||
# Such cases aren't reported by default.
|
# Such cases aren't reported by default.
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||||
usageLogRepository := repository.NewUsageLogRepository(db)
|
usageLogRepository := repository.NewUsageLogRepository(db)
|
||||||
usageService := service.NewUsageService(usageLogRepository, userRepository)
|
usageService := service.NewUsageService(usageLogRepository, userRepository)
|
||||||
usageHandler := handler.NewUsageHandler(usageService, usageLogRepository, apiKeyService)
|
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
||||||
redeemCodeRepository := repository.NewRedeemCodeRepository(db)
|
redeemCodeRepository := repository.NewRedeemCodeRepository(db)
|
||||||
billingCache := repository.NewBillingCache(client)
|
billingCache := repository.NewBillingCache(client)
|
||||||
billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository)
|
billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository)
|
||||||
@@ -67,7 +67,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService)
|
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService)
|
||||||
redeemHandler := handler.NewRedeemHandler(redeemService)
|
redeemHandler := handler.NewRedeemHandler(redeemService)
|
||||||
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
|
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
|
||||||
dashboardHandler := admin.NewDashboardHandler(usageLogRepository)
|
dashboardService := service.NewDashboardService(usageLogRepository)
|
||||||
|
dashboardHandler := admin.NewDashboardHandler(dashboardService)
|
||||||
accountRepository := repository.NewAccountRepository(db)
|
accountRepository := repository.NewAccountRepository(db)
|
||||||
proxyRepository := repository.NewProxyRepository(db)
|
proxyRepository := repository.NewProxyRepository(db)
|
||||||
proxyExitInfoProber := repository.NewProxyExitInfoProber()
|
proxyExitInfoProber := repository.NewProxyExitInfoProber()
|
||||||
@@ -83,7 +84,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher)
|
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher)
|
||||||
httpUpstream := repository.NewHTTPUpstream(configConfig)
|
httpUpstream := repository.NewHTTPUpstream(configConfig)
|
||||||
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream)
|
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream)
|
||||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, usageLogRepository)
|
concurrencyCache := repository.NewConcurrencyCache(client)
|
||||||
|
concurrencyService := service.NewConcurrencyService(concurrencyCache)
|
||||||
|
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService)
|
||||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
||||||
proxyHandler := admin.NewProxyHandler(adminService)
|
proxyHandler := admin.NewProxyHandler(adminService)
|
||||||
@@ -95,7 +98,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
updateService := service.ProvideUpdateService(updateCache, gitHubReleaseClient, serviceBuildInfo)
|
updateService := service.ProvideUpdateService(updateCache, gitHubReleaseClient, serviceBuildInfo)
|
||||||
systemHandler := handler.ProvideSystemHandler(updateService)
|
systemHandler := handler.ProvideSystemHandler(updateService)
|
||||||
adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService)
|
adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService)
|
||||||
adminUsageHandler := admin.NewUsageHandler(usageLogRepository, apiKeyRepository, usageService, adminService)
|
adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService)
|
||||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler)
|
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler)
|
||||||
gatewayCache := repository.NewGatewayCache(client)
|
gatewayCache := repository.NewGatewayCache(client)
|
||||||
pricingRemoteClient := repository.NewPricingRemoteClient()
|
pricingRemoteClient := repository.NewPricingRemoteClient()
|
||||||
@@ -107,8 +110,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
identityCache := repository.NewIdentityCache(client)
|
identityCache := repository.NewIdentityCache(client)
|
||||||
identityService := service.NewIdentityService(identityCache)
|
identityService := service.NewIdentityService(identityCache)
|
||||||
gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream)
|
gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream)
|
||||||
concurrencyCache := repository.NewConcurrencyCache(client)
|
|
||||||
concurrencyService := service.NewConcurrencyService(concurrencyCache)
|
|
||||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, userService, concurrencyService, billingCacheService)
|
gatewayHandler := handler.NewGatewayHandler(gatewayService, userService, concurrencyService, billingCacheService)
|
||||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream)
|
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream)
|
||||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)
|
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/claude"
|
"sub2api/internal/pkg/claude"
|
||||||
"sub2api/internal/pkg/openai"
|
"sub2api/internal/pkg/openai"
|
||||||
"sub2api/internal/pkg/response"
|
"sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/pkg/timezone"
|
"sub2api/internal/pkg/timezone"
|
||||||
"sub2api/internal/repository"
|
|
||||||
"sub2api/internal/service"
|
"sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -33,11 +33,11 @@ type AccountHandler struct {
|
|||||||
rateLimitService *service.RateLimitService
|
rateLimitService *service.RateLimitService
|
||||||
accountUsageService *service.AccountUsageService
|
accountUsageService *service.AccountUsageService
|
||||||
accountTestService *service.AccountTestService
|
accountTestService *service.AccountTestService
|
||||||
usageLogRepo *repository.UsageLogRepository
|
concurrencyService *service.ConcurrencyService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAccountHandler creates a new admin account handler
|
// NewAccountHandler creates a new admin account handler
|
||||||
func NewAccountHandler(adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService, usageLogRepo *repository.UsageLogRepository) *AccountHandler {
|
func NewAccountHandler(adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService, concurrencyService *service.ConcurrencyService) *AccountHandler {
|
||||||
return &AccountHandler{
|
return &AccountHandler{
|
||||||
adminService: adminService,
|
adminService: adminService,
|
||||||
oauthService: oauthService,
|
oauthService: oauthService,
|
||||||
@@ -45,7 +45,7 @@ func NewAccountHandler(adminService service.AdminService, oauthService *service.
|
|||||||
rateLimitService: rateLimitService,
|
rateLimitService: rateLimitService,
|
||||||
accountUsageService: accountUsageService,
|
accountUsageService: accountUsageService,
|
||||||
accountTestService: accountTestService,
|
accountTestService: accountTestService,
|
||||||
usageLogRepo: usageLogRepo,
|
concurrencyService: concurrencyService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +76,12 @@ type UpdateAccountRequest struct {
|
|||||||
GroupIDs *[]int64 `json:"group_ids"`
|
GroupIDs *[]int64 `json:"group_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccountWithConcurrency extends Account with real-time concurrency info
|
||||||
|
type AccountWithConcurrency struct {
|
||||||
|
*model.Account
|
||||||
|
CurrentConcurrency int `json:"current_concurrency"`
|
||||||
|
}
|
||||||
|
|
||||||
// List handles listing all accounts with pagination
|
// List handles listing all accounts with pagination
|
||||||
// GET /api/v1/admin/accounts
|
// GET /api/v1/admin/accounts
|
||||||
func (h *AccountHandler) List(c *gin.Context) {
|
func (h *AccountHandler) List(c *gin.Context) {
|
||||||
@@ -91,7 +97,28 @@ func (h *AccountHandler) List(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Paginated(c, accounts, total, page, pageSize)
|
// Get current concurrency counts for all accounts
|
||||||
|
accountIDs := make([]int64, len(accounts))
|
||||||
|
for i, acc := range accounts {
|
||||||
|
accountIDs[i] = acc.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
concurrencyCounts, err := h.concurrencyService.GetAccountConcurrencyBatch(c.Request.Context(), accountIDs)
|
||||||
|
if err != nil {
|
||||||
|
// Log error but don't fail the request, just use 0 for all
|
||||||
|
concurrencyCounts = make(map[int64]int)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response with concurrency info
|
||||||
|
result := make([]AccountWithConcurrency, len(accounts))
|
||||||
|
for i := range accounts {
|
||||||
|
result[i] = AccountWithConcurrency{
|
||||||
|
Account: &accounts[i],
|
||||||
|
CurrentConcurrency: concurrencyCounts[accounts[i].ID],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Paginated(c, result, total, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByID handles getting an account by ID
|
// GetByID handles getting an account by ID
|
||||||
@@ -314,7 +341,7 @@ func (h *AccountHandler) GetStats(c *gin.Context) {
|
|||||||
endTime := timezone.StartOfDay(now.AddDate(0, 0, 1))
|
endTime := timezone.StartOfDay(now.AddDate(0, 0, 1))
|
||||||
startTime := timezone.StartOfDay(now.AddDate(0, 0, -days+1))
|
startTime := timezone.StartOfDay(now.AddDate(0, 0, -days+1))
|
||||||
|
|
||||||
stats, err := h.usageLogRepo.GetAccountUsageStats(c.Request.Context(), accountID, startTime, endTime)
|
stats, err := h.accountUsageService.GetAccountUsageStats(c.Request.Context(), accountID, startTime, endTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalError(c, "Failed to get account stats: "+err.Error())
|
response.InternalError(c, "Failed to get account stats: "+err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"sub2api/internal/pkg/response"
|
"sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/pkg/timezone"
|
"sub2api/internal/pkg/timezone"
|
||||||
"sub2api/internal/repository"
|
"sub2api/internal/service"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -12,15 +12,15 @@ import (
|
|||||||
|
|
||||||
// DashboardHandler handles admin dashboard statistics
|
// DashboardHandler handles admin dashboard statistics
|
||||||
type DashboardHandler struct {
|
type DashboardHandler struct {
|
||||||
usageRepo *repository.UsageLogRepository
|
dashboardService *service.DashboardService
|
||||||
startTime time.Time // Server start time for uptime calculation
|
startTime time.Time // Server start time for uptime calculation
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDashboardHandler creates a new admin dashboard handler
|
// NewDashboardHandler creates a new admin dashboard handler
|
||||||
func NewDashboardHandler(usageRepo *repository.UsageLogRepository) *DashboardHandler {
|
func NewDashboardHandler(dashboardService *service.DashboardService) *DashboardHandler {
|
||||||
return &DashboardHandler{
|
return &DashboardHandler{
|
||||||
usageRepo: usageRepo,
|
dashboardService: dashboardService,
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ func parseTimeRange(c *gin.Context) (time.Time, time.Time) {
|
|||||||
// GetStats handles getting dashboard statistics
|
// GetStats handles getting dashboard statistics
|
||||||
// GET /api/v1/admin/dashboard/stats
|
// GET /api/v1/admin/dashboard/stats
|
||||||
func (h *DashboardHandler) GetStats(c *gin.Context) {
|
func (h *DashboardHandler) GetStats(c *gin.Context) {
|
||||||
stats, err := h.usageRepo.GetDashboardStats(c.Request.Context())
|
stats, err := h.dashboardService.GetDashboardStats(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, 500, "Failed to get dashboard statistics")
|
response.Error(c, 500, "Failed to get dashboard statistics")
|
||||||
return
|
return
|
||||||
@@ -142,7 +142,7 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
trend, err := h.usageRepo.GetUsageTrendWithFilters(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID)
|
trend, err := h.dashboardService.GetUsageTrendWithFilters(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, 500, "Failed to get usage trend")
|
response.Error(c, 500, "Failed to get usage trend")
|
||||||
return
|
return
|
||||||
@@ -175,7 +175,7 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err := h.usageRepo.GetModelStatsWithFilters(c.Request.Context(), startTime, endTime, userID, apiKeyID, 0)
|
stats, err := h.dashboardService.GetModelStatsWithFilters(c.Request.Context(), startTime, endTime, userID, apiKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, 500, "Failed to get model statistics")
|
response.Error(c, 500, "Failed to get model statistics")
|
||||||
return
|
return
|
||||||
@@ -200,7 +200,7 @@ func (h *DashboardHandler) GetApiKeyUsageTrend(c *gin.Context) {
|
|||||||
limit = 5
|
limit = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
trend, err := h.usageRepo.GetApiKeyUsageTrend(c.Request.Context(), startTime, endTime, granularity, limit)
|
trend, err := h.dashboardService.GetApiKeyUsageTrend(c.Request.Context(), startTime, endTime, granularity, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, 500, "Failed to get API key usage trend")
|
response.Error(c, 500, "Failed to get API key usage trend")
|
||||||
return
|
return
|
||||||
@@ -226,7 +226,7 @@ func (h *DashboardHandler) GetUserUsageTrend(c *gin.Context) {
|
|||||||
limit = 12
|
limit = 12
|
||||||
}
|
}
|
||||||
|
|
||||||
trend, err := h.usageRepo.GetUserUsageTrend(c.Request.Context(), startTime, endTime, granularity, limit)
|
trend, err := h.dashboardService.GetUserUsageTrend(c.Request.Context(), startTime, endTime, granularity, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, 500, "Failed to get user usage trend")
|
response.Error(c, 500, "Failed to get user usage trend")
|
||||||
return
|
return
|
||||||
@@ -259,7 +259,7 @@ func (h *DashboardHandler) GetBatchUsersUsage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err := h.usageRepo.GetBatchUserUsageStats(c.Request.Context(), req.UserIDs)
|
stats, err := h.dashboardService.GetBatchUserUsageStats(c.Request.Context(), req.UserIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, 500, "Failed to get user usage stats")
|
response.Error(c, 500, "Failed to get user usage stats")
|
||||||
return
|
return
|
||||||
@@ -287,7 +287,7 @@ func (h *DashboardHandler) GetBatchApiKeysUsage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err := h.usageRepo.GetBatchApiKeyUsageStats(c.Request.Context(), req.ApiKeyIDs)
|
stats, err := h.dashboardService.GetBatchApiKeyUsageStats(c.Request.Context(), req.ApiKeyIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, 500, "Failed to get API key usage stats")
|
response.Error(c, 500, "Failed to get API key usage stats")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sub2api/internal/pkg/pagination"
|
"sub2api/internal/pkg/pagination"
|
||||||
"sub2api/internal/pkg/response"
|
"sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/pkg/timezone"
|
"sub2api/internal/pkg/timezone"
|
||||||
"sub2api/internal/repository"
|
"sub2api/internal/pkg/usagestats"
|
||||||
"sub2api/internal/service"
|
"sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -15,24 +15,21 @@ import (
|
|||||||
|
|
||||||
// UsageHandler handles admin usage-related requests
|
// UsageHandler handles admin usage-related requests
|
||||||
type UsageHandler struct {
|
type UsageHandler struct {
|
||||||
usageRepo *repository.UsageLogRepository
|
usageService *service.UsageService
|
||||||
apiKeyRepo *repository.ApiKeyRepository
|
apiKeyService *service.ApiKeyService
|
||||||
usageService *service.UsageService
|
adminService service.AdminService
|
||||||
adminService service.AdminService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUsageHandler creates a new admin usage handler
|
// NewUsageHandler creates a new admin usage handler
|
||||||
func NewUsageHandler(
|
func NewUsageHandler(
|
||||||
usageRepo *repository.UsageLogRepository,
|
|
||||||
apiKeyRepo *repository.ApiKeyRepository,
|
|
||||||
usageService *service.UsageService,
|
usageService *service.UsageService,
|
||||||
|
apiKeyService *service.ApiKeyService,
|
||||||
adminService service.AdminService,
|
adminService service.AdminService,
|
||||||
) *UsageHandler {
|
) *UsageHandler {
|
||||||
return &UsageHandler{
|
return &UsageHandler{
|
||||||
usageRepo: usageRepo,
|
usageService: usageService,
|
||||||
apiKeyRepo: apiKeyRepo,
|
apiKeyService: apiKeyService,
|
||||||
usageService: usageService,
|
adminService: adminService,
|
||||||
adminService: adminService,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,14 +81,14 @@ func (h *UsageHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
||||||
filters := repository.UsageLogFilters{
|
filters := usagestats.UsageLogFilters{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ApiKeyID: apiKeyID,
|
ApiKeyID: apiKeyID,
|
||||||
StartTime: startTime,
|
StartTime: startTime,
|
||||||
EndTime: endTime,
|
EndTime: endTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
records, result, err := h.usageRepo.ListWithFilters(c.Request.Context(), params, filters)
|
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalError(c, "Failed to list usage records: "+err.Error())
|
response.InternalError(c, "Failed to list usage records: "+err.Error())
|
||||||
return
|
return
|
||||||
@@ -179,7 +176,7 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get global stats
|
// Get global stats
|
||||||
stats, err := h.usageRepo.GetGlobalStats(c.Request.Context(), startTime, endTime)
|
stats, err := h.usageService.GetGlobalStats(c.Request.Context(), startTime, endTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalError(c, "Failed to get usage statistics: "+err.Error())
|
response.InternalError(c, "Failed to get usage statistics: "+err.Error())
|
||||||
return
|
return
|
||||||
@@ -237,7 +234,7 @@ func (h *UsageHandler) SearchApiKeys(c *gin.Context) {
|
|||||||
userID = id
|
userID = id
|
||||||
}
|
}
|
||||||
|
|
||||||
keys, err := h.apiKeyRepo.SearchApiKeys(c.Request.Context(), userID, keyword, 30)
|
keys, err := h.apiKeyService.SearchApiKeys(c.Request.Context(), userID, keyword, 30)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalError(c, "Failed to search API keys: "+err.Error())
|
response.InternalError(c, "Failed to search API keys: "+err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"sub2api/internal/pkg/pagination"
|
"sub2api/internal/pkg/pagination"
|
||||||
"sub2api/internal/pkg/response"
|
"sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/pkg/timezone"
|
"sub2api/internal/pkg/timezone"
|
||||||
"sub2api/internal/repository"
|
|
||||||
"sub2api/internal/service"
|
"sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -17,15 +16,13 @@ import (
|
|||||||
// UsageHandler handles usage-related requests
|
// UsageHandler handles usage-related requests
|
||||||
type UsageHandler struct {
|
type UsageHandler struct {
|
||||||
usageService *service.UsageService
|
usageService *service.UsageService
|
||||||
usageRepo *repository.UsageLogRepository
|
|
||||||
apiKeyService *service.ApiKeyService
|
apiKeyService *service.ApiKeyService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUsageHandler creates a new UsageHandler
|
// NewUsageHandler creates a new UsageHandler
|
||||||
func NewUsageHandler(usageService *service.UsageService, usageRepo *repository.UsageLogRepository, apiKeyService *service.ApiKeyService) *UsageHandler {
|
func NewUsageHandler(usageService *service.UsageService, apiKeyService *service.ApiKeyService) *UsageHandler {
|
||||||
return &UsageHandler{
|
return &UsageHandler{
|
||||||
usageService: usageService,
|
usageService: usageService,
|
||||||
usageRepo: usageRepo,
|
|
||||||
apiKeyService: apiKeyService,
|
apiKeyService: apiKeyService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,7 +257,7 @@ func (h *UsageHandler) DashboardStats(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err := h.usageRepo.GetUserDashboardStats(c.Request.Context(), user.ID)
|
stats, err := h.usageService.GetUserDashboardStats(c.Request.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalError(c, "Failed to get dashboard statistics")
|
response.InternalError(c, "Failed to get dashboard statistics")
|
||||||
return
|
return
|
||||||
@@ -287,7 +284,7 @@ func (h *UsageHandler) DashboardTrend(c *gin.Context) {
|
|||||||
startTime, endTime := parseUserTimeRange(c)
|
startTime, endTime := parseUserTimeRange(c)
|
||||||
granularity := c.DefaultQuery("granularity", "day")
|
granularity := c.DefaultQuery("granularity", "day")
|
||||||
|
|
||||||
trend, err := h.usageRepo.GetUserUsageTrendByUserID(c.Request.Context(), user.ID, startTime, endTime, granularity)
|
trend, err := h.usageService.GetUserUsageTrendByUserID(c.Request.Context(), user.ID, startTime, endTime, granularity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalError(c, "Failed to get usage trend")
|
response.InternalError(c, "Failed to get usage trend")
|
||||||
return
|
return
|
||||||
@@ -318,7 +315,7 @@ func (h *UsageHandler) DashboardModels(c *gin.Context) {
|
|||||||
|
|
||||||
startTime, endTime := parseUserTimeRange(c)
|
startTime, endTime := parseUserTimeRange(c)
|
||||||
|
|
||||||
stats, err := h.usageRepo.GetUserModelStats(c.Request.Context(), user.ID, startTime, endTime)
|
stats, err := h.usageService.GetUserModelStats(c.Request.Context(), user.ID, startTime, endTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalError(c, "Failed to get model statistics")
|
response.InternalError(c, "Failed to get model statistics")
|
||||||
return
|
return
|
||||||
@@ -387,7 +384,7 @@ func (h *UsageHandler) DashboardApiKeysUsage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err := h.usageRepo.GetBatchApiKeyUsageStats(c.Request.Context(), validApiKeyIDs)
|
stats, err := h.usageService.GetBatchApiKeyUsageStats(c.Request.Context(), validApiKeyIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalError(c, "Failed to get API key usage stats")
|
response.InternalError(c, "Failed to get API key usage stats")
|
||||||
return
|
return
|
||||||
|
|||||||
209
backend/internal/pkg/usagestats/usage_log_types.go
Normal file
209
backend/internal/pkg/usagestats/usage_log_types.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
package usagestats
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// DashboardStats 仪表盘统计
|
||||||
|
type DashboardStats struct {
|
||||||
|
// 用户统计
|
||||||
|
TotalUsers int64 `json:"total_users"`
|
||||||
|
TodayNewUsers int64 `json:"today_new_users"` // 今日新增用户数
|
||||||
|
ActiveUsers int64 `json:"active_users"` // 今日有请求的用户数
|
||||||
|
|
||||||
|
// API Key 统计
|
||||||
|
TotalApiKeys int64 `json:"total_api_keys"`
|
||||||
|
ActiveApiKeys int64 `json:"active_api_keys"` // 状态为 active 的 API Key 数
|
||||||
|
|
||||||
|
// 账户统计
|
||||||
|
TotalAccounts int64 `json:"total_accounts"`
|
||||||
|
NormalAccounts int64 `json:"normal_accounts"` // 正常账户数 (schedulable=true, status=active)
|
||||||
|
ErrorAccounts int64 `json:"error_accounts"` // 异常账户数 (status=error)
|
||||||
|
RateLimitAccounts int64 `json:"ratelimit_accounts"` // 限流账户数
|
||||||
|
OverloadAccounts int64 `json:"overload_accounts"` // 过载账户数
|
||||||
|
|
||||||
|
// 累计 Token 使用统计
|
||||||
|
TotalRequests int64 `json:"total_requests"`
|
||||||
|
TotalInputTokens int64 `json:"total_input_tokens"`
|
||||||
|
TotalOutputTokens int64 `json:"total_output_tokens"`
|
||||||
|
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
|
||||||
|
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
|
||||||
|
TotalTokens int64 `json:"total_tokens"`
|
||||||
|
TotalCost float64 `json:"total_cost"` // 累计标准计费
|
||||||
|
TotalActualCost float64 `json:"total_actual_cost"` // 累计实际扣除
|
||||||
|
|
||||||
|
// 今日 Token 使用统计
|
||||||
|
TodayRequests int64 `json:"today_requests"`
|
||||||
|
TodayInputTokens int64 `json:"today_input_tokens"`
|
||||||
|
TodayOutputTokens int64 `json:"today_output_tokens"`
|
||||||
|
TodayCacheCreationTokens int64 `json:"today_cache_creation_tokens"`
|
||||||
|
TodayCacheReadTokens int64 `json:"today_cache_read_tokens"`
|
||||||
|
TodayTokens int64 `json:"today_tokens"`
|
||||||
|
TodayCost float64 `json:"today_cost"` // 今日标准计费
|
||||||
|
TodayActualCost float64 `json:"today_actual_cost"` // 今日实际扣除
|
||||||
|
|
||||||
|
// 系统运行统计
|
||||||
|
AverageDurationMs float64 `json:"average_duration_ms"` // 平均响应时间
|
||||||
|
|
||||||
|
// 性能指标
|
||||||
|
Rpm int64 `json:"rpm"` // 最近1分钟的请求数
|
||||||
|
Tpm int64 `json:"tpm"` // 最近1分钟的Token数
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrendDataPoint represents a single point in trend data
|
||||||
|
type TrendDataPoint struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
InputTokens int64 `json:"input_tokens"`
|
||||||
|
OutputTokens int64 `json:"output_tokens"`
|
||||||
|
CacheTokens int64 `json:"cache_tokens"`
|
||||||
|
TotalTokens int64 `json:"total_tokens"`
|
||||||
|
Cost float64 `json:"cost"` // 标准计费
|
||||||
|
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelStat represents usage statistics for a single model
|
||||||
|
type ModelStat struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
InputTokens int64 `json:"input_tokens"`
|
||||||
|
OutputTokens int64 `json:"output_tokens"`
|
||||||
|
TotalTokens int64 `json:"total_tokens"`
|
||||||
|
Cost float64 `json:"cost"` // 标准计费
|
||||||
|
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserUsageTrendPoint represents user usage trend data point
|
||||||
|
type UserUsageTrendPoint struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
Tokens int64 `json:"tokens"`
|
||||||
|
Cost float64 `json:"cost"` // 标准计费
|
||||||
|
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApiKeyUsageTrendPoint represents API key usage trend data point
|
||||||
|
type ApiKeyUsageTrendPoint struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
ApiKeyID int64 `json:"api_key_id"`
|
||||||
|
KeyName string `json:"key_name"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
Tokens int64 `json:"tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDashboardStats 用户仪表盘统计
|
||||||
|
type UserDashboardStats struct {
|
||||||
|
// API Key 统计
|
||||||
|
TotalApiKeys int64 `json:"total_api_keys"`
|
||||||
|
ActiveApiKeys int64 `json:"active_api_keys"`
|
||||||
|
|
||||||
|
// 累计 Token 使用统计
|
||||||
|
TotalRequests int64 `json:"total_requests"`
|
||||||
|
TotalInputTokens int64 `json:"total_input_tokens"`
|
||||||
|
TotalOutputTokens int64 `json:"total_output_tokens"`
|
||||||
|
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
|
||||||
|
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
|
||||||
|
TotalTokens int64 `json:"total_tokens"`
|
||||||
|
TotalCost float64 `json:"total_cost"` // 累计标准计费
|
||||||
|
TotalActualCost float64 `json:"total_actual_cost"` // 累计实际扣除
|
||||||
|
|
||||||
|
// 今日 Token 使用统计
|
||||||
|
TodayRequests int64 `json:"today_requests"`
|
||||||
|
TodayInputTokens int64 `json:"today_input_tokens"`
|
||||||
|
TodayOutputTokens int64 `json:"today_output_tokens"`
|
||||||
|
TodayCacheCreationTokens int64 `json:"today_cache_creation_tokens"`
|
||||||
|
TodayCacheReadTokens int64 `json:"today_cache_read_tokens"`
|
||||||
|
TodayTokens int64 `json:"today_tokens"`
|
||||||
|
TodayCost float64 `json:"today_cost"` // 今日标准计费
|
||||||
|
TodayActualCost float64 `json:"today_actual_cost"` // 今日实际扣除
|
||||||
|
|
||||||
|
// 性能统计
|
||||||
|
AverageDurationMs float64 `json:"average_duration_ms"`
|
||||||
|
|
||||||
|
// 性能指标
|
||||||
|
Rpm int64 `json:"rpm"` // 最近1分钟的请求数
|
||||||
|
Tpm int64 `json:"tpm"` // 最近1分钟的Token数
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageLogFilters represents filters for usage log queries
|
||||||
|
type UsageLogFilters struct {
|
||||||
|
UserID int64
|
||||||
|
ApiKeyID int64
|
||||||
|
StartTime *time.Time
|
||||||
|
EndTime *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageStats represents usage statistics
|
||||||
|
type UsageStats struct {
|
||||||
|
TotalRequests int64 `json:"total_requests"`
|
||||||
|
TotalInputTokens int64 `json:"total_input_tokens"`
|
||||||
|
TotalOutputTokens int64 `json:"total_output_tokens"`
|
||||||
|
TotalCacheTokens int64 `json:"total_cache_tokens"`
|
||||||
|
TotalTokens int64 `json:"total_tokens"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
TotalActualCost float64 `json:"total_actual_cost"`
|
||||||
|
AverageDurationMs float64 `json:"average_duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchUserUsageStats represents usage stats for a single user
|
||||||
|
type BatchUserUsageStats struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
TodayActualCost float64 `json:"today_actual_cost"`
|
||||||
|
TotalActualCost float64 `json:"total_actual_cost"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchApiKeyUsageStats represents usage stats for a single API key
|
||||||
|
type BatchApiKeyUsageStats struct {
|
||||||
|
ApiKeyID int64 `json:"api_key_id"`
|
||||||
|
TodayActualCost float64 `json:"today_actual_cost"`
|
||||||
|
TotalActualCost float64 `json:"total_actual_cost"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountUsageHistory represents daily usage history for an account
|
||||||
|
type AccountUsageHistory struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
Tokens int64 `json:"tokens"`
|
||||||
|
Cost float64 `json:"cost"`
|
||||||
|
ActualCost float64 `json:"actual_cost"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountUsageSummary represents summary statistics for an account
|
||||||
|
type AccountUsageSummary struct {
|
||||||
|
Days int `json:"days"`
|
||||||
|
ActualDaysUsed int `json:"actual_days_used"`
|
||||||
|
TotalCost float64 `json:"total_cost"`
|
||||||
|
TotalStandardCost float64 `json:"total_standard_cost"`
|
||||||
|
TotalRequests int64 `json:"total_requests"`
|
||||||
|
TotalTokens int64 `json:"total_tokens"`
|
||||||
|
AvgDailyCost float64 `json:"avg_daily_cost"`
|
||||||
|
AvgDailyRequests float64 `json:"avg_daily_requests"`
|
||||||
|
AvgDailyTokens float64 `json:"avg_daily_tokens"`
|
||||||
|
AvgDurationMs float64 `json:"avg_duration_ms"`
|
||||||
|
Today *struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Cost float64 `json:"cost"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
Tokens int64 `json:"tokens"`
|
||||||
|
} `json:"today"`
|
||||||
|
HighestCostDay *struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Cost float64 `json:"cost"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
} `json:"highest_cost_day"`
|
||||||
|
HighestRequestDay *struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
Cost float64 `json:"cost"`
|
||||||
|
} `json:"highest_request_day"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountUsageStatsResponse represents the full usage statistics response for an account
|
||||||
|
type AccountUsageStatsResponse struct {
|
||||||
|
History []AccountUsageHistory `json:"history"`
|
||||||
|
Summary AccountUsageSummary `json:"summary"`
|
||||||
|
Models []ModelStat `json:"models"`
|
||||||
|
}
|
||||||
@@ -19,6 +19,29 @@ func NewUsageLogRepository(db *gorm.DB) *UsageLogRepository {
|
|||||||
return &UsageLogRepository{db: db}
|
return &UsageLogRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getPerformanceStats 获取 RPM 和 TPM(可选按用户过滤)
|
||||||
|
func (r *UsageLogRepository) getPerformanceStats(ctx context.Context, userID int64) (rpm, tpm int64) {
|
||||||
|
oneMinuteAgo := time.Now().Add(-1 * time.Minute)
|
||||||
|
var perfStats struct {
|
||||||
|
RequestCount int64 `gorm:"column:request_count"`
|
||||||
|
TokenCount int64 `gorm:"column:token_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
db := r.db.WithContext(ctx).Model(&model.UsageLog{}).
|
||||||
|
Select(`
|
||||||
|
COUNT(*) as request_count,
|
||||||
|
COALESCE(SUM(input_tokens + output_tokens), 0) as token_count
|
||||||
|
`).
|
||||||
|
Where("created_at >= ?", oneMinuteAgo)
|
||||||
|
|
||||||
|
if userID > 0 {
|
||||||
|
db = db.Where("user_id = ?", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Scan(&perfStats)
|
||||||
|
return perfStats.RequestCount, perfStats.TokenCount
|
||||||
|
}
|
||||||
|
|
||||||
func (r *UsageLogRepository) Create(ctx context.Context, log *model.UsageLog) error {
|
func (r *UsageLogRepository) Create(ctx context.Context, log *model.UsageLog) error {
|
||||||
return r.db.WithContext(ctx).Create(log).Error
|
return r.db.WithContext(ctx).Create(log).Error
|
||||||
}
|
}
|
||||||
@@ -113,46 +136,7 @@ func (r *UsageLogRepository) GetUserStats(ctx context.Context, userID int64, sta
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DashboardStats 仪表盘统计
|
// DashboardStats 仪表盘统计
|
||||||
type DashboardStats struct {
|
type DashboardStats = usagestats.DashboardStats
|
||||||
// 用户统计
|
|
||||||
TotalUsers int64 `json:"total_users"`
|
|
||||||
TodayNewUsers int64 `json:"today_new_users"` // 今日新增用户数
|
|
||||||
ActiveUsers int64 `json:"active_users"` // 今日有请求的用户数
|
|
||||||
|
|
||||||
// API Key 统计
|
|
||||||
TotalApiKeys int64 `json:"total_api_keys"`
|
|
||||||
ActiveApiKeys int64 `json:"active_api_keys"` // 状态为 active 的 API Key 数
|
|
||||||
|
|
||||||
// 账户统计
|
|
||||||
TotalAccounts int64 `json:"total_accounts"`
|
|
||||||
NormalAccounts int64 `json:"normal_accounts"` // 正常账户数 (schedulable=true, status=active)
|
|
||||||
ErrorAccounts int64 `json:"error_accounts"` // 异常账户数 (status=error)
|
|
||||||
RateLimitAccounts int64 `json:"ratelimit_accounts"` // 限流账户数
|
|
||||||
OverloadAccounts int64 `json:"overload_accounts"` // 过载账户数
|
|
||||||
|
|
||||||
// 累计 Token 使用统计
|
|
||||||
TotalRequests int64 `json:"total_requests"`
|
|
||||||
TotalInputTokens int64 `json:"total_input_tokens"`
|
|
||||||
TotalOutputTokens int64 `json:"total_output_tokens"`
|
|
||||||
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
|
|
||||||
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
|
|
||||||
TotalTokens int64 `json:"total_tokens"`
|
|
||||||
TotalCost float64 `json:"total_cost"` // 累计标准计费
|
|
||||||
TotalActualCost float64 `json:"total_actual_cost"` // 累计实际扣除
|
|
||||||
|
|
||||||
// 今日 Token 使用统计
|
|
||||||
TodayRequests int64 `json:"today_requests"`
|
|
||||||
TodayInputTokens int64 `json:"today_input_tokens"`
|
|
||||||
TodayOutputTokens int64 `json:"today_output_tokens"`
|
|
||||||
TodayCacheCreationTokens int64 `json:"today_cache_creation_tokens"`
|
|
||||||
TodayCacheReadTokens int64 `json:"today_cache_read_tokens"`
|
|
||||||
TodayTokens int64 `json:"today_tokens"`
|
|
||||||
TodayCost float64 `json:"today_cost"` // 今日标准计费
|
|
||||||
TodayActualCost float64 `json:"today_actual_cost"` // 今日实际扣除
|
|
||||||
|
|
||||||
// 系统运行统计
|
|
||||||
AverageDurationMs float64 `json:"average_duration_ms"` // 平均响应时间
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UsageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
|
func (r *UsageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
|
||||||
var stats DashboardStats
|
var stats DashboardStats
|
||||||
@@ -269,6 +253,9 @@ func (r *UsageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardS
|
|||||||
stats.TodayCost = todayStats.TodayCost
|
stats.TodayCost = todayStats.TodayCost
|
||||||
stats.TodayActualCost = todayStats.TodayActualCost
|
stats.TodayActualCost = todayStats.TodayActualCost
|
||||||
|
|
||||||
|
// 性能指标:RPM 和 TPM(最近1分钟,全局)
|
||||||
|
stats.Rpm, stats.Tpm = r.getPerformanceStats(ctx, 0)
|
||||||
|
|
||||||
return &stats, nil
|
return &stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,47 +385,16 @@ func (r *UsageLogRepository) GetAccountWindowStats(ctx context.Context, accountI
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TrendDataPoint represents a single point in trend data
|
// TrendDataPoint represents a single point in trend data
|
||||||
type TrendDataPoint struct {
|
type TrendDataPoint = usagestats.TrendDataPoint
|
||||||
Date string `json:"date"`
|
|
||||||
Requests int64 `json:"requests"`
|
|
||||||
InputTokens int64 `json:"input_tokens"`
|
|
||||||
OutputTokens int64 `json:"output_tokens"`
|
|
||||||
CacheTokens int64 `json:"cache_tokens"`
|
|
||||||
TotalTokens int64 `json:"total_tokens"`
|
|
||||||
Cost float64 `json:"cost"` // 标准计费
|
|
||||||
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModelStat represents usage statistics for a single model
|
// ModelStat represents usage statistics for a single model
|
||||||
type ModelStat struct {
|
type ModelStat = usagestats.ModelStat
|
||||||
Model string `json:"model"`
|
|
||||||
Requests int64 `json:"requests"`
|
|
||||||
InputTokens int64 `json:"input_tokens"`
|
|
||||||
OutputTokens int64 `json:"output_tokens"`
|
|
||||||
TotalTokens int64 `json:"total_tokens"`
|
|
||||||
Cost float64 `json:"cost"` // 标准计费
|
|
||||||
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserUsageTrendPoint represents user usage trend data point
|
// UserUsageTrendPoint represents user usage trend data point
|
||||||
type UserUsageTrendPoint struct {
|
type UserUsageTrendPoint = usagestats.UserUsageTrendPoint
|
||||||
Date string `json:"date"`
|
|
||||||
UserID int64 `json:"user_id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Requests int64 `json:"requests"`
|
|
||||||
Tokens int64 `json:"tokens"`
|
|
||||||
Cost float64 `json:"cost"` // 标准计费
|
|
||||||
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApiKeyUsageTrendPoint represents API key usage trend data point
|
// ApiKeyUsageTrendPoint represents API key usage trend data point
|
||||||
type ApiKeyUsageTrendPoint struct {
|
type ApiKeyUsageTrendPoint = usagestats.ApiKeyUsageTrendPoint
|
||||||
Date string `json:"date"`
|
|
||||||
ApiKeyID int64 `json:"api_key_id"`
|
|
||||||
KeyName string `json:"key_name"`
|
|
||||||
Requests int64 `json:"requests"`
|
|
||||||
Tokens int64 `json:"tokens"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetApiKeyUsageTrend returns usage trend data grouped by API key and date
|
// GetApiKeyUsageTrend returns usage trend data grouped by API key and date
|
||||||
func (r *UsageLogRepository) GetApiKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]ApiKeyUsageTrendPoint, error) {
|
func (r *UsageLogRepository) GetApiKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]ApiKeyUsageTrendPoint, error) {
|
||||||
@@ -531,34 +487,7 @@ func (r *UsageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UserDashboardStats 用户仪表盘统计
|
// UserDashboardStats 用户仪表盘统计
|
||||||
type UserDashboardStats struct {
|
type UserDashboardStats = usagestats.UserDashboardStats
|
||||||
// API Key 统计
|
|
||||||
TotalApiKeys int64 `json:"total_api_keys"`
|
|
||||||
ActiveApiKeys int64 `json:"active_api_keys"`
|
|
||||||
|
|
||||||
// 累计 Token 使用统计
|
|
||||||
TotalRequests int64 `json:"total_requests"`
|
|
||||||
TotalInputTokens int64 `json:"total_input_tokens"`
|
|
||||||
TotalOutputTokens int64 `json:"total_output_tokens"`
|
|
||||||
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
|
|
||||||
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
|
|
||||||
TotalTokens int64 `json:"total_tokens"`
|
|
||||||
TotalCost float64 `json:"total_cost"` // 累计标准计费
|
|
||||||
TotalActualCost float64 `json:"total_actual_cost"` // 累计实际扣除
|
|
||||||
|
|
||||||
// 今日 Token 使用统计
|
|
||||||
TodayRequests int64 `json:"today_requests"`
|
|
||||||
TodayInputTokens int64 `json:"today_input_tokens"`
|
|
||||||
TodayOutputTokens int64 `json:"today_output_tokens"`
|
|
||||||
TodayCacheCreationTokens int64 `json:"today_cache_creation_tokens"`
|
|
||||||
TodayCacheReadTokens int64 `json:"today_cache_read_tokens"`
|
|
||||||
TodayTokens int64 `json:"today_tokens"`
|
|
||||||
TodayCost float64 `json:"today_cost"` // 今日标准计费
|
|
||||||
TodayActualCost float64 `json:"today_actual_cost"` // 今日实际扣除
|
|
||||||
|
|
||||||
// 性能统计
|
|
||||||
AverageDurationMs float64 `json:"average_duration_ms"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserDashboardStats 获取用户专属的仪表盘统计
|
// GetUserDashboardStats 获取用户专属的仪表盘统计
|
||||||
func (r *UsageLogRepository) GetUserDashboardStats(ctx context.Context, userID int64) (*UserDashboardStats, error) {
|
func (r *UsageLogRepository) GetUserDashboardStats(ctx context.Context, userID int64) (*UserDashboardStats, error) {
|
||||||
@@ -641,6 +570,9 @@ func (r *UsageLogRepository) GetUserDashboardStats(ctx context.Context, userID i
|
|||||||
stats.TodayCost = todayStats.TodayCost
|
stats.TodayCost = todayStats.TodayCost
|
||||||
stats.TodayActualCost = todayStats.TodayActualCost
|
stats.TodayActualCost = todayStats.TodayActualCost
|
||||||
|
|
||||||
|
// 性能指标:RPM 和 TPM(最近1分钟,仅统计该用户的请求)
|
||||||
|
stats.Rpm, stats.Tpm = r.getPerformanceStats(ctx, userID)
|
||||||
|
|
||||||
return &stats, nil
|
return &stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,12 +637,7 @@ func (r *UsageLogRepository) GetUserModelStats(ctx context.Context, userID int64
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UsageLogFilters represents filters for usage log queries
|
// UsageLogFilters represents filters for usage log queries
|
||||||
type UsageLogFilters struct {
|
type UsageLogFilters = usagestats.UsageLogFilters
|
||||||
UserID int64
|
|
||||||
ApiKeyID int64
|
|
||||||
StartTime *time.Time
|
|
||||||
EndTime *time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListWithFilters lists usage logs with optional filters (for admin)
|
// ListWithFilters lists usage logs with optional filters (for admin)
|
||||||
func (r *UsageLogRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UsageLogFilters) ([]model.UsageLog, *pagination.PaginationResult, error) {
|
func (r *UsageLogRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UsageLogFilters) ([]model.UsageLog, *pagination.PaginationResult, error) {
|
||||||
@@ -758,23 +685,10 @@ func (r *UsageLogRepository) ListWithFilters(ctx context.Context, params paginat
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UsageStats represents usage statistics
|
// UsageStats represents usage statistics
|
||||||
type UsageStats struct {
|
type UsageStats = usagestats.UsageStats
|
||||||
TotalRequests int64 `json:"total_requests"`
|
|
||||||
TotalInputTokens int64 `json:"total_input_tokens"`
|
|
||||||
TotalOutputTokens int64 `json:"total_output_tokens"`
|
|
||||||
TotalCacheTokens int64 `json:"total_cache_tokens"`
|
|
||||||
TotalTokens int64 `json:"total_tokens"`
|
|
||||||
TotalCost float64 `json:"total_cost"`
|
|
||||||
TotalActualCost float64 `json:"total_actual_cost"`
|
|
||||||
AverageDurationMs float64 `json:"average_duration_ms"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BatchUserUsageStats represents usage stats for a single user
|
// BatchUserUsageStats represents usage stats for a single user
|
||||||
type BatchUserUsageStats struct {
|
type BatchUserUsageStats = usagestats.BatchUserUsageStats
|
||||||
UserID int64 `json:"user_id"`
|
|
||||||
TodayActualCost float64 `json:"today_actual_cost"`
|
|
||||||
TotalActualCost float64 `json:"total_actual_cost"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBatchUserUsageStats gets today and total actual_cost for multiple users
|
// GetBatchUserUsageStats gets today and total actual_cost for multiple users
|
||||||
func (r *UsageLogRepository) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*BatchUserUsageStats, error) {
|
func (r *UsageLogRepository) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*BatchUserUsageStats, error) {
|
||||||
@@ -834,11 +748,7 @@ func (r *UsageLogRepository) GetBatchUserUsageStats(ctx context.Context, userIDs
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BatchApiKeyUsageStats represents usage stats for a single API key
|
// BatchApiKeyUsageStats represents usage stats for a single API key
|
||||||
type BatchApiKeyUsageStats struct {
|
type BatchApiKeyUsageStats = usagestats.BatchApiKeyUsageStats
|
||||||
ApiKeyID int64 `json:"api_key_id"`
|
|
||||||
TodayActualCost float64 `json:"today_actual_cost"`
|
|
||||||
TotalActualCost float64 `json:"total_actual_cost"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBatchApiKeyUsageStats gets today and total actual_cost for multiple API keys
|
// GetBatchApiKeyUsageStats gets today and total actual_cost for multiple API keys
|
||||||
func (r *UsageLogRepository) GetBatchApiKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*BatchApiKeyUsageStats, error) {
|
func (r *UsageLogRepository) GetBatchApiKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*BatchApiKeyUsageStats, error) {
|
||||||
@@ -1012,53 +922,13 @@ func (r *UsageLogRepository) GetGlobalStats(ctx context.Context, startTime, endT
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AccountUsageHistory represents daily usage history for an account
|
// AccountUsageHistory represents daily usage history for an account
|
||||||
type AccountUsageHistory struct {
|
type AccountUsageHistory = usagestats.AccountUsageHistory
|
||||||
Date string `json:"date"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Requests int64 `json:"requests"`
|
|
||||||
Tokens int64 `json:"tokens"`
|
|
||||||
Cost float64 `json:"cost"`
|
|
||||||
ActualCost float64 `json:"actual_cost"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AccountUsageSummary represents summary statistics for an account
|
// AccountUsageSummary represents summary statistics for an account
|
||||||
type AccountUsageSummary struct {
|
type AccountUsageSummary = usagestats.AccountUsageSummary
|
||||||
Days int `json:"days"`
|
|
||||||
ActualDaysUsed int `json:"actual_days_used"`
|
|
||||||
TotalCost float64 `json:"total_cost"`
|
|
||||||
TotalStandardCost float64 `json:"total_standard_cost"`
|
|
||||||
TotalRequests int64 `json:"total_requests"`
|
|
||||||
TotalTokens int64 `json:"total_tokens"`
|
|
||||||
AvgDailyCost float64 `json:"avg_daily_cost"`
|
|
||||||
AvgDailyRequests float64 `json:"avg_daily_requests"`
|
|
||||||
AvgDailyTokens float64 `json:"avg_daily_tokens"`
|
|
||||||
AvgDurationMs float64 `json:"avg_duration_ms"`
|
|
||||||
Today *struct {
|
|
||||||
Date string `json:"date"`
|
|
||||||
Cost float64 `json:"cost"`
|
|
||||||
Requests int64 `json:"requests"`
|
|
||||||
Tokens int64 `json:"tokens"`
|
|
||||||
} `json:"today"`
|
|
||||||
HighestCostDay *struct {
|
|
||||||
Date string `json:"date"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Cost float64 `json:"cost"`
|
|
||||||
Requests int64 `json:"requests"`
|
|
||||||
} `json:"highest_cost_day"`
|
|
||||||
HighestRequestDay *struct {
|
|
||||||
Date string `json:"date"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Requests int64 `json:"requests"`
|
|
||||||
Cost float64 `json:"cost"`
|
|
||||||
} `json:"highest_request_day"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AccountUsageStatsResponse represents the full usage statistics response for an account
|
// AccountUsageStatsResponse represents the full usage statistics response for an account
|
||||||
type AccountUsageStatsResponse struct {
|
type AccountUsageStatsResponse = usagestats.AccountUsageStatsResponse
|
||||||
History []AccountUsageHistory `json:"history"`
|
|
||||||
Summary AccountUsageSummary `json:"summary"`
|
|
||||||
Models []ModelStat `json:"models"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAccountUsageStats returns comprehensive usage statistics for an account over a time range
|
// GetAccountUsageStats returns comprehensive usage statistics for an account over a time range
|
||||||
func (r *UsageLogRepository) GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*AccountUsageStatsResponse, error) {
|
func (r *UsageLogRepository) GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*AccountUsageStatsResponse, error) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"sub2api/internal/model"
|
||||||
|
"sub2api/internal/pkg/usagestats"
|
||||||
"sub2api/internal/service/ports"
|
"sub2api/internal/service/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -176,6 +177,14 @@ func (s *AccountUsageService) GetTodayStats(ctx context.Context, accountID int64
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// fetchOAuthUsage 从Anthropic API获取OAuth账号的使用量
|
// fetchOAuthUsage 从Anthropic API获取OAuth账号的使用量
|
||||||
func (s *AccountUsageService) fetchOAuthUsage(ctx context.Context, account *model.Account) (*UsageInfo, error) {
|
func (s *AccountUsageService) fetchOAuthUsage(ctx context.Context, account *model.Account) (*UsageInfo, error) {
|
||||||
accessToken := account.GetCredential("access_token")
|
accessToken := account.GetCredential("access_token")
|
||||||
|
|||||||
@@ -455,3 +455,11 @@ func (s *ApiKeyService) canUserBindGroupInternal(user *model.User, group *model.
|
|||||||
// 标准类型分组:使用原有逻辑
|
// 标准类型分组:使用原有逻辑
|
||||||
return user.CanBindGroup(group.ID, group.IsExclusive)
|
return user.CanBindGroup(group.ID, group.IsExclusive)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ApiKeyService) SearchApiKeys(ctx context.Context, userID int64, keyword string, limit int) ([]model.ApiKey, error) {
|
||||||
|
keys, err := s.apiKeyRepo.SearchApiKeys(ctx, userID, keyword, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search api keys: %w", err)
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -147,3 +147,20 @@ func CalculateMaxWait(userConcurrency int) int {
|
|||||||
}
|
}
|
||||||
return userConcurrency + defaultExtraWaitSlots
|
return userConcurrency + defaultExtraWaitSlots
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAccountConcurrencyBatch gets current concurrency counts for multiple accounts
|
||||||
|
// Returns a map of accountID -> current concurrency count
|
||||||
|
func (s *ConcurrencyService) GetAccountConcurrencyBatch(ctx context.Context, accountIDs []int64) (map[int64]int, error) {
|
||||||
|
result := make(map[int64]int)
|
||||||
|
|
||||||
|
for _, accountID := range accountIDs {
|
||||||
|
count, err := s.cache.GetAccountConcurrency(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
// If key doesn't exist in Redis, count is 0
|
||||||
|
count = 0
|
||||||
|
}
|
||||||
|
result[accountID] = count
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|||||||
77
backend/internal/service/dashboard_service.go
Normal file
77
backend/internal/service/dashboard_service.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sub2api/internal/pkg/usagestats"
|
||||||
|
"sub2api/internal/service/ports"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DashboardService provides aggregated statistics for admin dashboard.
|
||||||
|
type DashboardService struct {
|
||||||
|
usageRepo ports.UsageLogRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDashboardService(usageRepo ports.UsageLogRepository) *DashboardService {
|
||||||
|
return &DashboardService{
|
||||||
|
usageRepo: usageRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error) {
|
||||||
|
stats, err := s.usageRepo.GetDashboardStats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get dashboard stats: %w", err)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID int64) ([]usagestats.TrendDataPoint, error) {
|
||||||
|
trend, err := s.usageRepo.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get usage trend with filters: %w", err)
|
||||||
|
}
|
||||||
|
return trend, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID int64) ([]usagestats.ModelStat, error) {
|
||||||
|
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get model stats with filters: %w", err)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) GetApiKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.ApiKeyUsageTrendPoint, error) {
|
||||||
|
trend, err := s.usageRepo.GetApiKeyUsageTrend(ctx, startTime, endTime, granularity, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get api key usage trend: %w", err)
|
||||||
|
}
|
||||||
|
return trend, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) {
|
||||||
|
trend, err := s.usageRepo.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get user usage trend: %w", err)
|
||||||
|
}
|
||||||
|
return trend, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error) {
|
||||||
|
stats, err := s.usageRepo.GetBatchUserUsageStats(ctx, userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get batch user usage stats: %w", err)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) GetBatchApiKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchApiKeyUsageStats, error) {
|
||||||
|
stats, err := s.usageRepo.GetBatchApiKeyUsageStats(ctx, apiKeyIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get batch api key usage stats: %w", err)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
@@ -358,6 +358,25 @@ func (s *GatewayService) getOAuthToken(ctx context.Context, account *model.Accou
|
|||||||
return accessToken, "oauth", nil
|
return accessToken, "oauth", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重试相关常量
|
||||||
|
const (
|
||||||
|
maxRetries = 3 // 最大重试次数
|
||||||
|
retryDelay = 2 * time.Second // 重试等待时间
|
||||||
|
)
|
||||||
|
|
||||||
|
// shouldRetryUpstreamError 判断是否应该重试上游错误
|
||||||
|
// OAuth/Setup Token 账号:仅 403 重试
|
||||||
|
// API Key 账号:未配置的错误码重试
|
||||||
|
func (s *GatewayService) shouldRetryUpstreamError(account *model.Account, statusCode int) bool {
|
||||||
|
// OAuth/Setup Token 账号:仅 403 重试
|
||||||
|
if account.IsOAuth() {
|
||||||
|
return statusCode == 403
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Key 账号:未配置的错误码重试
|
||||||
|
return !account.ShouldHandleErrorCode(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
// Forward 转发请求到Claude API
|
// Forward 转发请求到Claude API
|
||||||
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *model.Account, body []byte) (*ForwardResult, error) {
|
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *model.Account, body []byte) (*ForwardResult, error) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
@@ -389,26 +408,51 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *m
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建上游请求
|
|
||||||
upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, body, token, tokenType)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取代理URL
|
// 获取代理URL
|
||||||
proxyURL := ""
|
proxyURL := ""
|
||||||
if account.ProxyID != nil && account.Proxy != nil {
|
if account.ProxyID != nil && account.Proxy != nil {
|
||||||
proxyURL = account.Proxy.URL()
|
proxyURL = account.Proxy.URL()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送请求
|
// 重试循环
|
||||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL)
|
var resp *http.Response
|
||||||
if err != nil {
|
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||||
return nil, fmt.Errorf("upstream request failed: %w", err)
|
// 构建上游请求(每次重试需要重新构建,因为请求体需要重新读取)
|
||||||
|
upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, body, token, tokenType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("upstream request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要重试
|
||||||
|
if resp.StatusCode >= 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) {
|
||||||
|
if attempt < maxRetries {
|
||||||
|
log.Printf("Account %d: upstream error %d, retry %d/%d after %v",
|
||||||
|
account.ID, resp.StatusCode, attempt, maxRetries, retryDelay)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
time.Sleep(retryDelay)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 最后一次尝试也失败,跳出循环处理重试耗尽
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不需要重试(成功或不可重试的错误),跳出循环
|
||||||
|
break
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
// 处理错误响应(包括401,由后台TokenRefreshService维护token有效性)
|
// 处理重试耗尽的情况
|
||||||
|
if resp.StatusCode >= 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) {
|
||||||
|
return s.handleRetryExhaustedError(ctx, resp, c, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理错误响应(不可重试的错误)
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
return s.handleErrorResponse(ctx, resp, c, account)
|
return s.handleErrorResponse(ctx, resp, c, account)
|
||||||
}
|
}
|
||||||
@@ -570,19 +614,6 @@ func (s *GatewayService) getBetaHeader(body []byte, clientBetaHeader string) str
|
|||||||
func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *model.Account) (*ForwardResult, error) {
|
func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *model.Account) (*ForwardResult, error) {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
// apikey 类型账号:检查自定义错误码配置
|
|
||||||
// 如果启用且错误码不在列表中,返回通用 500 错误(不做任何账号状态处理)
|
|
||||||
if !account.ShouldHandleErrorCode(resp.StatusCode) {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"type": "error",
|
|
||||||
"error": gin.H{
|
|
||||||
"type": "upstream_error",
|
|
||||||
"message": "Upstream gateway error",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理上游错误,标记账号状态
|
// 处理上游错误,标记账号状态
|
||||||
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
|
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
|
||||||
|
|
||||||
@@ -629,6 +660,34 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res
|
|||||||
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
|
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRetryExhaustedError 处理重试耗尽后的错误
|
||||||
|
// OAuth 403:标记账号异常
|
||||||
|
// API Key 未配置错误码:仅返回错误,不标记账号
|
||||||
|
func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *http.Response, c *gin.Context, account *model.Account) (*ForwardResult, error) {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
statusCode := resp.StatusCode
|
||||||
|
|
||||||
|
// OAuth/Setup Token 账号的 403:标记账号异常
|
||||||
|
if account.IsOAuth() && statusCode == 403 {
|
||||||
|
s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body)
|
||||||
|
log.Printf("Account %d: marked as error after %d retries for status %d", account.ID, maxRetries, statusCode)
|
||||||
|
} else {
|
||||||
|
// API Key 未配置错误码:不标记账号状态
|
||||||
|
log.Printf("Account %d: upstream error %d after %d retries (not marking account)", account.ID, statusCode, maxRetries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回统一的重试耗尽错误响应
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{
|
||||||
|
"type": "error",
|
||||||
|
"error": gin.H{
|
||||||
|
"type": "upstream_error",
|
||||||
|
"message": "Upstream request failed after retries",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("upstream error: %d (retries exhausted)", statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
// streamingResult 流式响应结果
|
// streamingResult 流式响应结果
|
||||||
type streamingResult struct {
|
type streamingResult struct {
|
||||||
usage *ClaudeUsage
|
usage *ClaudeUsage
|
||||||
|
|||||||
@@ -25,4 +25,25 @@ type UsageLogRepository interface {
|
|||||||
|
|
||||||
GetAccountWindowStats(ctx context.Context, accountID int64, startTime time.Time) (*usagestats.AccountStats, error)
|
GetAccountWindowStats(ctx context.Context, accountID int64, startTime time.Time) (*usagestats.AccountStats, error)
|
||||||
GetAccountTodayStats(ctx context.Context, accountID int64) (*usagestats.AccountStats, error)
|
GetAccountTodayStats(ctx context.Context, accountID int64) (*usagestats.AccountStats, error)
|
||||||
|
|
||||||
|
// Admin dashboard stats
|
||||||
|
GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error)
|
||||||
|
GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID int64) ([]usagestats.TrendDataPoint, error)
|
||||||
|
GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID int64) ([]usagestats.ModelStat, error)
|
||||||
|
GetApiKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.ApiKeyUsageTrendPoint, error)
|
||||||
|
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)
|
||||||
|
GetBatchApiKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchApiKeyUsageStats, error)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]model.UsageLog, *pagination.PaginationResult, error)
|
||||||
|
GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
||||||
|
|
||||||
|
// Account stats
|
||||||
|
GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sub2api/internal/model"
|
"sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"sub2api/internal/pkg/pagination"
|
||||||
|
"sub2api/internal/pkg/usagestats"
|
||||||
"sub2api/internal/service/ports"
|
"sub2api/internal/service/ports"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -282,3 +283,57 @@ func (s *UsageService) Delete(ctx context.Context, id int64) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserDashboardStats returns per-user dashboard summary stats.
|
||||||
|
func (s *UsageService) GetUserDashboardStats(ctx context.Context, userID int64) (*usagestats.UserDashboardStats, error) {
|
||||||
|
stats, err := s.usageRepo.GetUserDashboardStats(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get user dashboard stats: %w", err)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserUsageTrendByUserID returns per-user usage trend.
|
||||||
|
func (s *UsageService) GetUserUsageTrendByUserID(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.TrendDataPoint, error) {
|
||||||
|
trend, err := s.usageRepo.GetUserUsageTrendByUserID(ctx, userID, startTime, endTime, granularity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get user usage trend: %w", err)
|
||||||
|
}
|
||||||
|
return trend, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserModelStats returns per-user model usage stats.
|
||||||
|
func (s *UsageService) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.ModelStat, error) {
|
||||||
|
stats, err := s.usageRepo.GetUserModelStats(ctx, userID, startTime, endTime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get user model stats: %w", err)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBatchApiKeyUsageStats returns today/total actual_cost for given api keys.
|
||||||
|
func (s *UsageService) GetBatchApiKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchApiKeyUsageStats, error) {
|
||||||
|
stats, err := s.usageRepo.GetBatchApiKeyUsageStats(ctx, apiKeyIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get batch api key usage stats: %w", err)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWithFilters lists usage logs with admin filters.
|
||||||
|
func (s *UsageService) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]model.UsageLog, *pagination.PaginationResult, error) {
|
||||||
|
logs, result, err := s.usageRepo.ListWithFilters(ctx, params, filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("list usage logs with filters: %w", err)
|
||||||
|
}
|
||||||
|
return logs, result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGlobalStats returns global usage stats for a time range.
|
||||||
|
func (s *UsageService) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
|
||||||
|
stats, err := s.usageRepo.GetGlobalStats(ctx, startTime, endTime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get global usage stats: %w", err)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewProxyService,
|
NewProxyService,
|
||||||
NewRedeemService,
|
NewRedeemService,
|
||||||
NewUsageService,
|
NewUsageService,
|
||||||
|
NewDashboardService,
|
||||||
ProvidePricingService,
|
ProvidePricingService,
|
||||||
NewBillingService,
|
NewBillingService,
|
||||||
NewBillingCacheService,
|
NewBillingCacheService,
|
||||||
|
|||||||
@@ -101,6 +101,18 @@ declare -A MSG_ZH=(
|
|||||||
["starting_service"]="正在启动服务..."
|
["starting_service"]="正在启动服务..."
|
||||||
["upgrade_complete"]="升级完成!"
|
["upgrade_complete"]="升级完成!"
|
||||||
|
|
||||||
|
# Version install
|
||||||
|
["installing_version"]="正在安装指定版本"
|
||||||
|
["version_not_found"]="指定版本不存在"
|
||||||
|
["same_version"]="已经是该版本,无需操作"
|
||||||
|
["rollback_complete"]="版本回退完成!"
|
||||||
|
["install_version_complete"]="指定版本安装完成!"
|
||||||
|
["validating_version"]="正在验证版本..."
|
||||||
|
["available_versions"]="可用版本列表"
|
||||||
|
["fetching_versions"]="正在获取可用版本..."
|
||||||
|
["not_installed"]="Sub2API 尚未安装,请先执行全新安装"
|
||||||
|
["fresh_install_hint"]="用法"
|
||||||
|
|
||||||
# Uninstall
|
# Uninstall
|
||||||
["uninstall_confirm"]="这将从系统中移除 Sub2API。"
|
["uninstall_confirm"]="这将从系统中移除 Sub2API。"
|
||||||
["are_you_sure"]="确定要继续吗?(y/N)"
|
["are_you_sure"]="确定要继续吗?(y/N)"
|
||||||
@@ -118,6 +130,9 @@ declare -A MSG_ZH=(
|
|||||||
["cmd_install"]="安装 Sub2API"
|
["cmd_install"]="安装 Sub2API"
|
||||||
["cmd_upgrade"]="升级到最新版本"
|
["cmd_upgrade"]="升级到最新版本"
|
||||||
["cmd_uninstall"]="卸载 Sub2API"
|
["cmd_uninstall"]="卸载 Sub2API"
|
||||||
|
["cmd_install_version"]="安装/回退到指定版本"
|
||||||
|
["cmd_list_versions"]="列出可用版本"
|
||||||
|
["opt_version"]="指定要安装的版本号 (例如: v1.0.0)"
|
||||||
|
|
||||||
# Server configuration
|
# Server configuration
|
||||||
["server_config_title"]="服务器配置"
|
["server_config_title"]="服务器配置"
|
||||||
@@ -207,6 +222,18 @@ declare -A MSG_EN=(
|
|||||||
["starting_service"]="Starting service..."
|
["starting_service"]="Starting service..."
|
||||||
["upgrade_complete"]="Upgrade completed!"
|
["upgrade_complete"]="Upgrade completed!"
|
||||||
|
|
||||||
|
# Version install
|
||||||
|
["installing_version"]="Installing specified version"
|
||||||
|
["version_not_found"]="Specified version not found"
|
||||||
|
["same_version"]="Already at this version, no action needed"
|
||||||
|
["rollback_complete"]="Version rollback completed!"
|
||||||
|
["install_version_complete"]="Specified version installed!"
|
||||||
|
["validating_version"]="Validating version..."
|
||||||
|
["available_versions"]="Available versions"
|
||||||
|
["fetching_versions"]="Fetching available versions..."
|
||||||
|
["not_installed"]="Sub2API is not installed. Please run a fresh install first"
|
||||||
|
["fresh_install_hint"]="Usage"
|
||||||
|
|
||||||
# Uninstall
|
# Uninstall
|
||||||
["uninstall_confirm"]="This will remove Sub2API from your system."
|
["uninstall_confirm"]="This will remove Sub2API from your system."
|
||||||
["are_you_sure"]="Are you sure? (y/N)"
|
["are_you_sure"]="Are you sure? (y/N)"
|
||||||
@@ -224,6 +251,9 @@ declare -A MSG_EN=(
|
|||||||
["cmd_install"]="Install Sub2API"
|
["cmd_install"]="Install Sub2API"
|
||||||
["cmd_upgrade"]="Upgrade to the latest version"
|
["cmd_upgrade"]="Upgrade to the latest version"
|
||||||
["cmd_uninstall"]="Remove Sub2API"
|
["cmd_uninstall"]="Remove Sub2API"
|
||||||
|
["cmd_install_version"]="Install/rollback to a specific version"
|
||||||
|
["cmd_list_versions"]="List available versions"
|
||||||
|
["opt_version"]="Specify version to install (e.g., v1.0.0)"
|
||||||
|
|
||||||
# Server configuration
|
# Server configuration
|
||||||
["server_config_title"]="Server Configuration"
|
["server_config_title"]="Server Configuration"
|
||||||
@@ -428,16 +458,88 @@ check_dependencies() {
|
|||||||
# Get latest release version
|
# Get latest release version
|
||||||
get_latest_version() {
|
get_latest_version() {
|
||||||
print_info "$(msg 'fetching_version')"
|
print_info "$(msg 'fetching_version')"
|
||||||
LATEST_VERSION=$(curl -s "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
|
LATEST_VERSION=$(curl -s --connect-timeout 10 --max-time 30 "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" 2>/dev/null | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||||
|
|
||||||
if [ -z "$LATEST_VERSION" ]; then
|
if [ -z "$LATEST_VERSION" ]; then
|
||||||
print_error "$(msg 'failed_get_version')"
|
print_error "$(msg 'failed_get_version')"
|
||||||
|
print_info "Please check your network connection or try again later."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
print_info "$(msg 'latest_version'): $LATEST_VERSION"
|
print_info "$(msg 'latest_version'): $LATEST_VERSION"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# List available versions
|
||||||
|
list_versions() {
|
||||||
|
print_info "$(msg 'fetching_versions')"
|
||||||
|
|
||||||
|
local versions
|
||||||
|
versions=$(curl -s --connect-timeout 10 --max-time 30 "https://api.github.com/repos/${GITHUB_REPO}/releases" 2>/dev/null | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/' | head -20)
|
||||||
|
|
||||||
|
if [ -z "$versions" ]; then
|
||||||
|
print_error "$(msg 'failed_get_version')"
|
||||||
|
print_info "Please check your network connection or try again later."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "$(msg 'available_versions'):"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
echo "$versions" | while read -r version; do
|
||||||
|
echo " $version"
|
||||||
|
done
|
||||||
|
echo "----------------------------------------"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate if a version exists
|
||||||
|
validate_version() {
|
||||||
|
local version="$1"
|
||||||
|
|
||||||
|
# Check for empty version
|
||||||
|
if [ -z "$version" ]; then
|
||||||
|
print_error "$(msg 'opt_version')" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure version starts with 'v'
|
||||||
|
if [[ ! "$version" =~ ^v ]]; then
|
||||||
|
version="v$version"
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_info "$(msg 'validating_version') $version" >&2
|
||||||
|
|
||||||
|
# Check if the release exists
|
||||||
|
local http_code
|
||||||
|
http_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 10 --max-time 30 "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${version}" 2>/dev/null)
|
||||||
|
|
||||||
|
# Check for network errors (empty or non-numeric response)
|
||||||
|
if [ -z "$http_code" ] || ! [[ "$http_code" =~ ^[0-9]+$ ]]; then
|
||||||
|
print_error "Network error: Failed to connect to GitHub API" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$http_code" != "200" ]; then
|
||||||
|
print_error "$(msg 'version_not_found'): $version" >&2
|
||||||
|
echo "" >&2
|
||||||
|
list_versions >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Return the normalized version (to stdout)
|
||||||
|
echo "$version"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get current installed version
|
||||||
|
get_current_version() {
|
||||||
|
if [ -f "$INSTALL_DIR/sub2api" ]; then
|
||||||
|
# Use grep -E for better compatibility (works on macOS and Linux)
|
||||||
|
"$INSTALL_DIR/sub2api" --version 2>/dev/null | grep -oE 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown"
|
||||||
|
else
|
||||||
|
echo "not_installed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Download and extract
|
# Download and extract
|
||||||
download_and_extract() {
|
download_and_extract() {
|
||||||
local version_num=${LATEST_VERSION#v}
|
local version_num=${LATEST_VERSION#v}
|
||||||
@@ -678,13 +780,18 @@ print_completion() {
|
|||||||
|
|
||||||
# Upgrade function
|
# Upgrade function
|
||||||
upgrade() {
|
upgrade() {
|
||||||
|
# Check if Sub2API is installed
|
||||||
|
if [ ! -f "$INSTALL_DIR/sub2api" ]; then
|
||||||
|
print_error "$(msg 'not_installed')"
|
||||||
|
print_info "$(msg 'fresh_install_hint'): $0 install"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
print_info "$(msg 'upgrading')"
|
print_info "$(msg 'upgrading')"
|
||||||
|
|
||||||
# Get current version
|
# Get current version
|
||||||
if [ -f "$INSTALL_DIR/sub2api" ]; then
|
CURRENT_VERSION=$("$INSTALL_DIR/sub2api" --version 2>/dev/null | grep -oE 'v?[0-9]+\.[0-9]+\.[0-9]+' || echo "unknown")
|
||||||
CURRENT_VERSION=$("$INSTALL_DIR/sub2api" --version 2>/dev/null | grep -oP 'v?\d+\.\d+\.\d+' || echo "unknown")
|
print_info "$(msg 'current_version'): $CURRENT_VERSION"
|
||||||
print_info "$(msg 'current_version'): $CURRENT_VERSION"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Stop service
|
# Stop service
|
||||||
if systemctl is-active --quiet sub2api; then
|
if systemctl is-active --quiet sub2api; then
|
||||||
@@ -693,10 +800,8 @@ upgrade() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Backup current binary
|
# Backup current binary
|
||||||
if [ -f "$INSTALL_DIR/sub2api" ]; then
|
cp "$INSTALL_DIR/sub2api" "$INSTALL_DIR/sub2api.backup"
|
||||||
cp "$INSTALL_DIR/sub2api" "$INSTALL_DIR/sub2api.backup"
|
print_info "$(msg 'backup_created'): $INSTALL_DIR/sub2api.backup"
|
||||||
print_info "$(msg 'backup_created'): $INSTALL_DIR/sub2api.backup"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Download and install new version
|
# Download and install new version
|
||||||
get_latest_version
|
get_latest_version
|
||||||
@@ -712,6 +817,82 @@ upgrade() {
|
|||||||
print_success "$(msg 'upgrade_complete')"
|
print_success "$(msg 'upgrade_complete')"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Install specific version (for upgrade or rollback)
|
||||||
|
# Requires: Sub2API must already be installed
|
||||||
|
install_version() {
|
||||||
|
local target_version="$1"
|
||||||
|
|
||||||
|
# Check if Sub2API is installed
|
||||||
|
if [ ! -f "$INSTALL_DIR/sub2api" ]; then
|
||||||
|
print_error "$(msg 'not_installed')"
|
||||||
|
print_info "$(msg 'fresh_install_hint'): $0 install -v $target_version"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate and normalize version
|
||||||
|
target_version=$(validate_version "$target_version")
|
||||||
|
|
||||||
|
print_info "$(msg 'installing_version'): $target_version"
|
||||||
|
|
||||||
|
# Get current version
|
||||||
|
local current_version
|
||||||
|
current_version=$(get_current_version)
|
||||||
|
print_info "$(msg 'current_version'): $current_version"
|
||||||
|
|
||||||
|
# Check if same version
|
||||||
|
if [ "$current_version" = "$target_version" ] || [ "$current_version" = "${target_version#v}" ]; then
|
||||||
|
print_warning "$(msg 'same_version')"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop service if running
|
||||||
|
if systemctl is-active --quiet sub2api; then
|
||||||
|
print_info "$(msg 'stopping_service')"
|
||||||
|
systemctl stop sub2api
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup current binary (for potential recovery)
|
||||||
|
if [ -f "$INSTALL_DIR/sub2api" ]; then
|
||||||
|
local backup_name
|
||||||
|
if [ "$current_version" != "unknown" ] && [ "$current_version" != "not_installed" ]; then
|
||||||
|
backup_name="sub2api.backup.${current_version}"
|
||||||
|
else
|
||||||
|
backup_name="sub2api.backup.$(date +%Y%m%d%H%M%S)"
|
||||||
|
fi
|
||||||
|
cp "$INSTALL_DIR/sub2api" "$INSTALL_DIR/$backup_name"
|
||||||
|
print_info "$(msg 'backup_created'): $INSTALL_DIR/$backup_name"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set LATEST_VERSION to the target version for download_and_extract
|
||||||
|
LATEST_VERSION="$target_version"
|
||||||
|
|
||||||
|
# Download and install
|
||||||
|
download_and_extract
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
chown "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR/sub2api"
|
||||||
|
|
||||||
|
# Start service
|
||||||
|
print_info "$(msg 'starting_service')"
|
||||||
|
if systemctl start sub2api; then
|
||||||
|
print_success "$(msg 'service_started')"
|
||||||
|
else
|
||||||
|
print_error "$(msg 'service_start_failed')"
|
||||||
|
print_info "sudo journalctl -u sub2api -n 50"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Print completion message
|
||||||
|
local new_version
|
||||||
|
new_version=$(get_current_version)
|
||||||
|
echo ""
|
||||||
|
echo "=============================================="
|
||||||
|
print_success "$(msg 'install_version_complete')"
|
||||||
|
echo "=============================================="
|
||||||
|
echo ""
|
||||||
|
echo " $(msg 'current_version'): $new_version"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
# Uninstall function
|
# Uninstall function
|
||||||
uninstall() {
|
uninstall() {
|
||||||
print_warning "$(msg 'uninstall_confirm')"
|
print_warning "$(msg 'uninstall_confirm')"
|
||||||
@@ -753,13 +934,43 @@ uninstall() {
|
|||||||
|
|
||||||
# Main
|
# Main
|
||||||
main() {
|
main() {
|
||||||
# Parse -y flag first
|
# Parse flags first
|
||||||
for arg in "$@"; do
|
local target_version=""
|
||||||
if [ "$arg" = "-y" ] || [ "$arg" = "--yes" ]; then
|
local positional_args=()
|
||||||
FORCE_YES="true"
|
|
||||||
fi
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-y|--yes)
|
||||||
|
FORCE_YES="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-v|--version)
|
||||||
|
if [ -n "${2:-}" ] && [[ ! "$2" =~ ^- ]]; then
|
||||||
|
target_version="$2"
|
||||||
|
shift 2
|
||||||
|
else
|
||||||
|
echo "Error: --version requires a version argument"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
--version=*)
|
||||||
|
target_version="${1#*=}"
|
||||||
|
if [ -z "$target_version" ]; then
|
||||||
|
echo "Error: --version requires a version argument"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
positional_args+=("$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Restore positional arguments
|
||||||
|
set -- "${positional_args[@]}"
|
||||||
|
|
||||||
# Select language first
|
# Select language first
|
||||||
select_language
|
select_language
|
||||||
|
|
||||||
@@ -769,12 +980,83 @@ main() {
|
|||||||
echo "=============================================="
|
echo "=============================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Parse arguments
|
# Parse commands
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
upgrade|update)
|
upgrade|update)
|
||||||
check_root
|
check_root
|
||||||
detect_platform
|
detect_platform
|
||||||
upgrade
|
check_dependencies
|
||||||
|
if [ -n "$target_version" ]; then
|
||||||
|
# Upgrade to specific version
|
||||||
|
install_version "$target_version"
|
||||||
|
else
|
||||||
|
# Upgrade to latest
|
||||||
|
upgrade
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
install)
|
||||||
|
# Install with optional version
|
||||||
|
check_root
|
||||||
|
detect_platform
|
||||||
|
check_dependencies
|
||||||
|
if [ -n "$target_version" ]; then
|
||||||
|
# Install specific version (fresh install or rollback)
|
||||||
|
if [ -f "$INSTALL_DIR/sub2api" ]; then
|
||||||
|
# Already installed, treat as version change
|
||||||
|
install_version "$target_version"
|
||||||
|
else
|
||||||
|
# Fresh install with specific version
|
||||||
|
configure_server
|
||||||
|
LATEST_VERSION=$(validate_version "$target_version")
|
||||||
|
download_and_extract
|
||||||
|
create_user
|
||||||
|
setup_directories
|
||||||
|
install_service
|
||||||
|
prepare_for_setup
|
||||||
|
get_public_ip
|
||||||
|
start_service
|
||||||
|
enable_autostart
|
||||||
|
print_completion
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Fresh install with latest version
|
||||||
|
configure_server
|
||||||
|
get_latest_version
|
||||||
|
download_and_extract
|
||||||
|
create_user
|
||||||
|
setup_directories
|
||||||
|
install_service
|
||||||
|
prepare_for_setup
|
||||||
|
get_public_ip
|
||||||
|
start_service
|
||||||
|
enable_autostart
|
||||||
|
print_completion
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
rollback)
|
||||||
|
# Rollback to a specific version (alias for install with version)
|
||||||
|
if [ -z "$target_version" ] && [ -n "${2:-}" ]; then
|
||||||
|
target_version="$2"
|
||||||
|
fi
|
||||||
|
if [ -z "$target_version" ]; then
|
||||||
|
print_error "$(msg 'opt_version')"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: $0 rollback -v <version>"
|
||||||
|
echo " $0 rollback <version>"
|
||||||
|
echo ""
|
||||||
|
list_versions
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
check_root
|
||||||
|
detect_platform
|
||||||
|
check_dependencies
|
||||||
|
install_version "$target_version"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
list-versions|versions)
|
||||||
|
list_versions
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
uninstall|remove)
|
uninstall|remove)
|
||||||
@@ -786,32 +1068,65 @@ main() {
|
|||||||
echo "$(msg 'usage'): $0 [command] [options]"
|
echo "$(msg 'usage'): $0 [command] [options]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Commands:"
|
echo "Commands:"
|
||||||
echo " $(msg 'cmd_none') $(msg 'cmd_install')"
|
echo " $(msg 'cmd_none') $(msg 'cmd_install')"
|
||||||
echo " upgrade $(msg 'cmd_upgrade')"
|
echo " install $(msg 'cmd_install')"
|
||||||
echo " uninstall $(msg 'cmd_uninstall')"
|
echo " upgrade $(msg 'cmd_upgrade')"
|
||||||
|
echo " rollback <version> $(msg 'cmd_install_version')"
|
||||||
|
echo " list-versions $(msg 'cmd_list_versions')"
|
||||||
|
echo " uninstall $(msg 'cmd_uninstall')"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -y, --yes Skip confirmation prompts (for uninstall)"
|
echo " -v, --version <ver> $(msg 'opt_version')"
|
||||||
|
echo " -y, --yes Skip confirmation prompts (for uninstall)"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 # Install latest version"
|
||||||
|
echo " $0 install -v v0.1.0 # Install specific version"
|
||||||
|
echo " $0 upgrade # Upgrade to latest"
|
||||||
|
echo " $0 upgrade -v v0.2.0 # Upgrade to specific version"
|
||||||
|
echo " $0 rollback v0.1.0 # Rollback to v0.1.0"
|
||||||
|
echo " $0 list-versions # List available versions"
|
||||||
echo ""
|
echo ""
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Fresh install
|
# Default: Fresh install with latest version
|
||||||
check_root
|
check_root
|
||||||
detect_platform
|
detect_platform
|
||||||
check_dependencies
|
check_dependencies
|
||||||
configure_server
|
|
||||||
get_latest_version
|
if [ -n "$target_version" ]; then
|
||||||
download_and_extract
|
# Install specific version
|
||||||
create_user
|
if [ -f "$INSTALL_DIR/sub2api" ]; then
|
||||||
setup_directories
|
install_version "$target_version"
|
||||||
install_service
|
else
|
||||||
prepare_for_setup
|
configure_server
|
||||||
get_public_ip
|
LATEST_VERSION=$(validate_version "$target_version")
|
||||||
start_service
|
download_and_extract
|
||||||
enable_autostart
|
create_user
|
||||||
print_completion
|
setup_directories
|
||||||
|
install_service
|
||||||
|
prepare_for_setup
|
||||||
|
get_public_ip
|
||||||
|
start_service
|
||||||
|
enable_autostart
|
||||||
|
print_completion
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Install latest version
|
||||||
|
configure_server
|
||||||
|
get_latest_version
|
||||||
|
download_and_extract
|
||||||
|
create_user
|
||||||
|
setup_directories
|
||||||
|
install_service
|
||||||
|
prepare_for_setup
|
||||||
|
get_public_ip
|
||||||
|
start_service
|
||||||
|
enable_autostart
|
||||||
|
print_completion
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export interface UserDashboardStats {
|
|||||||
today_cost: number; // 今日标准计费
|
today_cost: number; // 今日标准计费
|
||||||
today_actual_cost: number; // 今日实际扣除
|
today_actual_cost: number; // 今日实际扣除
|
||||||
average_duration_ms: number;
|
average_duration_ms: number;
|
||||||
|
rpm: number; // 最近1分钟的请求数
|
||||||
|
tpm: number; // 最近1分钟的Token数
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrendParams {
|
export interface TrendParams {
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="showUsageWindows">
|
<div v-if="showUsageWindows">
|
||||||
<!-- Anthropic OAuth accounts: fetch real usage data -->
|
<!-- Anthropic OAuth and Setup Token accounts: fetch real usage data -->
|
||||||
<template v-if="account.platform === 'anthropic' && account.type === 'oauth'">
|
<template v-if="account.platform === 'anthropic' && (account.type === 'oauth' || account.type === 'setup-token')">
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-if="loading" class="space-y-1.5">
|
<div v-if="loading" class="space-y-1.5">
|
||||||
|
<!-- OAuth: 3 rows, Setup Token: 1 row -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||||
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
|
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
|
||||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<template v-if="account.type === 'oauth'">
|
||||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
<div class="flex items-center gap-1">
|
||||||
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
|
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
|
||||||
</div>
|
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||||
<div class="flex items-center gap-1">
|
</div>
|
||||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
<div class="flex items-center gap-1">
|
||||||
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
|
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
|
||||||
</div>
|
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
@@ -38,7 +41,7 @@
|
|||||||
color="indigo"
|
color="indigo"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 7d Window -->
|
<!-- 7d Window (OAuth only) -->
|
||||||
<UsageProgressBar
|
<UsageProgressBar
|
||||||
v-if="usageInfo.seven_day"
|
v-if="usageInfo.seven_day"
|
||||||
label="7d"
|
label="7d"
|
||||||
@@ -47,7 +50,7 @@
|
|||||||
color="emerald"
|
color="emerald"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 7d Sonnet Window -->
|
<!-- 7d Sonnet Window (OAuth only) -->
|
||||||
<UsageProgressBar
|
<UsageProgressBar
|
||||||
v-if="usageInfo.seven_day_sonnet"
|
v-if="usageInfo.seven_day_sonnet"
|
||||||
label="7d S"
|
label="7d S"
|
||||||
@@ -63,11 +66,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Anthropic Setup Token accounts: show time-based window progress -->
|
|
||||||
<template v-else-if="account.platform === 'anthropic' && account.type === 'setup-token'">
|
|
||||||
<SetupTokenTimeWindow :account="account" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- OpenAI OAuth accounts: show Codex usage from extra field -->
|
<!-- OpenAI OAuth accounts: show Codex usage from extra field -->
|
||||||
<template v-else-if="account.platform === 'openai' && account.type === 'oauth'">
|
<template v-else-if="account.platform === 'openai' && account.type === 'oauth'">
|
||||||
<div v-if="hasCodexUsage" class="space-y-1">
|
<div v-if="hasCodexUsage" class="space-y-1">
|
||||||
@@ -109,7 +107,6 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Account, AccountUsageInfo } from '@/types'
|
import type { Account, AccountUsageInfo } from '@/types'
|
||||||
import UsageProgressBar from './UsageProgressBar.vue'
|
import UsageProgressBar from './UsageProgressBar.vue'
|
||||||
import SetupTokenTimeWindow from './SetupTokenTimeWindow.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
account: Account
|
account: Account
|
||||||
@@ -160,9 +157,10 @@ const codexSecondaryResetAt = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const loadUsage = async () => {
|
const loadUsage = async () => {
|
||||||
// Only fetch usage for Anthropic OAuth accounts
|
// Fetch usage for Anthropic OAuth and Setup Token accounts
|
||||||
// OpenAI usage comes from account.extra field (updated during forwarding)
|
// OpenAI usage comes from account.extra field (updated during forwarding)
|
||||||
if (props.account.platform !== 'anthropic' || props.account.type !== 'oauth') return
|
if (props.account.platform !== 'anthropic') return
|
||||||
|
if (props.account.type !== 'oauth' && props.account.type !== 'setup-token') return
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<!-- 5h Time Window Progress -->
|
|
||||||
<div v-if="hasWindowInfo" class="flex items-center gap-1">
|
|
||||||
<!-- Label badge -->
|
|
||||||
<span class="text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0 bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
|
|
||||||
5h
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Progress bar container -->
|
|
||||||
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden shrink-0">
|
|
||||||
<div
|
|
||||||
:class="['h-full transition-all duration-300', barColorClass]"
|
|
||||||
:style="{ width: progressWidth }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Percentage -->
|
|
||||||
<span :class="['text-[10px] font-medium w-[32px] text-right shrink-0', textColorClass]">
|
|
||||||
{{ displayPercent }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Reset time -->
|
|
||||||
<span class="text-[10px] text-gray-400 shrink-0">
|
|
||||||
{{ formatResetTime }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- No recent activity (had activity but window expired > 1 hour) -->
|
|
||||||
<div v-else-if="hasExpiredWindow" class="flex items-center gap-1">
|
|
||||||
<span class="text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0 bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
|
|
||||||
5h
|
|
||||||
</span>
|
|
||||||
<span class="text-[10px] text-gray-400 italic">
|
|
||||||
No recent activity
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- No window info yet (never had activity) -->
|
|
||||||
<div v-else class="flex items-center gap-1">
|
|
||||||
<span class="text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0 bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
|
|
||||||
5h
|
|
||||||
</span>
|
|
||||||
<span class="text-[10px] text-gray-400 italic">
|
|
||||||
No activity yet
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hint -->
|
|
||||||
<div class="text-[10px] text-gray-400 italic">
|
|
||||||
Setup Token (time-based)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
|
||||||
import type { Account } from '@/types'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
account: Account
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Update timer
|
|
||||||
const currentTime = ref(new Date())
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// Update every second for more accurate countdown
|
|
||||||
timer = setInterval(() => {
|
|
||||||
currentTime.value = new Date()
|
|
||||||
}, 1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (timer) {
|
|
||||||
clearInterval(timer)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check if we have window information but it's been expired for more than 1 hour
|
|
||||||
const hasExpiredWindow = computed(() => {
|
|
||||||
if (!props.account.session_window_start || !props.account.session_window_end) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const end = new Date(props.account.session_window_end).getTime()
|
|
||||||
const now = currentTime.value.getTime()
|
|
||||||
const expiredMs = now - end
|
|
||||||
|
|
||||||
// Window exists and expired more than 1 hour ago
|
|
||||||
return expiredMs > 1000 * 60 * 60
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check if we have valid window information (not expired for more than 1 hour)
|
|
||||||
const hasWindowInfo = computed(() => {
|
|
||||||
if (!props.account.session_window_start || !props.account.session_window_end) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// If window is expired more than 1 hour, don't show progress bar
|
|
||||||
if (hasExpiredWindow.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Calculate time-based progress (0-100)
|
|
||||||
const timeProgress = computed(() => {
|
|
||||||
if (!props.account.session_window_start || !props.account.session_window_end) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = new Date(props.account.session_window_start).getTime()
|
|
||||||
const end = new Date(props.account.session_window_end).getTime()
|
|
||||||
const now = currentTime.value.getTime()
|
|
||||||
|
|
||||||
// Window hasn't started yet
|
|
||||||
if (now < start) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Window has ended
|
|
||||||
if (now >= end) {
|
|
||||||
return 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate progress within window
|
|
||||||
const total = end - start
|
|
||||||
const elapsed = now - start
|
|
||||||
return Math.round((elapsed / total) * 100)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Progress bar width
|
|
||||||
const progressWidth = computed(() => {
|
|
||||||
return `${Math.min(timeProgress.value, 100)}%`
|
|
||||||
})
|
|
||||||
|
|
||||||
// Display percentage
|
|
||||||
const displayPercent = computed(() => {
|
|
||||||
return `${timeProgress.value}%`
|
|
||||||
})
|
|
||||||
|
|
||||||
// Progress bar color based on progress
|
|
||||||
const barColorClass = computed(() => {
|
|
||||||
if (timeProgress.value >= 100) {
|
|
||||||
return 'bg-red-500'
|
|
||||||
} else if (timeProgress.value >= 80) {
|
|
||||||
return 'bg-amber-500'
|
|
||||||
} else {
|
|
||||||
return 'bg-green-500'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Text color based on progress
|
|
||||||
const textColorClass = computed(() => {
|
|
||||||
if (timeProgress.value >= 100) {
|
|
||||||
return 'text-red-600 dark:text-red-400'
|
|
||||||
} else if (timeProgress.value >= 80) {
|
|
||||||
return 'text-amber-600 dark:text-amber-400'
|
|
||||||
} else {
|
|
||||||
return 'text-gray-600 dark:text-gray-400'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Format reset time (time remaining until window end)
|
|
||||||
const formatResetTime = computed(() => {
|
|
||||||
if (!props.account.session_window_end) {
|
|
||||||
return 'N/A'
|
|
||||||
}
|
|
||||||
|
|
||||||
const end = new Date(props.account.session_window_end)
|
|
||||||
const now = currentTime.value
|
|
||||||
const diffMs = end.getTime() - now.getTime()
|
|
||||||
|
|
||||||
if (diffMs <= 0) {
|
|
||||||
// 窗口已过期,计算过期了多久
|
|
||||||
const expiredMs = Math.abs(diffMs)
|
|
||||||
const expiredHours = Math.floor(expiredMs / (1000 * 60 * 60))
|
|
||||||
|
|
||||||
if (expiredHours >= 1) {
|
|
||||||
return 'No recent activity'
|
|
||||||
}
|
|
||||||
return 'Window expired'
|
|
||||||
}
|
|
||||||
|
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
|
||||||
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
|
||||||
const diffSecs = Math.floor((diffMs % (1000 * 60)) / 1000)
|
|
||||||
|
|
||||||
if (diffHours > 0) {
|
|
||||||
return `${diffHours}h ${diffMins}m`
|
|
||||||
} else if (diffMins > 0) {
|
|
||||||
return `${diffMins}m ${diffSecs}s`
|
|
||||||
} else {
|
|
||||||
return `${diffSecs}s`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,37 +1,50 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-1">
|
<div>
|
||||||
<!-- Label badge (fixed width for alignment) -->
|
<!-- Window stats row (above progress bar, left-right aligned with progress bar) -->
|
||||||
<span
|
<div v-if="windowStats" class="flex items-center justify-between mb-0.5" :title="`5h 窗口用量统计`">
|
||||||
:class="[
|
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400 cursor-help">
|
||||||
'text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0',
|
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800">
|
||||||
labelClass
|
{{ formatRequests }} req
|
||||||
]"
|
</span>
|
||||||
>
|
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800">
|
||||||
{{ label }}
|
{{ formatTokens }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800">
|
||||||
<!-- Progress bar container -->
|
${{ formatCost }}
|
||||||
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden shrink-0">
|
</span>
|
||||||
<div
|
</div>
|
||||||
:class="['h-full transition-all duration-300', barClass]"
|
|
||||||
:style="{ width: barWidth }"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Percentage -->
|
<!-- Progress bar row -->
|
||||||
<span :class="['text-[10px] font-medium w-[32px] text-right shrink-0', textClass]">
|
<div class="flex items-center gap-1">
|
||||||
{{ displayPercent }}
|
<!-- Label badge (fixed width for alignment) -->
|
||||||
</span>
|
<span
|
||||||
|
:class="[
|
||||||
|
'text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0',
|
||||||
|
labelClass
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
|
||||||
<!-- Reset time -->
|
<!-- Progress bar container -->
|
||||||
<span v-if="resetsAt" class="text-[10px] text-gray-400 shrink-0">
|
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden shrink-0">
|
||||||
{{ formatResetTime }}
|
<div
|
||||||
</span>
|
:class="['h-full transition-all duration-300', barClass]"
|
||||||
|
:style="{ width: barWidth }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Window stats (only for 5h window) -->
|
<!-- Percentage -->
|
||||||
<span v-if="windowStats" class="text-[10px] text-gray-400 shrink-0 ml-1">
|
<span :class="['text-[10px] font-medium w-[32px] text-right shrink-0', textClass]">
|
||||||
({{ formatStats }})
|
{{ displayPercent }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<!-- Reset time -->
|
||||||
|
<span v-if="resetsAt" class="text-[10px] text-gray-400 shrink-0">
|
||||||
|
{{ formatResetTime }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -113,17 +126,25 @@ const formatResetTime = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Format window stats
|
// Format window stats
|
||||||
const formatStats = computed(() => {
|
const formatRequests = computed(() => {
|
||||||
if (!props.windowStats) return ''
|
if (!props.windowStats) return ''
|
||||||
const { requests, tokens, cost } = props.windowStats
|
const r = props.windowStats.requests
|
||||||
|
if (r >= 1000000) return `${(r / 1000000).toFixed(1)}M`
|
||||||
|
if (r >= 1000) return `${(r / 1000).toFixed(1)}K`
|
||||||
|
return r.toString()
|
||||||
|
})
|
||||||
|
|
||||||
// Format tokens (e.g., 1234567 -> 1.2M)
|
const formatTokens = computed(() => {
|
||||||
const formatTokens = (t: number): string => {
|
if (!props.windowStats) return ''
|
||||||
if (t >= 1000000) return `${(t / 1000000).toFixed(1)}M`
|
const t = props.windowStats.tokens
|
||||||
if (t >= 1000) return `${(t / 1000).toFixed(1)}K`
|
if (t >= 1000000000) return `${(t / 1000000000).toFixed(1)}B`
|
||||||
return t.toString()
|
if (t >= 1000000) return `${(t / 1000000).toFixed(1)}M`
|
||||||
}
|
if (t >= 1000) return `${(t / 1000).toFixed(1)}K`
|
||||||
|
return t.toString()
|
||||||
|
})
|
||||||
|
|
||||||
return `${requests}req ${formatTokens(tokens)}tok $${cost.toFixed(2)}`
|
const formatCost = computed(() => {
|
||||||
|
if (!props.windowStats) return '0.00'
|
||||||
|
return props.windowStats.cost.toFixed(2)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
75
frontend/src/components/common/PlatformTypeBadge.vue
Normal file
75
frontend/src/components/common/PlatformTypeBadge.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div class="inline-flex items-center rounded-md overflow-hidden text-xs font-medium">
|
||||||
|
<!-- Platform part -->
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1 px-2 py-1',
|
||||||
|
platformClass
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<PlatformIcon :platform="platform" size="xs" />
|
||||||
|
<span>{{ platformLabel }}</span>
|
||||||
|
</span>
|
||||||
|
<!-- Type part -->
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1 px-1.5 py-1',
|
||||||
|
typeClass
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- OAuth icon -->
|
||||||
|
<svg v-if="type === 'oauth'" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
<!-- Setup Token icon -->
|
||||||
|
<svg v-else-if="type === 'setup-token'" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||||
|
</svg>
|
||||||
|
<!-- API Key icon -->
|
||||||
|
<svg v-else class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ typeLabel }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { AccountPlatform, AccountType } from '@/types'
|
||||||
|
import PlatformIcon from './PlatformIcon.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
platform: AccountPlatform
|
||||||
|
type: AccountType
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const platformLabel = computed(() => {
|
||||||
|
return props.platform === 'anthropic' ? 'Anthropic' : 'OpenAI'
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeLabel = computed(() => {
|
||||||
|
switch (props.type) {
|
||||||
|
case 'oauth': return 'OAuth'
|
||||||
|
case 'setup-token': return 'Token'
|
||||||
|
case 'apikey': return 'Key'
|
||||||
|
default: return props.type
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const platformClass = computed(() => {
|
||||||
|
if (props.platform === 'anthropic') {
|
||||||
|
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||||
|
}
|
||||||
|
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeClass = computed(() => {
|
||||||
|
if (props.platform === 'anthropic') {
|
||||||
|
return 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
|
||||||
|
}
|
||||||
|
return 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Restart button -->
|
<!-- Restart button with countdown -->
|
||||||
<button
|
<button
|
||||||
@click="handleRestart"
|
@click="handleRestart"
|
||||||
:disabled="restarting"
|
:disabled="restarting"
|
||||||
@@ -122,7 +122,11 @@
|
|||||||
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ restarting ? t('version.restarting') : t('version.restartNow') }}
|
<template v-if="restarting">
|
||||||
|
<span>{{ t('version.restarting') }}</span>
|
||||||
|
<span v-if="restartCountdown > 0" class="tabular-nums">({{ restartCountdown }}s)</span>
|
||||||
|
</template>
|
||||||
|
<span v-else>{{ t('version.restartNow') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -266,6 +270,7 @@ const restarting = ref(false);
|
|||||||
const needRestart = ref(false);
|
const needRestart = ref(false);
|
||||||
const updateError = ref('');
|
const updateError = ref('');
|
||||||
const updateSuccess = ref(false);
|
const updateSuccess = ref(false);
|
||||||
|
const restartCountdown = ref(0);
|
||||||
|
|
||||||
// Only show update check for release builds (binary/docker deployment)
|
// Only show update check for release builds (binary/docker deployment)
|
||||||
const isReleaseBuild = computed(() => buildType.value === 'release');
|
const isReleaseBuild = computed(() => buildType.value === 'release');
|
||||||
@@ -314,6 +319,7 @@ async function handleRestart() {
|
|||||||
if (restarting.value) return;
|
if (restarting.value) return;
|
||||||
|
|
||||||
restarting.value = true;
|
restarting.value = true;
|
||||||
|
restartCountdown.value = 8;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await restartService();
|
await restartService();
|
||||||
@@ -323,10 +329,43 @@ async function handleRestart() {
|
|||||||
console.log('Service restarting...');
|
console.log('Service restarting...');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show restarting state for a while, then reload
|
// Start countdown
|
||||||
setTimeout(() => {
|
const countdownInterval = setInterval(() => {
|
||||||
window.location.reload();
|
restartCountdown.value--;
|
||||||
}, 3000);
|
if (restartCountdown.value <= 0) {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
// Try to check if service is back before reload
|
||||||
|
checkServiceAndReload();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkServiceAndReload() {
|
||||||
|
const maxRetries = 5;
|
||||||
|
const retryDelay = 1000;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/health', {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-cache'
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
// Service is back, reload page
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Service not ready yet
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < maxRetries - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After retries, reload anyway
|
||||||
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export default {
|
|||||||
todayTokens: 'Today Tokens',
|
todayTokens: 'Today Tokens',
|
||||||
totalTokens: 'Total Tokens',
|
totalTokens: 'Total Tokens',
|
||||||
cacheToday: 'Cache (Today)',
|
cacheToday: 'Cache (Today)',
|
||||||
|
performance: 'Performance',
|
||||||
avgResponse: 'Avg Response',
|
avgResponse: 'Avg Response',
|
||||||
averageTime: 'Average time',
|
averageTime: 'Average time',
|
||||||
timeRange: 'Time Range',
|
timeRange: 'Time Range',
|
||||||
@@ -420,6 +421,7 @@ export default {
|
|||||||
todayTokens: 'Today Tokens',
|
todayTokens: 'Today Tokens',
|
||||||
totalTokens: 'Total Tokens',
|
totalTokens: 'Total Tokens',
|
||||||
cacheToday: 'Cache (Today)',
|
cacheToday: 'Cache (Today)',
|
||||||
|
performance: 'Performance',
|
||||||
avgResponse: 'Avg Response',
|
avgResponse: 'Avg Response',
|
||||||
active: 'active',
|
active: 'active',
|
||||||
ok: 'ok',
|
ok: 'ok',
|
||||||
@@ -700,8 +702,10 @@ export default {
|
|||||||
},
|
},
|
||||||
columns: {
|
columns: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
|
platformType: 'Platform/Type',
|
||||||
platform: 'Platform',
|
platform: 'Platform',
|
||||||
type: 'Type',
|
type: 'Type',
|
||||||
|
concurrencyStatus: 'Concurrency',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
schedulable: 'Schedule',
|
schedulable: 'Schedule',
|
||||||
todayStats: "Today's Stats",
|
todayStats: "Today's Stats",
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export default {
|
|||||||
todayTokens: '今日 Token',
|
todayTokens: '今日 Token',
|
||||||
totalTokens: '累计 Token',
|
totalTokens: '累计 Token',
|
||||||
cacheToday: '今日缓存',
|
cacheToday: '今日缓存',
|
||||||
|
performance: '性能指标',
|
||||||
avgResponse: '平均响应',
|
avgResponse: '平均响应',
|
||||||
averageTime: '平均时间',
|
averageTime: '平均时间',
|
||||||
timeRange: '时间范围',
|
timeRange: '时间范围',
|
||||||
@@ -432,6 +433,7 @@ export default {
|
|||||||
input: '输入',
|
input: '输入',
|
||||||
output: '输出',
|
output: '输出',
|
||||||
cacheToday: '今日缓存',
|
cacheToday: '今日缓存',
|
||||||
|
performance: '性能指标',
|
||||||
avgResponse: '平均响应',
|
avgResponse: '平均响应',
|
||||||
averageTime: '平均时间',
|
averageTime: '平均时间',
|
||||||
timeRange: '时间范围',
|
timeRange: '时间范围',
|
||||||
@@ -794,8 +796,10 @@ export default {
|
|||||||
failedToToggleSchedulable: '切换调度状态失败',
|
failedToToggleSchedulable: '切换调度状态失败',
|
||||||
columns: {
|
columns: {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
|
platformType: '平台/类型',
|
||||||
platform: '平台',
|
platform: '平台',
|
||||||
type: '类型',
|
type: '类型',
|
||||||
|
concurrencyStatus: '并发',
|
||||||
priority: '优先级',
|
priority: '优先级',
|
||||||
weight: '权重',
|
weight: '权重',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ export interface Account {
|
|||||||
extra?: CodexUsageSnapshot & Record<string, unknown>; // Extra fields including Codex usage
|
extra?: CodexUsageSnapshot & Record<string, unknown>; // Extra fields including Codex usage
|
||||||
proxy_id: number | null;
|
proxy_id: number | null;
|
||||||
concurrency: number;
|
concurrency: number;
|
||||||
|
current_concurrency?: number; // Real-time concurrency count from Redis
|
||||||
priority: number;
|
priority: number;
|
||||||
status: 'active' | 'inactive' | 'error';
|
status: 'active' | 'inactive' | 'error';
|
||||||
error_message: string | null;
|
error_message: string | null;
|
||||||
@@ -517,6 +518,10 @@ export interface DashboardStats {
|
|||||||
// 系统运行统计
|
// 系统运行统计
|
||||||
average_duration_ms: number; // 平均响应时间
|
average_duration_ms: number; // 平均响应时间
|
||||||
uptime: number; // 系统运行时间(秒)
|
uptime: number; // 系统运行时间(秒)
|
||||||
|
|
||||||
|
// 性能指标
|
||||||
|
rpm: number; // 最近1分钟的请求数
|
||||||
|
tpm: number; // 最近1分钟的Token数
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageStatsResponse {
|
export interface UsageStatsResponse {
|
||||||
|
|||||||
@@ -73,29 +73,30 @@
|
|||||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-platform="{ value }">
|
<template #cell-platform_type="{ row }">
|
||||||
<span
|
<PlatformTypeBadge :platform="row.platform" :type="row.type" />
|
||||||
:class="[
|
|
||||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium',
|
|
||||||
value === 'anthropic'
|
|
||||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
|
||||||
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<PlatformIcon :platform="value" size="xs" />
|
|
||||||
{{ value === 'anthropic' ? 'Anthropic' : 'OpenAI' }}
|
|
||||||
</span>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-type="{ value }">
|
<template #cell-concurrency="{ row }">
|
||||||
<span
|
<div class="flex items-center gap-1.5">
|
||||||
:class="[
|
<span
|
||||||
'badge',
|
:class="[
|
||||||
value === 'oauth' ? 'badge-primary' : value === 'setup-token' ? 'badge-info' : 'badge-purple'
|
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium',
|
||||||
]"
|
(row.current_concurrency || 0) >= row.concurrency
|
||||||
>
|
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||||
{{ value === 'oauth' ? 'Oauth' : value === 'setup-token' ? t('admin.accounts.setupToken') : t('admin.accounts.apiKey') }}
|
: (row.current_concurrency || 0) > 0
|
||||||
</span>
|
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-mono">{{ row.current_concurrency || 0 }}</span>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||||
|
<span class="font-mono">{{ row.concurrency }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-status="{ row }">
|
<template #cell-status="{ row }">
|
||||||
@@ -336,7 +337,7 @@ import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
|
|||||||
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
||||||
import AccountTestModal from '@/components/account/AccountTestModal.vue'
|
import AccountTestModal from '@/components/account/AccountTestModal.vue'
|
||||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||||
import { formatRelativeTime } from '@/utils/format'
|
import { formatRelativeTime } from '@/utils/format'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -345,8 +346,8 @@ const appStore = useAppStore()
|
|||||||
// Table columns
|
// Table columns
|
||||||
const columns = computed<Column[]>(() => [
|
const columns = computed<Column[]>(() => [
|
||||||
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
|
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
|
||||||
{ key: 'platform', label: t('admin.accounts.columns.platform'), sortable: true },
|
{ key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false },
|
||||||
{ key: 'type', label: t('admin.accounts.columns.type'), sortable: true },
|
{ key: 'concurrency', label: t('admin.accounts.columns.concurrencyStatus'), sortable: false },
|
||||||
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
|
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
|
||||||
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
|
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
|
||||||
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false },
|
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false },
|
||||||
|
|||||||
@@ -117,20 +117,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cache Tokens -->
|
<!-- Performance (RPM/TPM) -->
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="p-2 rounded-lg bg-cyan-100 dark:bg-cyan-900/30">
|
<div class="p-2 rounded-lg bg-violet-100 dark:bg-violet-900/30">
|
||||||
<svg class="w-5 h-5 text-cyan-600 dark:text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-violet-600 dark:text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex-1">
|
||||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.cacheToday') }}</p>
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.performance') }}</p>
|
||||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.today_cache_read_tokens) }}</p>
|
<div class="flex items-baseline gap-2">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.rpm) }}</p>
|
||||||
{{ t('common.create') }}: {{ formatTokens(stats.today_cache_creation_tokens) }}
|
<span class="text-xs text-gray-500 dark:text-gray-400">RPM</span>
|
||||||
</p>
|
</div>
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<p class="text-sm font-semibold text-violet-600 dark:text-violet-400">{{ formatTokens(stats.tpm) }}</p>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">TPM</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -378,7 +382,8 @@ const userTrendChartData = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Format helpers
|
// Format helpers
|
||||||
const formatTokens = (value: number): string => {
|
const formatTokens = (value: number | undefined): string => {
|
||||||
|
if (value === undefined || value === null) return '0'
|
||||||
if (value >= 1_000_000_000) {
|
if (value >= 1_000_000_000) {
|
||||||
return `${(value / 1_000_000_000).toFixed(2)}B`
|
return `${(value / 1_000_000_000).toFixed(2)}B`
|
||||||
} else if (value >= 1_000_000) {
|
} else if (value >= 1_000_000) {
|
||||||
|
|||||||
@@ -119,20 +119,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cache Tokens -->
|
<!-- Performance (RPM/TPM) -->
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="p-2 rounded-lg bg-cyan-100 dark:bg-cyan-900/30">
|
<div class="p-2 rounded-lg bg-violet-100 dark:bg-violet-900/30">
|
||||||
<svg class="w-5 h-5 text-cyan-600 dark:text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-violet-600 dark:text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex-1">
|
||||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.cacheToday') }}</p>
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.performance') }}</p>
|
||||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.today_cache_read_tokens) }}</p>
|
<div class="flex items-baseline gap-2">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.rpm) }}</p>
|
||||||
{{ t('common.create') }}: {{ formatTokens(stats.today_cache_creation_tokens) }}
|
<span class="text-xs text-gray-500 dark:text-gray-400">RPM</span>
|
||||||
</p>
|
</div>
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<p class="text-sm font-semibold text-violet-600 dark:text-violet-400">{{ formatTokens(stats.tpm) }}</p>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">TPM</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -584,7 +588,8 @@ const trendChartData = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Format helpers
|
// Format helpers
|
||||||
const formatTokens = (value: number): string => {
|
const formatTokens = (value: number | undefined): string => {
|
||||||
|
if (value === undefined || value === null) return '0'
|
||||||
if (value >= 1_000_000_000) {
|
if (value >= 1_000_000_000) {
|
||||||
return `${(value / 1_000_000_000).toFixed(2)}B`
|
return `${(value / 1_000_000_000).toFixed(2)}B`
|
||||||
} else if (value >= 1_000_000) {
|
} else if (value >= 1_000_000) {
|
||||||
|
|||||||
Reference in New Issue
Block a user