mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-08 01:00:21 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
870b21916c | ||
|
|
fb119f9a67 | ||
|
|
ad54795a24 | ||
|
|
0abe322cca | ||
|
|
b071511676 | ||
|
|
7d9a757a26 | ||
|
|
bbf4024dc7 | ||
|
|
5831eb8a6a | ||
|
|
61838cdb3d | ||
|
|
50dba656fd | ||
|
|
0e2821456c | ||
|
|
f25ac3aff5 | ||
|
|
f6341b7f2b | ||
|
|
4e257512b9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,6 +28,7 @@ node_modules/
|
|||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
*.local
|
*.local
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# 日志
|
# 日志
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ type UpdateUserRequest struct {
|
|||||||
|
|
||||||
// UpdateBalanceRequest represents balance update request
|
// UpdateBalanceRequest represents balance update request
|
||||||
type UpdateBalanceRequest struct {
|
type UpdateBalanceRequest struct {
|
||||||
Balance float64 `json:"balance" binding:"required"`
|
Balance float64 `json:"balance" binding:"required,gt=0"`
|
||||||
Operation string `json:"operation" binding:"required,oneof=set add subtract"`
|
Operation string `json:"operation" binding:"required,oneof=set add subtract"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// List handles listing all users with pagination
|
// List handles listing all users with pagination
|
||||||
@@ -183,7 +184,7 @@ func (h *UserHandler) UpdateBalance(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.adminService.UpdateUserBalance(c.Request.Context(), userID, req.Balance, req.Operation)
|
user, err := h.adminService.UpdateUserBalance(c.Request.Context(), userID, req.Balance, req.Operation, req.Notes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalError(c, "Failed to update balance: "+err.Error())
|
response.InternalError(c, "Failed to update balance: "+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
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type RedeemCode struct {
|
|||||||
Status string `gorm:"size:20;default:unused;not null" json:"status"` // unused/used
|
Status string `gorm:"size:20;default:unused;not null" json:"status"` // unused/used
|
||||||
UsedBy *int64 `gorm:"index" json:"used_by"`
|
UsedBy *int64 `gorm:"index" json:"used_by"`
|
||||||
UsedAt *time.Time `json:"used_at"`
|
UsedAt *time.Time `json:"used_at"`
|
||||||
|
Notes string `gorm:"type:text" json:"notes"`
|
||||||
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
||||||
|
|
||||||
// 订阅类型专用字段
|
// 订阅类型专用字段
|
||||||
|
|||||||
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"`
|
||||||
|
}
|
||||||
@@ -23,14 +23,18 @@ func (r *AccountRepository) Create(ctx context.Context, account *model.Account)
|
|||||||
|
|
||||||
func (r *AccountRepository) GetByID(ctx context.Context, id int64) (*model.Account, error) {
|
func (r *AccountRepository) GetByID(ctx context.Context, id int64) (*model.Account, error) {
|
||||||
var account model.Account
|
var account model.Account
|
||||||
err := r.db.WithContext(ctx).Preload("Proxy").Preload("AccountGroups").First(&account, id).Error
|
err := r.db.WithContext(ctx).Preload("Proxy").Preload("AccountGroups.Group").First(&account, id).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 填充 GroupIDs 虚拟字段
|
// 填充 GroupIDs 和 Groups 虚拟字段
|
||||||
account.GroupIDs = make([]int64, 0, len(account.AccountGroups))
|
account.GroupIDs = make([]int64, 0, len(account.AccountGroups))
|
||||||
|
account.Groups = make([]*model.Group, 0, len(account.AccountGroups))
|
||||||
for _, ag := range account.AccountGroups {
|
for _, ag := range account.AccountGroups {
|
||||||
account.GroupIDs = append(account.GroupIDs, ag.GroupID)
|
account.GroupIDs = append(account.GroupIDs, ag.GroupID)
|
||||||
|
if ag.Group != nil {
|
||||||
|
account.Groups = append(account.Groups, ag.Group)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return &account, nil
|
return &account, nil
|
||||||
}
|
}
|
||||||
@@ -303,3 +307,31 @@ func (r *AccountRepository) SetSchedulable(ctx context.Context, id int64, schedu
|
|||||||
return r.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", id).
|
return r.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", id).
|
||||||
Update("schedulable", schedulable).Error
|
Update("schedulable", schedulable).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateExtra updates specific fields in account's Extra JSONB field
|
||||||
|
// It merges the updates into existing Extra data without overwriting other fields
|
||||||
|
func (r *AccountRepository) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error {
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current account to preserve existing Extra data
|
||||||
|
var account model.Account
|
||||||
|
if err := r.db.WithContext(ctx).Select("extra").Where("id = ?", id).First(&account).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Extra if nil
|
||||||
|
if account.Extra == nil {
|
||||||
|
account.Extra = make(model.JSONB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge updates into existing Extra
|
||||||
|
for k, v := range updates {
|
||||||
|
account.Extra[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save updated Extra
|
||||||
|
return r.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", id).
|
||||||
|
Update("extra", account.Extra).Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/pkg/oauth"
|
"sub2api/internal/pkg/oauth"
|
||||||
@@ -139,20 +140,12 @@ func (s *claudeOAuthService) GetAuthorizationCode(ctx context.Context, sessionKe
|
|||||||
func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string) (*oauth.TokenResponse, error) {
|
func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string) (*oauth.TokenResponse, error) {
|
||||||
client := createReqClient(proxyURL)
|
client := createReqClient(proxyURL)
|
||||||
|
|
||||||
|
// Parse code which may contain state in format "authCode#state"
|
||||||
authCode := code
|
authCode := code
|
||||||
codeState := ""
|
codeState := ""
|
||||||
if len(code) > 0 {
|
if idx := strings.Index(code, "#"); idx != -1 {
|
||||||
parts := make([]string, 0, 2)
|
authCode = code[:idx]
|
||||||
for i, part := range []rune(code) {
|
codeState = code[idx+1:]
|
||||||
if part == '#' {
|
|
||||||
authCode = code[:i]
|
|
||||||
codeState = code[i+1:]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(parts) == 0 {
|
|
||||||
authCode = code
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reqBody := map[string]any{
|
reqBody := map[string]any{
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type AdminService interface {
|
|||||||
CreateUser(ctx context.Context, input *CreateUserInput) (*model.User, error)
|
CreateUser(ctx context.Context, input *CreateUserInput) (*model.User, error)
|
||||||
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*model.User, error)
|
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*model.User, error)
|
||||||
DeleteUser(ctx context.Context, id int64) error
|
DeleteUser(ctx context.Context, id int64) error
|
||||||
UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string) (*model.User, error)
|
UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*model.User, error)
|
||||||
GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]model.ApiKey, int64, error)
|
GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]model.ApiKey, int64, error)
|
||||||
GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error)
|
GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error)
|
||||||
|
|
||||||
@@ -271,8 +271,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
|||||||
return nil, errors.New("cannot disable admin user")
|
return nil, errors.New("cannot disable admin user")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track balance and concurrency changes for logging
|
|
||||||
oldBalance := user.Balance
|
|
||||||
oldConcurrency := user.Concurrency
|
oldConcurrency := user.Concurrency
|
||||||
|
|
||||||
if input.Email != "" {
|
if input.Email != "" {
|
||||||
@@ -284,7 +282,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新用户字段
|
|
||||||
if input.Username != nil {
|
if input.Username != nil {
|
||||||
user.Username = *input.Username
|
user.Username = *input.Username
|
||||||
}
|
}
|
||||||
@@ -295,22 +292,14 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
|||||||
user.Notes = *input.Notes
|
user.Notes = *input.Notes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role is not allowed to be changed via API to prevent privilege escalation
|
|
||||||
if input.Status != "" {
|
if input.Status != "" {
|
||||||
user.Status = input.Status
|
user.Status = input.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只在指针非 nil 时更新 Balance(支持设置为 0)
|
|
||||||
if input.Balance != nil {
|
|
||||||
user.Balance = *input.Balance
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只在指针非 nil 时更新 Concurrency(支持设置为任意值)
|
|
||||||
if input.Concurrency != nil {
|
if input.Concurrency != nil {
|
||||||
user.Concurrency = *input.Concurrency
|
user.Concurrency = *input.Concurrency
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只在指针非 nil 时更新 AllowedGroups
|
|
||||||
if input.AllowedGroups != nil {
|
if input.AllowedGroups != nil {
|
||||||
user.AllowedGroups = *input.AllowedGroups
|
user.AllowedGroups = *input.AllowedGroups
|
||||||
}
|
}
|
||||||
@@ -319,41 +308,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 余额变化时失效缓存
|
|
||||||
if input.Balance != nil && *input.Balance != oldBalance {
|
|
||||||
if s.billingCacheService != nil {
|
|
||||||
go func() {
|
|
||||||
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := s.billingCacheService.InvalidateUserBalance(cacheCtx, id); err != nil {
|
|
||||||
log.Printf("invalidate user balance cache failed: user_id=%d err=%v", id, err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create adjustment records for balance/concurrency changes
|
|
||||||
balanceDiff := user.Balance - oldBalance
|
|
||||||
if balanceDiff != 0 {
|
|
||||||
code, err := model.GenerateRedeemCode()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("failed to generate adjustment redeem code: %v", err)
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
adjustmentRecord := &model.RedeemCode{
|
|
||||||
Code: code,
|
|
||||||
Type: model.AdjustmentTypeAdminBalance,
|
|
||||||
Value: balanceDiff,
|
|
||||||
Status: model.StatusUsed,
|
|
||||||
UsedBy: &user.ID,
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
adjustmentRecord.UsedAt = &now
|
|
||||||
if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil {
|
|
||||||
log.Printf("failed to create balance adjustment redeem code: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
concurrencyDiff := user.Concurrency - oldConcurrency
|
concurrencyDiff := user.Concurrency - oldConcurrency
|
||||||
if concurrencyDiff != 0 {
|
if concurrencyDiff != 0 {
|
||||||
code, err := model.GenerateRedeemCode()
|
code, err := model.GenerateRedeemCode()
|
||||||
@@ -390,12 +344,14 @@ func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error {
|
|||||||
return s.userRepo.Delete(ctx, id)
|
return s.userRepo.Delete(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string) (*model.User, error) {
|
func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*model.User, error) {
|
||||||
user, err := s.userRepo.GetByID(ctx, userID)
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oldBalance := user.Balance
|
||||||
|
|
||||||
switch operation {
|
switch operation {
|
||||||
case "set":
|
case "set":
|
||||||
user.Balance = balance
|
user.Balance = balance
|
||||||
@@ -405,11 +361,14 @@ func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64,
|
|||||||
user.Balance -= balance
|
user.Balance -= balance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user.Balance < 0 {
|
||||||
|
return nil, fmt.Errorf("balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f", oldBalance, user.Balance)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 失效余额缓存
|
|
||||||
if s.billingCacheService != nil {
|
if s.billingCacheService != nil {
|
||||||
go func() {
|
go func() {
|
||||||
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
@@ -420,6 +379,30 @@ func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64,
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
balanceDiff := user.Balance - oldBalance
|
||||||
|
if balanceDiff != 0 {
|
||||||
|
code, err := model.GenerateRedeemCode()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to generate adjustment redeem code: %v", err)
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustmentRecord := &model.RedeemCode{
|
||||||
|
Code: code,
|
||||||
|
Type: model.AdjustmentTypeAdminBalance,
|
||||||
|
Value: balanceDiff,
|
||||||
|
Status: model.StatusUsed,
|
||||||
|
UsedBy: &user.ID,
|
||||||
|
Notes: notes,
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
adjustmentRecord.UsedAt = &now
|
||||||
|
|
||||||
|
if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil {
|
||||||
|
log.Printf("failed to create balance adjustment redeem code: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -734,7 +793,7 @@ func (s *GatewayService) replaceModelInSSELine(line, fromModel, toModel string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) {
|
func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) {
|
||||||
// 解析message_start获取input tokens
|
// 解析message_start获取input tokens(标准Claude API格式)
|
||||||
var msgStart struct {
|
var msgStart struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Message struct {
|
Message struct {
|
||||||
@@ -747,15 +806,30 @@ func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) {
|
|||||||
usage.CacheReadInputTokens = msgStart.Message.Usage.CacheReadInputTokens
|
usage.CacheReadInputTokens = msgStart.Message.Usage.CacheReadInputTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析message_delta获取output tokens
|
// 解析message_delta获取tokens(兼容GLM等把所有usage放在delta中的API)
|
||||||
var msgDelta struct {
|
var msgDelta struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Usage struct {
|
Usage struct {
|
||||||
OutputTokens int `json:"output_tokens"`
|
InputTokens int `json:"input_tokens"`
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
||||||
|
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
||||||
} `json:"usage"`
|
} `json:"usage"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal([]byte(data), &msgDelta) == nil && msgDelta.Type == "message_delta" {
|
if json.Unmarshal([]byte(data), &msgDelta) == nil && msgDelta.Type == "message_delta" {
|
||||||
|
// output_tokens 总是从 message_delta 获取
|
||||||
usage.OutputTokens = msgDelta.Usage.OutputTokens
|
usage.OutputTokens = msgDelta.Usage.OutputTokens
|
||||||
|
|
||||||
|
// 如果 message_start 中没有值,则从 message_delta 获取(兼容GLM等API)
|
||||||
|
if usage.InputTokens == 0 {
|
||||||
|
usage.InputTokens = msgDelta.Usage.InputTokens
|
||||||
|
}
|
||||||
|
if usage.CacheCreationInputTokens == 0 {
|
||||||
|
usage.CacheCreationInputTokens = msgDelta.Usage.CacheCreationInputTokens
|
||||||
|
}
|
||||||
|
if usage.CacheReadInputTokens == 0 {
|
||||||
|
usage.CacheReadInputTokens = msgDelta.Usage.CacheReadInputTokens
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -38,6 +39,18 @@ var openaiAllowedHeaders = map[string]bool{
|
|||||||
"session_id": true,
|
"session_id": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenAICodexUsageSnapshot represents Codex API usage limits from response headers
|
||||||
|
type OpenAICodexUsageSnapshot struct {
|
||||||
|
PrimaryUsedPercent *float64 `json:"primary_used_percent,omitempty"`
|
||||||
|
PrimaryResetAfterSeconds *int `json:"primary_reset_after_seconds,omitempty"`
|
||||||
|
PrimaryWindowMinutes *int `json:"primary_window_minutes,omitempty"`
|
||||||
|
SecondaryUsedPercent *float64 `json:"secondary_used_percent,omitempty"`
|
||||||
|
SecondaryResetAfterSeconds *int `json:"secondary_reset_after_seconds,omitempty"`
|
||||||
|
SecondaryWindowMinutes *int `json:"secondary_window_minutes,omitempty"`
|
||||||
|
PrimaryOverSecondaryPercent *float64 `json:"primary_over_secondary_percent,omitempty"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// OpenAIUsage represents OpenAI API response usage
|
// OpenAIUsage represents OpenAI API response usage
|
||||||
type OpenAIUsage struct {
|
type OpenAIUsage struct {
|
||||||
InputTokens int `json:"input_tokens"`
|
InputTokens int `json:"input_tokens"`
|
||||||
@@ -284,6 +297,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract and save Codex usage snapshot from response headers (for OAuth accounts)
|
||||||
|
if account.Type == model.AccountTypeOAuth {
|
||||||
|
if snapshot := extractCodexUsageHeaders(resp.Header); snapshot != nil {
|
||||||
|
s.updateCodexUsageSnapshot(ctx, account.ID, snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &OpenAIForwardResult{
|
return &OpenAIForwardResult{
|
||||||
RequestID: resp.Header.Get("x-request-id"),
|
RequestID: resp.Header.Get("x-request-id"),
|
||||||
Usage: *usage,
|
Usage: *usage,
|
||||||
@@ -708,3 +728,109 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractCodexUsageHeaders extracts Codex usage limits from response headers
|
||||||
|
func extractCodexUsageHeaders(headers http.Header) *OpenAICodexUsageSnapshot {
|
||||||
|
snapshot := &OpenAICodexUsageSnapshot{}
|
||||||
|
hasData := false
|
||||||
|
|
||||||
|
// Helper to parse float64 from header
|
||||||
|
parseFloat := func(key string) *float64 {
|
||||||
|
if v := headers.Get(key); v != "" {
|
||||||
|
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||||
|
return &f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to parse int from header
|
||||||
|
parseInt := func(key string) *int {
|
||||||
|
if v := headers.Get(key); v != "" {
|
||||||
|
if i, err := strconv.Atoi(v); err == nil {
|
||||||
|
return &i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary (weekly) limits
|
||||||
|
if v := parseFloat("x-codex-primary-used-percent"); v != nil {
|
||||||
|
snapshot.PrimaryUsedPercent = v
|
||||||
|
hasData = true
|
||||||
|
}
|
||||||
|
if v := parseInt("x-codex-primary-reset-after-seconds"); v != nil {
|
||||||
|
snapshot.PrimaryResetAfterSeconds = v
|
||||||
|
hasData = true
|
||||||
|
}
|
||||||
|
if v := parseInt("x-codex-primary-window-minutes"); v != nil {
|
||||||
|
snapshot.PrimaryWindowMinutes = v
|
||||||
|
hasData = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary (5h) limits
|
||||||
|
if v := parseFloat("x-codex-secondary-used-percent"); v != nil {
|
||||||
|
snapshot.SecondaryUsedPercent = v
|
||||||
|
hasData = true
|
||||||
|
}
|
||||||
|
if v := parseInt("x-codex-secondary-reset-after-seconds"); v != nil {
|
||||||
|
snapshot.SecondaryResetAfterSeconds = v
|
||||||
|
hasData = true
|
||||||
|
}
|
||||||
|
if v := parseInt("x-codex-secondary-window-minutes"); v != nil {
|
||||||
|
snapshot.SecondaryWindowMinutes = v
|
||||||
|
hasData = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overflow ratio
|
||||||
|
if v := parseFloat("x-codex-primary-over-secondary-limit-percent"); v != nil {
|
||||||
|
snapshot.PrimaryOverSecondaryPercent = v
|
||||||
|
hasData = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasData {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateCodexUsageSnapshot saves the Codex usage snapshot to account's Extra field
|
||||||
|
func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, accountID int64, snapshot *OpenAICodexUsageSnapshot) {
|
||||||
|
if snapshot == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert snapshot to map for merging into Extra
|
||||||
|
updates := make(map[string]any)
|
||||||
|
if snapshot.PrimaryUsedPercent != nil {
|
||||||
|
updates["codex_primary_used_percent"] = *snapshot.PrimaryUsedPercent
|
||||||
|
}
|
||||||
|
if snapshot.PrimaryResetAfterSeconds != nil {
|
||||||
|
updates["codex_primary_reset_after_seconds"] = *snapshot.PrimaryResetAfterSeconds
|
||||||
|
}
|
||||||
|
if snapshot.PrimaryWindowMinutes != nil {
|
||||||
|
updates["codex_primary_window_minutes"] = *snapshot.PrimaryWindowMinutes
|
||||||
|
}
|
||||||
|
if snapshot.SecondaryUsedPercent != nil {
|
||||||
|
updates["codex_secondary_used_percent"] = *snapshot.SecondaryUsedPercent
|
||||||
|
}
|
||||||
|
if snapshot.SecondaryResetAfterSeconds != nil {
|
||||||
|
updates["codex_secondary_reset_after_seconds"] = *snapshot.SecondaryResetAfterSeconds
|
||||||
|
}
|
||||||
|
if snapshot.SecondaryWindowMinutes != nil {
|
||||||
|
updates["codex_secondary_window_minutes"] = *snapshot.SecondaryWindowMinutes
|
||||||
|
}
|
||||||
|
if snapshot.PrimaryOverSecondaryPercent != nil {
|
||||||
|
updates["codex_primary_over_secondary_percent"] = *snapshot.PrimaryOverSecondaryPercent
|
||||||
|
}
|
||||||
|
updates["codex_usage_updated_at"] = snapshot.UpdatedAt
|
||||||
|
|
||||||
|
// Update account's Extra field asynchronously
|
||||||
|
go func() {
|
||||||
|
updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,4 +34,5 @@ type AccountRepository interface {
|
|||||||
SetOverloaded(ctx context.Context, id int64, until time.Time) error
|
SetOverloaded(ctx context.Context, id int64, until time.Time) error
|
||||||
ClearRateLimit(ctx context.Context, id int64) error
|
ClearRateLimit(ctx context.Context, id int64) error
|
||||||
UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error
|
UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error
|
||||||
|
UpdateExtra(ctx context.Context, id int64, updates map[string]any) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
6
backend/migrations/004_add_redeem_code_notes.sql
Normal file
6
backend/migrations/004_add_redeem_code_notes.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- 为 redeem_codes 表添加备注字段
|
||||||
|
|
||||||
|
ALTER TABLE redeem_codes
|
||||||
|
ADD COLUMN IF NOT EXISTS notes TEXT DEFAULT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN redeem_codes.notes IS '备注说明(管理员调整时的原因说明)';
|
||||||
@@ -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 "$@"
|
||||||
|
|||||||
@@ -84,16 +84,19 @@ export async function deleteUser(id: number): Promise<{ message: string }> {
|
|||||||
* @param id - User ID
|
* @param id - User ID
|
||||||
* @param balance - New balance
|
* @param balance - New balance
|
||||||
* @param operation - Operation type ('set', 'add', 'subtract')
|
* @param operation - Operation type ('set', 'add', 'subtract')
|
||||||
|
* @param notes - Optional notes for the balance adjustment
|
||||||
* @returns Updated user
|
* @returns Updated user
|
||||||
*/
|
*/
|
||||||
export async function updateBalance(
|
export async function updateBalance(
|
||||||
id: number,
|
id: number,
|
||||||
balance: number,
|
balance: number,
|
||||||
operation: 'set' | 'add' | 'subtract' = 'set'
|
operation: 'set' | 'add' | 'subtract' = 'set',
|
||||||
|
notes?: string
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
const { data } = await apiClient.post<User>(`/admin/users/${id}/balance`, {
|
const { data } = await apiClient.post<User>(`/admin/users/${id}/balance`, {
|
||||||
balance,
|
balance,
|
||||||
operation,
|
operation,
|
||||||
|
notes: notes || '',
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +66,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Anthropic Setup Token accounts: show time-based window progress -->
|
<!-- OpenAI OAuth accounts: show Codex usage from extra field -->
|
||||||
<template v-else-if="account.platform === 'anthropic' && account.type === 'setup-token'">
|
<template v-else-if="account.platform === 'openai' && account.type === 'oauth'">
|
||||||
<SetupTokenTimeWindow :account="account" />
|
<div v-if="hasCodexUsage" class="space-y-1">
|
||||||
|
<!-- 5h Window (Secondary) -->
|
||||||
|
<UsageProgressBar
|
||||||
|
v-if="codexSecondaryUsedPercent !== null"
|
||||||
|
label="5h"
|
||||||
|
:utilization="codexSecondaryUsedPercent"
|
||||||
|
:resets-at="codexSecondaryResetAt"
|
||||||
|
color="indigo"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Weekly Window (Primary) -->
|
||||||
|
<UsageProgressBar
|
||||||
|
v-if="codexPrimaryUsedPercent !== null"
|
||||||
|
label="7d"
|
||||||
|
:utilization="codexPrimaryUsedPercent"
|
||||||
|
:resets-at="codexPrimaryResetAt"
|
||||||
|
color="emerald"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-xs text-gray-400">-</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- OpenAI accounts: no usage window API, show dash -->
|
<!-- Other accounts: no usage window -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="text-xs text-gray-400">-</div>
|
<div class="text-xs text-gray-400">-</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -85,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
|
||||||
@@ -100,10 +121,46 @@ const showUsageWindows = computed(() =>
|
|||||||
props.account.type === 'oauth' || props.account.type === 'setup-token'
|
props.account.type === 'oauth' || props.account.type === 'setup-token'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// OpenAI Codex usage computed properties
|
||||||
|
const hasCodexUsage = computed(() => {
|
||||||
|
const extra = props.account.extra
|
||||||
|
return extra && (
|
||||||
|
extra.codex_primary_used_percent !== undefined ||
|
||||||
|
extra.codex_secondary_used_percent !== undefined
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const codexPrimaryUsedPercent = computed(() => {
|
||||||
|
const extra = props.account.extra
|
||||||
|
if (!extra || extra.codex_primary_used_percent === undefined) return null
|
||||||
|
return extra.codex_primary_used_percent
|
||||||
|
})
|
||||||
|
|
||||||
|
const codexSecondaryUsedPercent = computed(() => {
|
||||||
|
const extra = props.account.extra
|
||||||
|
if (!extra || extra.codex_secondary_used_percent === undefined) return null
|
||||||
|
return extra.codex_secondary_used_percent
|
||||||
|
})
|
||||||
|
|
||||||
|
const codexPrimaryResetAt = computed(() => {
|
||||||
|
const extra = props.account.extra
|
||||||
|
if (!extra || extra.codex_primary_reset_after_seconds === undefined) return null
|
||||||
|
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
||||||
|
return resetTime.toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
const codexSecondaryResetAt = computed(() => {
|
||||||
|
const extra = props.account.extra
|
||||||
|
if (!extra || extra.codex_secondary_reset_after_seconds === undefined) return null
|
||||||
|
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
||||||
|
return resetTime.toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
const loadUsage = async () => {
|
const loadUsage = async () => {
|
||||||
// Only fetch usage for Anthropic OAuth accounts
|
// Fetch usage for Anthropic OAuth and Setup Token accounts
|
||||||
// OpenAI doesn't have a usage window API - usage is updated from response headers 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
|
||||||
|
|||||||
@@ -168,7 +168,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
|
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">ChatGPT Plus</span>
|
<span class="text-xs text-gray-500 dark:text-gray-400">ChatGPT OAuth</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<transition name="dropdown">
|
<transition name="dropdown">
|
||||||
<div
|
<div
|
||||||
v-if="tooltipOpen"
|
v-if="tooltipOpen"
|
||||||
class="absolute right-0 mt-2 w-80 bg-white dark:bg-dark-800 rounded-xl shadow-xl border border-gray-200 dark:border-dark-700 z-50"
|
class="absolute right-0 mt-2 w-[340px] bg-white dark:bg-dark-800 rounded-xl shadow-xl border border-gray-200 dark:border-dark-700 z-50 overflow-hidden"
|
||||||
>
|
>
|
||||||
<div class="p-3 border-b border-gray-100 dark:border-dark-700">
|
<div class="p-3 border-b border-gray-100 dark:border-dark-700">
|
||||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
@@ -62,43 +62,43 @@
|
|||||||
<!-- Progress bars -->
|
<!-- Progress bars -->
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<div v-if="subscription.group?.daily_limit_usd" class="flex items-center gap-2">
|
<div v-if="subscription.group?.daily_limit_usd" class="flex items-center gap-2">
|
||||||
<span class="text-[10px] text-gray-500 w-8">{{ t('subscriptionProgress.daily') }}</span>
|
<span class="text-[10px] text-gray-500 w-8 flex-shrink-0">{{ t('subscriptionProgress.daily') }}</span>
|
||||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||||
<div
|
<div
|
||||||
class="h-1.5 rounded-full transition-all"
|
class="h-1.5 rounded-full transition-all"
|
||||||
:class="getProgressBarClass(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)"
|
:class="getProgressBarClass(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)"
|
||||||
:style="{ width: getProgressWidth(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }"
|
:style="{ width: getProgressWidth(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[10px] text-gray-500 w-16 text-right">
|
<span class="text-[10px] text-gray-500 w-24 text-right flex-shrink-0">
|
||||||
{{ formatUsage(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }}
|
{{ formatUsage(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="subscription.group?.weekly_limit_usd" class="flex items-center gap-2">
|
<div v-if="subscription.group?.weekly_limit_usd" class="flex items-center gap-2">
|
||||||
<span class="text-[10px] text-gray-500 w-8">{{ t('subscriptionProgress.weekly') }}</span>
|
<span class="text-[10px] text-gray-500 w-8 flex-shrink-0">{{ t('subscriptionProgress.weekly') }}</span>
|
||||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||||
<div
|
<div
|
||||||
class="h-1.5 rounded-full transition-all"
|
class="h-1.5 rounded-full transition-all"
|
||||||
:class="getProgressBarClass(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)"
|
:class="getProgressBarClass(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)"
|
||||||
:style="{ width: getProgressWidth(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }"
|
:style="{ width: getProgressWidth(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[10px] text-gray-500 w-16 text-right">
|
<span class="text-[10px] text-gray-500 w-24 text-right flex-shrink-0">
|
||||||
{{ formatUsage(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }}
|
{{ formatUsage(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="subscription.group?.monthly_limit_usd" class="flex items-center gap-2">
|
<div v-if="subscription.group?.monthly_limit_usd" class="flex items-center gap-2">
|
||||||
<span class="text-[10px] text-gray-500 w-8">{{ t('subscriptionProgress.monthly') }}</span>
|
<span class="text-[10px] text-gray-500 w-8 flex-shrink-0">{{ t('subscriptionProgress.monthly') }}</span>
|
||||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||||
<div
|
<div
|
||||||
class="h-1.5 rounded-full transition-all"
|
class="h-1.5 rounded-full transition-all"
|
||||||
:class="getProgressBarClass(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd)"
|
:class="getProgressBarClass(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd)"
|
||||||
:style="{ width: getProgressWidth(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }"
|
:style="{ width: getProgressWidth(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[10px] text-gray-500 w-16 text-right">
|
<span class="text-[10px] text-gray-500 w-24 text-right flex-shrink-0">
|
||||||
{{ formatUsage(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }}
|
{{ formatUsage(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -520,6 +522,26 @@ export default {
|
|||||||
allowedGroupsUpdated: 'Allowed groups updated successfully',
|
allowedGroupsUpdated: 'Allowed groups updated successfully',
|
||||||
failedToLoadGroups: 'Failed to load groups',
|
failedToLoadGroups: 'Failed to load groups',
|
||||||
failedToUpdateAllowedGroups: 'Failed to update allowed groups',
|
failedToUpdateAllowedGroups: 'Failed to update allowed groups',
|
||||||
|
deposit: 'Deposit',
|
||||||
|
withdraw: 'Withdraw',
|
||||||
|
depositAmount: 'Deposit Amount',
|
||||||
|
withdrawAmount: 'Withdraw Amount',
|
||||||
|
currentBalance: 'Current Balance',
|
||||||
|
depositNotesPlaceholder: 'e.g., New user registration bonus, promotional credit, compensation, etc.',
|
||||||
|
withdrawNotesPlaceholder: 'e.g., Service issue refund, incorrect charge reversal, account closure refund, etc.',
|
||||||
|
notesOptional: 'Notes are optional but helpful for record keeping',
|
||||||
|
amountHint: 'Please enter a positive amount',
|
||||||
|
newBalance: 'New Balance',
|
||||||
|
depositing: 'Depositing...',
|
||||||
|
withdrawing: 'Withdrawing...',
|
||||||
|
confirmDeposit: 'Confirm Deposit',
|
||||||
|
confirmWithdraw: 'Confirm Withdraw',
|
||||||
|
depositSuccess: 'Deposit successful',
|
||||||
|
withdrawSuccess: 'Withdraw successful',
|
||||||
|
failedToDeposit: 'Failed to deposit',
|
||||||
|
failedToWithdraw: 'Failed to withdraw',
|
||||||
|
useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance',
|
||||||
|
insufficientBalance: 'Insufficient balance, balance cannot be negative after withdrawal',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Groups
|
// Groups
|
||||||
@@ -680,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",
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default {
|
|||||||
groups: '分组管理',
|
groups: '分组管理',
|
||||||
subscriptions: '订阅管理',
|
subscriptions: '订阅管理',
|
||||||
accounts: '账号管理',
|
accounts: '账号管理',
|
||||||
proxies: '代理管理',
|
proxies: 'IP管理',
|
||||||
redeemCodes: '兑换码',
|
redeemCodes: '兑换码',
|
||||||
settings: '系统设置',
|
settings: '系统设置',
|
||||||
myAccount: '我的账户',
|
myAccount: '我的账户',
|
||||||
@@ -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: '时间范围',
|
||||||
@@ -587,6 +589,25 @@ export default {
|
|||||||
allowedGroupsUpdated: '允许分组更新成功',
|
allowedGroupsUpdated: '允许分组更新成功',
|
||||||
failedToLoadGroups: '加载分组列表失败',
|
failedToLoadGroups: '加载分组列表失败',
|
||||||
failedToUpdateAllowedGroups: '更新允许分组失败',
|
failedToUpdateAllowedGroups: '更新允许分组失败',
|
||||||
|
deposit: '充值',
|
||||||
|
withdraw: '退款',
|
||||||
|
depositAmount: '充值金额',
|
||||||
|
withdrawAmount: '退款金额',
|
||||||
|
depositNotesPlaceholder: '例如:新用户注册奖励、活动充值、补偿充值等',
|
||||||
|
withdrawNotesPlaceholder: '例如:服务问题退款、错误充值退回、账户注销退款等',
|
||||||
|
notesOptional: '备注为可选项,有助于未来查账',
|
||||||
|
amountHint: '请输入正数金额',
|
||||||
|
newBalance: '操作后余额',
|
||||||
|
depositing: '充值中...',
|
||||||
|
withdrawing: '退款中...',
|
||||||
|
confirmDeposit: '确认充值',
|
||||||
|
confirmWithdraw: '确认退款',
|
||||||
|
depositSuccess: '充值成功',
|
||||||
|
withdrawSuccess: '退款成功',
|
||||||
|
failedToDeposit: '充值失败',
|
||||||
|
failedToWithdraw: '退款失败',
|
||||||
|
useDepositWithdrawButtons: '请使用充值/退款按钮调整余额',
|
||||||
|
insufficientBalance: '余额不足,退款后余额不能为负数',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Groups Management
|
// Groups Management
|
||||||
@@ -775,8 +796,10 @@ export default {
|
|||||||
failedToToggleSchedulable: '切换调度状态失败',
|
failedToToggleSchedulable: '切换调度状态失败',
|
||||||
columns: {
|
columns: {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
|
platformType: '平台/类型',
|
||||||
platform: '平台',
|
platform: '平台',
|
||||||
type: '类型',
|
type: '类型',
|
||||||
|
concurrencyStatus: '并发',
|
||||||
priority: '优先级',
|
priority: '优先级',
|
||||||
weight: '权重',
|
weight: '权重',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
@@ -1015,7 +1038,7 @@ export default {
|
|||||||
|
|
||||||
// Proxies Management
|
// Proxies Management
|
||||||
proxies: {
|
proxies: {
|
||||||
title: '代理管理',
|
title: 'IP管理',
|
||||||
description: '管理代理服务器配置',
|
description: '管理代理服务器配置',
|
||||||
createProxy: '添加代理',
|
createProxy: '添加代理',
|
||||||
editProxy: '编辑代理',
|
editProxy: '编辑代理',
|
||||||
|
|||||||
@@ -316,8 +316,10 @@ export interface Account {
|
|||||||
platform: AccountPlatform;
|
platform: AccountPlatform;
|
||||||
type: AccountType;
|
type: AccountType;
|
||||||
credentials?: Record<string, unknown>;
|
credentials?: Record<string, unknown>;
|
||||||
|
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;
|
||||||
@@ -361,6 +363,18 @@ export interface AccountUsageInfo {
|
|||||||
seven_day_sonnet: UsageProgress | null;
|
seven_day_sonnet: UsageProgress | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenAI Codex usage snapshot (from response headers)
|
||||||
|
export interface CodexUsageSnapshot {
|
||||||
|
codex_primary_used_percent?: number; // Weekly limit usage percentage
|
||||||
|
codex_primary_reset_after_seconds?: number; // Seconds until weekly reset
|
||||||
|
codex_primary_window_minutes?: number; // Weekly window in minutes
|
||||||
|
codex_secondary_used_percent?: number; // 5h limit usage percentage
|
||||||
|
codex_secondary_reset_after_seconds?: number; // Seconds until 5h reset
|
||||||
|
codex_secondary_window_minutes?: number; // 5h window in minutes
|
||||||
|
codex_primary_over_secondary_percent?: number; // Overflow ratio
|
||||||
|
codex_usage_updated_at?: string; // Last update timestamp
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateAccountRequest {
|
export interface CreateAccountRequest {
|
||||||
name: string;
|
name: string;
|
||||||
platform: AccountPlatform;
|
platform: AccountPlatform;
|
||||||
@@ -504,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,27 +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 }">
|
||||||
<div class="flex items-center gap-2">
|
<PlatformTypeBadge :platform="row.platform" :type="row.type" />
|
||||||
<span
|
|
||||||
:class="[
|
|
||||||
'w-2 h-2 rounded-full',
|
|
||||||
value === 'anthropic' ? 'bg-orange-500' : value === 'openai' ? 'bg-green-500' : 'bg-gray-400'
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300 capitalize">{{ value === 'anthropic' ? 'Anthropic' : value === 'openai' ? 'OpenAI' : value }}</span>
|
|
||||||
</div>
|
|
||||||
</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 }">
|
||||||
@@ -334,6 +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 PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||||
import { formatRelativeTime } from '@/utils/format'
|
import { formatRelativeTime } from '@/utils/format'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -342,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) {
|
||||||
|
|||||||
@@ -60,9 +60,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-group="{ row }">
|
<template #cell-group="{ row }">
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">
|
<GroupBadge
|
||||||
{{ row.group?.name || `Group #${row.group_id}` }}
|
v-if="row.group"
|
||||||
</span>
|
:name="row.group.name"
|
||||||
|
:platform="row.group.platform"
|
||||||
|
:subscription-type="row.group.subscription_type"
|
||||||
|
:rate-multiplier="row.group.rate_multiplier"
|
||||||
|
:show-rate="false"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-usage="{ row }">
|
<template #cell-usage="{ row }">
|
||||||
@@ -361,6 +367,7 @@ import Modal from '@/components/common/Modal.vue'
|
|||||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
|
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|||||||
@@ -207,6 +207,26 @@
|
|||||||
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Deposit -->
|
||||||
|
<button
|
||||||
|
@click="handleDeposit(row)"
|
||||||
|
class="p-2 rounded-lg hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-gray-500 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors"
|
||||||
|
:title="t('admin.users.deposit')"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Withdraw -->
|
||||||
|
<button
|
||||||
|
@click="handleWithdraw(row)"
|
||||||
|
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||||
|
:title="t('admin.users.withdraw')"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<!-- Edit -->
|
<!-- Edit -->
|
||||||
<button
|
<button
|
||||||
@click="handleEdit(row)"
|
@click="handleEdit(row)"
|
||||||
@@ -476,24 +496,13 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
<p class="input-hint">{{ t('admin.users.notesHint') }}</p>
|
<p class="input-hint">{{ t('admin.users.notesHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div>
|
||||||
<div>
|
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
|
||||||
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
|
<input
|
||||||
<input
|
v-model.number="editForm.concurrency"
|
||||||
v-model.number="editForm.balance"
|
type="number"
|
||||||
type="number"
|
class="input"
|
||||||
step="any"
|
|
||||||
class="input"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
|
|
||||||
<input
|
|
||||||
v-model.number="editForm.concurrency"
|
|
||||||
type="number"
|
|
||||||
class="input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-4">
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
@@ -729,6 +738,114 @@
|
|||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Deposit/Withdraw Modal -->
|
||||||
|
<Modal
|
||||||
|
:show="showBalanceModal"
|
||||||
|
:title="balanceOperation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')"
|
||||||
|
size="md"
|
||||||
|
@close="closeBalanceModal"
|
||||||
|
>
|
||||||
|
<form v-if="balanceUser" @submit.prevent="handleBalanceSubmit" class="space-y-5">
|
||||||
|
<div class="flex items-center gap-3 p-4 rounded-xl bg-gray-50 dark:bg-dark-700">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
|
||||||
|
<span class="text-lg font-medium text-primary-700 dark:text-primary-300">
|
||||||
|
{{ balanceUser.email.charAt(0).toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white">{{ balanceUser.email }}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('admin.users.currentBalance') }}: ${{ balanceUser.balance.toFixed(2) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label">
|
||||||
|
{{ balanceOperation === 'add' ? t('admin.users.depositAmount') : t('admin.users.withdrawAmount') }}
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-dark-400 font-medium">
|
||||||
|
$
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model.number="balanceForm.amount"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
required
|
||||||
|
class="input pl-8"
|
||||||
|
:placeholder="balanceOperation === 'add' ? '10.00' : '5.00'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="input-hint">
|
||||||
|
{{ t('admin.users.amountHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.users.notes') }}</label>
|
||||||
|
<textarea
|
||||||
|
v-model="balanceForm.notes"
|
||||||
|
rows="3"
|
||||||
|
class="input"
|
||||||
|
:placeholder="balanceOperation === 'add'
|
||||||
|
? t('admin.users.depositNotesPlaceholder')
|
||||||
|
: t('admin.users.withdrawNotesPlaceholder')"
|
||||||
|
></textarea>
|
||||||
|
<p class="input-hint">{{ t('admin.users.notesOptional') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="balanceForm.amount > 0" class="p-4 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-blue-700 dark:text-blue-300">{{ t('admin.users.newBalance') }}:</span>
|
||||||
|
<span class="font-bold text-blue-900 dark:text-blue-100">
|
||||||
|
${{ calculateNewBalance().toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="balanceOperation === 'subtract' && calculateNewBalance() < 0" class="p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-red-700 dark:text-red-300">
|
||||||
|
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ t('admin.users.insufficientBalance') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
@click="closeBalanceModal"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="balanceSubmitting || !balanceForm.amount || balanceForm.amount <= 0 || (balanceOperation === 'subtract' && calculateNewBalance() < 0)"
|
||||||
|
class="btn"
|
||||||
|
:class="balanceOperation === 'add' ? 'bg-emerald-600 hover:bg-emerald-700 text-white' : 'btn-danger'"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="balanceSubmitting"
|
||||||
|
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ balanceSubmitting
|
||||||
|
? (balanceOperation === 'add' ? t('admin.users.depositing') : t('admin.users.withdrawing'))
|
||||||
|
: (balanceOperation === 'add' ? t('admin.users.confirmDeposit') : t('admin.users.confirmWithdraw'))
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<!-- Delete Confirmation Dialog -->
|
<!-- Delete Confirmation Dialog -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:show="showDeleteDialog"
|
:show="showDeleteDialog"
|
||||||
@@ -828,6 +945,16 @@ const selectedGroupIds = ref<number[]>([])
|
|||||||
const loadingGroups = ref(false)
|
const loadingGroups = ref(false)
|
||||||
const savingAllowedGroups = ref(false)
|
const savingAllowedGroups = ref(false)
|
||||||
|
|
||||||
|
// Balance (Deposit/Withdraw) modal state
|
||||||
|
const showBalanceModal = ref(false)
|
||||||
|
const balanceUser = ref<User | null>(null)
|
||||||
|
const balanceOperation = ref<'add' | 'subtract'>('add')
|
||||||
|
const balanceSubmitting = ref(false)
|
||||||
|
const balanceForm = reactive({
|
||||||
|
amount: 0,
|
||||||
|
notes: ''
|
||||||
|
})
|
||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
@@ -844,7 +971,6 @@ const editForm = reactive({
|
|||||||
username: '',
|
username: '',
|
||||||
wechat: '',
|
wechat: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
balance: 0,
|
|
||||||
concurrency: 1
|
concurrency: 1
|
||||||
})
|
})
|
||||||
const editPasswordCopied = ref(false)
|
const editPasswordCopied = ref(false)
|
||||||
@@ -997,7 +1123,6 @@ const handleEdit = (user: User) => {
|
|||||||
editForm.username = user.username || ''
|
editForm.username = user.username || ''
|
||||||
editForm.wechat = user.wechat || ''
|
editForm.wechat = user.wechat || ''
|
||||||
editForm.notes = user.notes || ''
|
editForm.notes = user.notes || ''
|
||||||
editForm.balance = user.balance
|
|
||||||
editForm.concurrency = user.concurrency
|
editForm.concurrency = user.concurrency
|
||||||
editPasswordCopied.value = false
|
editPasswordCopied.value = false
|
||||||
showEditModal.value = true
|
showEditModal.value = true
|
||||||
@@ -1015,13 +1140,11 @@ const handleUpdateUser = async () => {
|
|||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
// Build update data - only include password if not empty
|
|
||||||
const updateData: Record<string, any> = {
|
const updateData: Record<string, any> = {
|
||||||
email: editForm.email,
|
email: editForm.email,
|
||||||
username: editForm.username,
|
username: editForm.username,
|
||||||
wechat: editForm.wechat,
|
wechat: editForm.wechat,
|
||||||
notes: editForm.notes,
|
notes: editForm.notes,
|
||||||
balance: editForm.balance,
|
|
||||||
concurrency: editForm.concurrency
|
concurrency: editForm.concurrency
|
||||||
}
|
}
|
||||||
if (editForm.password.trim()) {
|
if (editForm.password.trim()) {
|
||||||
@@ -1141,6 +1264,69 @@ const confirmDelete = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeposit = (user: User) => {
|
||||||
|
balanceUser.value = user
|
||||||
|
balanceOperation.value = 'add'
|
||||||
|
balanceForm.amount = 0
|
||||||
|
balanceForm.notes = ''
|
||||||
|
showBalanceModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWithdraw = (user: User) => {
|
||||||
|
balanceUser.value = user
|
||||||
|
balanceOperation.value = 'subtract'
|
||||||
|
balanceForm.amount = 0
|
||||||
|
balanceForm.notes = ''
|
||||||
|
showBalanceModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeBalanceModal = () => {
|
||||||
|
showBalanceModal.value = false
|
||||||
|
balanceUser.value = null
|
||||||
|
balanceForm.amount = 0
|
||||||
|
balanceForm.notes = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateNewBalance = () => {
|
||||||
|
if (!balanceUser.value) return 0
|
||||||
|
if (balanceOperation.value === 'add') {
|
||||||
|
return balanceUser.value.balance + balanceForm.amount
|
||||||
|
} else {
|
||||||
|
return balanceUser.value.balance - balanceForm.amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBalanceSubmit = async () => {
|
||||||
|
if (!balanceUser.value || balanceForm.amount <= 0) return
|
||||||
|
|
||||||
|
balanceSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await adminAPI.users.updateBalance(
|
||||||
|
balanceUser.value.id,
|
||||||
|
balanceForm.amount,
|
||||||
|
balanceOperation.value,
|
||||||
|
balanceForm.notes
|
||||||
|
)
|
||||||
|
|
||||||
|
const successMsg = balanceOperation.value === 'add'
|
||||||
|
? t('admin.users.depositSuccess')
|
||||||
|
: t('admin.users.withdrawSuccess')
|
||||||
|
|
||||||
|
appStore.showSuccess(successMsg)
|
||||||
|
closeBalanceModal()
|
||||||
|
loadUsers()
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMsg = balanceOperation.value === 'add'
|
||||||
|
? t('admin.users.failedToDeposit')
|
||||||
|
: t('admin.users.failedToWithdraw')
|
||||||
|
|
||||||
|
appStore.showError(error.response?.data?.detail || errorMsg)
|
||||||
|
console.error('Error updating balance:', error)
|
||||||
|
} finally {
|
||||||
|
balanceSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadUsers()
|
loadUsers()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/groups.ts","./src/api/index.ts","./src/api/keys.ts","./src/api/redeem.ts","./src/api/setup.ts","./src/api/subscriptions.ts","./src/api/usage.ts","./src/api/user.ts","./src/api/admin/accounts.ts","./src/api/admin/dashboard.ts","./src/api/admin/groups.ts","./src/api/admin/index.ts","./src/api/admin/proxies.ts","./src/api/admin/redeem.ts","./src/api/admin/settings.ts","./src/api/admin/subscriptions.ts","./src/api/admin/system.ts","./src/api/admin/usage.ts","./src/api/admin/users.ts","./src/components/account/index.ts","./src/components/common/index.ts","./src/components/common/types.ts","./src/components/layout/index.ts","./src/composables/useAccountOAuth.ts","./src/composables/useClipboard.ts","./src/composables/useOpenAIOAuth.ts","./src/i18n/index.ts","./src/i18n/locales/en.ts","./src/i18n/locales/zh.ts","./src/router/index.ts","./src/router/meta.d.ts","./src/stores/app.ts","./src/stores/auth.ts","./src/stores/index.ts","./src/types/index.ts","./src/utils/format.ts","./src/views/auth/index.ts","./src/App.vue","./src/components/TurnstileWidget.vue","./src/components/account/AccountStatusIndicator.vue","./src/components/account/AccountTestModal.vue","./src/components/account/AccountTodayStatsCell.vue","./src/components/account/AccountUsageCell.vue","./src/components/account/CreateAccountModal.vue","./src/components/account/EditAccountModal.vue","./src/components/account/OAuthAuthorizationFlow.vue","./src/components/account/ReAuthAccountModal.vue","./src/components/account/SetupTokenTimeWindow.vue","./src/components/account/UsageProgressBar.vue","./src/components/charts/ModelDistributionChart.vue","./src/components/charts/TokenUsageTrend.vue","./src/components/common/ConfirmDialog.vue","./src/components/common/DataTable.vue","./src/components/common/DateRangePicker.vue","./src/components/common/EmptyState.vue","./src/components/common/GroupBadge.vue","./src/components/common/GroupSelector.vue","./src/components/common/LoadingSpinner.vue","./src/components/common/LocaleSwitcher.vue","./src/components/common/Modal.vue","./src/components/common/Pagination.vue","./src/components/common/PlatformIcon.vue","./src/components/common/ProxySelector.vue","./src/components/common/Select.vue","./src/components/common/StatCard.vue","./src/components/common/SubscriptionProgressMini.vue","./src/components/common/Toast.vue","./src/components/common/Toggle.vue","./src/components/common/VersionBadge.vue","./src/components/keys/UseKeyModal.vue","./src/components/layout/AppHeader.vue","./src/components/layout/AppLayout.vue","./src/components/layout/AppSidebar.vue","./src/components/layout/AuthLayout.vue","./src/views/HomeView.vue","./src/views/NotFoundView.vue","./src/views/admin/AccountsView.vue","./src/views/admin/DashboardView.vue","./src/views/admin/GroupsView.vue","./src/views/admin/ProxiesView.vue","./src/views/admin/RedeemView.vue","./src/views/admin/SettingsView.vue","./src/views/admin/SubscriptionsView.vue","./src/views/admin/UsageView.vue","./src/views/admin/UsersView.vue","./src/views/auth/EmailVerifyView.vue","./src/views/auth/LoginView.vue","./src/views/auth/RegisterView.vue","./src/views/setup/SetupWizardView.vue","./src/views/user/DashboardView.vue","./src/views/user/KeysView.vue","./src/views/user/ProfileView.vue","./src/views/user/RedeemView.vue","./src/views/user/SubscriptionsView.vue","./src/views/user/UsageView.vue"],"version":"5.6.3"}
|
|
||||||
Reference in New Issue
Block a user