mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-06 00:10:21 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5831eb8a6a | ||
|
|
61838cdb3d | ||
|
|
50dba656fd | ||
|
|
0e2821456c | ||
|
|
f25ac3aff5 | ||
|
|
f6341b7f2b | ||
|
|
4e257512b9 | ||
|
|
e53b34f321 | ||
|
|
12ddae0184 | ||
|
|
7b9c3f165e | ||
|
|
0b8e84f942 | ||
|
|
d9e27df9af | ||
|
|
f0fabf89a1 | ||
|
|
5bbfbcdae9 | ||
|
|
eb55947ec4 | ||
|
|
5f7e5184eb | ||
|
|
008a111268 | ||
|
|
fda753278c | ||
|
|
6c469b42ed |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,6 +28,7 @@ node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
*.local
|
||||
*.tsbuildinfo
|
||||
|
||||
# 日志
|
||||
npm-debug.log*
|
||||
|
||||
@@ -85,6 +85,14 @@ func provideCleanup(
|
||||
services.EmailQueue.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"OAuthService", func() error {
|
||||
services.OAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"OpenAIOAuthService", func() error {
|
||||
services.OpenAIOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"Redis", func() error {
|
||||
return rdb.Close()
|
||||
}},
|
||||
|
||||
@@ -76,13 +76,16 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
groupHandler := admin.NewGroupHandler(adminService)
|
||||
claudeOAuthClient := repository.NewClaudeOAuthClient()
|
||||
oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient)
|
||||
openAIOAuthClient := repository.NewOpenAIOAuthClient()
|
||||
openAIOAuthService := service.NewOpenAIOAuthService(proxyRepository, openAIOAuthClient)
|
||||
rateLimitService := service.NewRateLimitService(accountRepository, configConfig)
|
||||
claudeUsageFetcher := repository.NewClaudeUsageFetcher()
|
||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher)
|
||||
claudeUpstream := repository.NewClaudeUpstream(configConfig)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, claudeUpstream)
|
||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, rateLimitService, accountUsageService, accountTestService)
|
||||
httpUpstream := repository.NewHTTPUpstream(configConfig)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream)
|
||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, usageLogRepository)
|
||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
||||
proxyHandler := admin.NewProxyHandler(adminService)
|
||||
adminRedeemHandler := admin.NewRedeemHandler(adminService)
|
||||
settingHandler := admin.NewSettingHandler(settingService, emailService)
|
||||
@@ -93,7 +96,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
systemHandler := handler.ProvideSystemHandler(updateService)
|
||||
adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService)
|
||||
adminUsageHandler := admin.NewUsageHandler(usageLogRepository, apiKeyRepository, usageService, adminService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, 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)
|
||||
pricingRemoteClient := repository.NewPricingRemoteClient()
|
||||
pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient)
|
||||
@@ -103,43 +106,47 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
billingService := service.NewBillingService(configConfig, pricingService)
|
||||
identityCache := repository.NewIdentityCache(client)
|
||||
identityService := service.NewIdentityService(identityCache)
|
||||
gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, claudeUpstream)
|
||||
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)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream)
|
||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)
|
||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, handlerSettingHandler)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler)
|
||||
groupService := service.NewGroupService(groupRepository)
|
||||
accountService := service.NewAccountService(accountRepository, groupRepository)
|
||||
proxyService := service.NewProxyService(proxyRepository)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, configConfig)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, configConfig)
|
||||
services := &service.Services{
|
||||
Auth: authService,
|
||||
User: userService,
|
||||
ApiKey: apiKeyService,
|
||||
Group: groupService,
|
||||
Account: accountService,
|
||||
Proxy: proxyService,
|
||||
Redeem: redeemService,
|
||||
Usage: usageService,
|
||||
Pricing: pricingService,
|
||||
Billing: billingService,
|
||||
BillingCache: billingCacheService,
|
||||
Admin: adminService,
|
||||
Gateway: gatewayService,
|
||||
OAuth: oAuthService,
|
||||
RateLimit: rateLimitService,
|
||||
AccountUsage: accountUsageService,
|
||||
AccountTest: accountTestService,
|
||||
Setting: settingService,
|
||||
Email: emailService,
|
||||
EmailQueue: emailQueueService,
|
||||
Turnstile: turnstileService,
|
||||
Subscription: subscriptionService,
|
||||
Concurrency: concurrencyService,
|
||||
Identity: identityService,
|
||||
Update: updateService,
|
||||
TokenRefresh: tokenRefreshService,
|
||||
Auth: authService,
|
||||
User: userService,
|
||||
ApiKey: apiKeyService,
|
||||
Group: groupService,
|
||||
Account: accountService,
|
||||
Proxy: proxyService,
|
||||
Redeem: redeemService,
|
||||
Usage: usageService,
|
||||
Pricing: pricingService,
|
||||
Billing: billingService,
|
||||
BillingCache: billingCacheService,
|
||||
Admin: adminService,
|
||||
Gateway: gatewayService,
|
||||
OpenAIGateway: openAIGatewayService,
|
||||
OAuth: oAuthService,
|
||||
OpenAIOAuth: openAIOAuthService,
|
||||
RateLimit: rateLimitService,
|
||||
AccountUsage: accountUsageService,
|
||||
AccountTest: accountTestService,
|
||||
Setting: settingService,
|
||||
Email: emailService,
|
||||
EmailQueue: emailQueueService,
|
||||
Turnstile: turnstileService,
|
||||
Subscription: subscriptionService,
|
||||
Concurrency: concurrencyService,
|
||||
Identity: identityService,
|
||||
Update: updateService,
|
||||
TokenRefresh: tokenRefreshService,
|
||||
}
|
||||
repositories := &repository.Repositories{
|
||||
User: userRepository,
|
||||
@@ -201,6 +208,14 @@ func provideCleanup(
|
||||
services.EmailQueue.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"OAuthService", func() error {
|
||||
services.OAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"OpenAIOAuthService", func() error {
|
||||
services.OpenAIOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"Redis", func() error {
|
||||
return rdb.Close()
|
||||
}},
|
||||
|
||||
@@ -4,7 +4,10 @@ import (
|
||||
"strconv"
|
||||
|
||||
"sub2api/internal/pkg/claude"
|
||||
"sub2api/internal/pkg/openai"
|
||||
"sub2api/internal/pkg/response"
|
||||
"sub2api/internal/pkg/timezone"
|
||||
"sub2api/internal/repository"
|
||||
"sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -26,19 +29,23 @@ func NewOAuthHandler(oauthService *service.OAuthService) *OAuthHandler {
|
||||
type AccountHandler struct {
|
||||
adminService service.AdminService
|
||||
oauthService *service.OAuthService
|
||||
openaiOAuthService *service.OpenAIOAuthService
|
||||
rateLimitService *service.RateLimitService
|
||||
accountUsageService *service.AccountUsageService
|
||||
accountTestService *service.AccountTestService
|
||||
usageLogRepo *repository.UsageLogRepository
|
||||
}
|
||||
|
||||
// NewAccountHandler creates a new admin account handler
|
||||
func NewAccountHandler(adminService service.AdminService, oauthService *service.OAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService) *AccountHandler {
|
||||
func NewAccountHandler(adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService, usageLogRepo *repository.UsageLogRepository) *AccountHandler {
|
||||
return &AccountHandler{
|
||||
adminService: adminService,
|
||||
oauthService: oauthService,
|
||||
openaiOAuthService: openaiOAuthService,
|
||||
rateLimitService: rateLimitService,
|
||||
accountUsageService: accountUsageService,
|
||||
accountTestService: accountTestService,
|
||||
usageLogRepo: usageLogRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,26 +239,47 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use OAuth service to refresh token
|
||||
tokenInfo, err := h.oauthService.RefreshAccountToken(c.Request.Context(), account)
|
||||
if err != nil {
|
||||
response.InternalError(c, "Failed to refresh credentials: "+err.Error())
|
||||
return
|
||||
}
|
||||
var newCredentials map[string]any
|
||||
|
||||
// Copy existing credentials to preserve non-token settings (e.g., intercept_warmup_requests)
|
||||
newCredentials := make(map[string]any)
|
||||
for k, v := range account.Credentials {
|
||||
newCredentials[k] = v
|
||||
}
|
||||
if account.IsOpenAI() {
|
||||
// Use OpenAI OAuth service to refresh token
|
||||
tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(c.Request.Context(), account)
|
||||
if err != nil {
|
||||
response.InternalError(c, "Failed to refresh credentials: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Update token-related fields
|
||||
newCredentials["access_token"] = tokenInfo.AccessToken
|
||||
newCredentials["token_type"] = tokenInfo.TokenType
|
||||
newCredentials["expires_in"] = tokenInfo.ExpiresIn
|
||||
newCredentials["expires_at"] = tokenInfo.ExpiresAt
|
||||
newCredentials["refresh_token"] = tokenInfo.RefreshToken
|
||||
newCredentials["scope"] = tokenInfo.Scope
|
||||
// Build new credentials from token info
|
||||
newCredentials = h.openaiOAuthService.BuildAccountCredentials(tokenInfo)
|
||||
|
||||
// Preserve non-token settings from existing credentials
|
||||
for k, v := range account.Credentials {
|
||||
if _, exists := newCredentials[k]; !exists {
|
||||
newCredentials[k] = v
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use Anthropic/Claude OAuth service to refresh token
|
||||
tokenInfo, err := h.oauthService.RefreshAccountToken(c.Request.Context(), account)
|
||||
if err != nil {
|
||||
response.InternalError(c, "Failed to refresh credentials: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Copy existing credentials to preserve non-token settings (e.g., intercept_warmup_requests)
|
||||
newCredentials = make(map[string]any)
|
||||
for k, v := range account.Credentials {
|
||||
newCredentials[k] = v
|
||||
}
|
||||
|
||||
// Update token-related fields
|
||||
newCredentials["access_token"] = tokenInfo.AccessToken
|
||||
newCredentials["token_type"] = tokenInfo.TokenType
|
||||
newCredentials["expires_in"] = tokenInfo.ExpiresIn
|
||||
newCredentials["expires_at"] = tokenInfo.ExpiresAt
|
||||
newCredentials["refresh_token"] = tokenInfo.RefreshToken
|
||||
newCredentials["scope"] = tokenInfo.Scope
|
||||
}
|
||||
|
||||
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
||||
Credentials: newCredentials,
|
||||
@@ -273,15 +301,26 @@ func (h *AccountHandler) GetStats(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Return mock data for now
|
||||
_ = accountID
|
||||
response.Success(c, gin.H{
|
||||
"total_requests": 0,
|
||||
"successful_requests": 0,
|
||||
"failed_requests": 0,
|
||||
"total_tokens": 0,
|
||||
"average_response_time": 0,
|
||||
})
|
||||
// Parse days parameter (default 30)
|
||||
days := 30
|
||||
if daysStr := c.Query("days"); daysStr != "" {
|
||||
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 && d <= 90 {
|
||||
days = d
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate time range
|
||||
now := timezone.Now()
|
||||
endTime := timezone.StartOfDay(now.AddDate(0, 0, 1))
|
||||
startTime := timezone.StartOfDay(now.AddDate(0, 0, -days+1))
|
||||
|
||||
stats, err := h.usageLogRepo.GetAccountUsageStats(c.Request.Context(), accountID, startTime, endTime)
|
||||
if err != nil {
|
||||
response.InternalError(c, "Failed to get account stats: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, stats)
|
||||
}
|
||||
|
||||
// ClearError handles clearing account error
|
||||
@@ -563,6 +602,46 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle OpenAI accounts
|
||||
if account.IsOpenAI() {
|
||||
// For OAuth accounts: return default OpenAI models
|
||||
if account.IsOAuth() {
|
||||
response.Success(c, openai.DefaultModels)
|
||||
return
|
||||
}
|
||||
|
||||
// For API Key accounts: check model_mapping
|
||||
mapping := account.GetModelMapping()
|
||||
if len(mapping) == 0 {
|
||||
response.Success(c, openai.DefaultModels)
|
||||
return
|
||||
}
|
||||
|
||||
// Return mapped models
|
||||
var models []openai.Model
|
||||
for requestedModel := range mapping {
|
||||
var found bool
|
||||
for _, dm := range openai.DefaultModels {
|
||||
if dm.ID == requestedModel {
|
||||
models = append(models, dm)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
models = append(models, openai.Model{
|
||||
ID: requestedModel,
|
||||
Object: "model",
|
||||
Type: "model",
|
||||
DisplayName: requestedModel,
|
||||
})
|
||||
}
|
||||
}
|
||||
response.Success(c, models)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Claude/Anthropic accounts
|
||||
// For OAuth and Setup-Token accounts: return default models
|
||||
if account.IsOAuth() {
|
||||
response.Success(c, claude.DefaultModels)
|
||||
|
||||
@@ -175,7 +175,7 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := h.usageRepo.GetModelStatsWithFilters(c.Request.Context(), startTime, endTime, userID, apiKeyID)
|
||||
stats, err := h.usageRepo.GetModelStatsWithFilters(c.Request.Context(), startTime, endTime, userID, apiKeyID, 0)
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get model statistics")
|
||||
return
|
||||
|
||||
228
backend/internal/handler/admin/openai_oauth_handler.go
Normal file
228
backend/internal/handler/admin/openai_oauth_handler.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"sub2api/internal/pkg/response"
|
||||
"sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OpenAIOAuthHandler handles OpenAI OAuth-related operations
|
||||
type OpenAIOAuthHandler struct {
|
||||
openaiOAuthService *service.OpenAIOAuthService
|
||||
adminService service.AdminService
|
||||
}
|
||||
|
||||
// NewOpenAIOAuthHandler creates a new OpenAI OAuth handler
|
||||
func NewOpenAIOAuthHandler(openaiOAuthService *service.OpenAIOAuthService, adminService service.AdminService) *OpenAIOAuthHandler {
|
||||
return &OpenAIOAuthHandler{
|
||||
openaiOAuthService: openaiOAuthService,
|
||||
adminService: adminService,
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAIGenerateAuthURLRequest represents the request for generating OpenAI auth URL
|
||||
type OpenAIGenerateAuthURLRequest struct {
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
// GenerateAuthURL generates OpenAI OAuth authorization URL
|
||||
// POST /api/v1/admin/openai/generate-auth-url
|
||||
func (h *OpenAIOAuthHandler) GenerateAuthURL(c *gin.Context) {
|
||||
var req OpenAIGenerateAuthURLRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// Allow empty body
|
||||
req = OpenAIGenerateAuthURLRequest{}
|
||||
}
|
||||
|
||||
result, err := h.openaiOAuthService.GenerateAuthURL(c.Request.Context(), req.ProxyID, req.RedirectURI)
|
||||
if err != nil {
|
||||
response.InternalError(c, "Failed to generate auth URL: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// OpenAIExchangeCodeRequest represents the request for exchanging OpenAI auth code
|
||||
type OpenAIExchangeCodeRequest struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
}
|
||||
|
||||
// ExchangeCode exchanges OpenAI authorization code for tokens
|
||||
// POST /api/v1/admin/openai/exchange-code
|
||||
func (h *OpenAIOAuthHandler) ExchangeCode(c *gin.Context) {
|
||||
var req OpenAIExchangeCodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tokenInfo, err := h.openaiOAuthService.ExchangeCode(c.Request.Context(), &service.OpenAIExchangeCodeInput{
|
||||
SessionID: req.SessionID,
|
||||
Code: req.Code,
|
||||
RedirectURI: req.RedirectURI,
|
||||
ProxyID: req.ProxyID,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Failed to exchange code: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, tokenInfo)
|
||||
}
|
||||
|
||||
// OpenAIRefreshTokenRequest represents the request for refreshing OpenAI token
|
||||
type OpenAIRefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
}
|
||||
|
||||
// RefreshToken refreshes an OpenAI OAuth token
|
||||
// POST /api/v1/admin/openai/refresh-token
|
||||
func (h *OpenAIOAuthHandler) RefreshToken(c *gin.Context) {
|
||||
var req OpenAIRefreshTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var proxyURL string
|
||||
if req.ProxyID != nil {
|
||||
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
|
||||
if err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
tokenInfo, err := h.openaiOAuthService.RefreshToken(c.Request.Context(), req.RefreshToken, proxyURL)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Failed to refresh token: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, tokenInfo)
|
||||
}
|
||||
|
||||
// RefreshAccountToken refreshes token for a specific OpenAI account
|
||||
// POST /api/v1/admin/openai/accounts/:id/refresh
|
||||
func (h *OpenAIOAuthHandler) RefreshAccountToken(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Get account
|
||||
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.NotFound(c, "Account not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure account is OpenAI platform
|
||||
if !account.IsOpenAI() {
|
||||
response.BadRequest(c, "Account is not an OpenAI account")
|
||||
return
|
||||
}
|
||||
|
||||
// Only refresh OAuth-based accounts
|
||||
if !account.IsOAuth() {
|
||||
response.BadRequest(c, "Cannot refresh non-OAuth account credentials")
|
||||
return
|
||||
}
|
||||
|
||||
// Use OpenAI OAuth service to refresh token
|
||||
tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(c.Request.Context(), account)
|
||||
if err != nil {
|
||||
response.InternalError(c, "Failed to refresh credentials: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Build new credentials from token info
|
||||
newCredentials := h.openaiOAuthService.BuildAccountCredentials(tokenInfo)
|
||||
|
||||
// Preserve non-token settings from existing credentials
|
||||
for k, v := range account.Credentials {
|
||||
if _, exists := newCredentials[k]; !exists {
|
||||
newCredentials[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
||||
Credentials: newCredentials,
|
||||
})
|
||||
if err != nil {
|
||||
response.InternalError(c, "Failed to update account credentials: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, updatedAccount)
|
||||
}
|
||||
|
||||
// CreateAccountFromOAuth creates a new OpenAI OAuth account from token info
|
||||
// POST /api/v1/admin/openai/create-from-oauth
|
||||
func (h *OpenAIOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
|
||||
var req struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Name string `json:"name"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Priority int `json:"priority"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
tokenInfo, err := h.openaiOAuthService.ExchangeCode(c.Request.Context(), &service.OpenAIExchangeCodeInput{
|
||||
SessionID: req.SessionID,
|
||||
Code: req.Code,
|
||||
RedirectURI: req.RedirectURI,
|
||||
ProxyID: req.ProxyID,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Failed to exchange code: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Build credentials from token info
|
||||
credentials := h.openaiOAuthService.BuildAccountCredentials(tokenInfo)
|
||||
|
||||
// Use email as default name if not provided
|
||||
name := req.Name
|
||||
if name == "" && tokenInfo.Email != "" {
|
||||
name = tokenInfo.Email
|
||||
}
|
||||
if name == "" {
|
||||
name = "OpenAI OAuth Account"
|
||||
}
|
||||
|
||||
// Create account
|
||||
account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
|
||||
Name: name,
|
||||
Platform: "openai",
|
||||
Type: "oauth",
|
||||
Credentials: credentials,
|
||||
ProxyID: req.ProxyID,
|
||||
Concurrency: req.Concurrency,
|
||||
Priority: req.Priority,
|
||||
GroupIDs: req.GroupIDs,
|
||||
})
|
||||
if err != nil {
|
||||
response.InternalError(c, "Failed to create account: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, account)
|
||||
}
|
||||
@@ -25,6 +25,9 @@ func NewUserHandler(adminService service.AdminService) *UserHandler {
|
||||
type CreateUserRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Username string `json:"username"`
|
||||
Wechat string `json:"wechat"`
|
||||
Notes string `json:"notes"`
|
||||
Balance float64 `json:"balance"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
AllowedGroups []int64 `json:"allowed_groups"`
|
||||
@@ -35,6 +38,9 @@ type CreateUserRequest struct {
|
||||
type UpdateUserRequest struct {
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
Password string `json:"password" binding:"omitempty,min=6"`
|
||||
Username *string `json:"username"`
|
||||
Wechat *string `json:"wechat"`
|
||||
Notes *string `json:"notes"`
|
||||
Balance *float64 `json:"balance"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active disabled"`
|
||||
@@ -43,8 +49,9 @@ type UpdateUserRequest struct {
|
||||
|
||||
// UpdateBalanceRequest represents balance update request
|
||||
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"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
// List handles listing all users with pagination
|
||||
@@ -94,6 +101,9 @@ func (h *UserHandler) Create(c *gin.Context) {
|
||||
user, err := h.adminService.CreateUser(c.Request.Context(), &service.CreateUserInput{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Username: req.Username,
|
||||
Wechat: req.Wechat,
|
||||
Notes: req.Notes,
|
||||
Balance: req.Balance,
|
||||
Concurrency: req.Concurrency,
|
||||
AllowedGroups: req.AllowedGroups,
|
||||
@@ -125,6 +135,9 @@ func (h *UserHandler) Update(c *gin.Context) {
|
||||
user, err := h.adminService.UpdateUser(c.Request.Context(), userID, &service.UpdateUserInput{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Username: req.Username,
|
||||
Wechat: req.Wechat,
|
||||
Notes: req.Notes,
|
||||
Balance: req.Balance,
|
||||
Concurrency: req.Concurrency,
|
||||
Status: req.Status,
|
||||
@@ -171,7 +184,7 @@ func (h *UserHandler) UpdateBalance(c *gin.Context) {
|
||||
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 {
|
||||
response.InternalError(c, "Failed to update balance: "+err.Error())
|
||||
return
|
||||
|
||||
@@ -13,24 +13,18 @@ import (
|
||||
"sub2api/internal/middleware"
|
||||
"sub2api/internal/model"
|
||||
"sub2api/internal/pkg/claude"
|
||||
"sub2api/internal/pkg/openai"
|
||||
"sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
// Maximum wait time for concurrency slot
|
||||
maxConcurrencyWait = 60 * time.Second
|
||||
// Ping interval during wait
|
||||
pingInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
// GatewayHandler handles API gateway requests
|
||||
type GatewayHandler struct {
|
||||
gatewayService *service.GatewayService
|
||||
userService *service.UserService
|
||||
concurrencyService *service.ConcurrencyService
|
||||
billingCacheService *service.BillingCacheService
|
||||
concurrencyHelper *ConcurrencyHelper
|
||||
}
|
||||
|
||||
// NewGatewayHandler creates a new GatewayHandler
|
||||
@@ -38,8 +32,8 @@ func NewGatewayHandler(gatewayService *service.GatewayService, userService *serv
|
||||
return &GatewayHandler{
|
||||
gatewayService: gatewayService,
|
||||
userService: userService,
|
||||
concurrencyService: concurrencyService,
|
||||
billingCacheService: billingCacheService,
|
||||
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatClaude),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +83,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
|
||||
// 0. 检查wait队列是否已满
|
||||
maxWait := service.CalculateMaxWait(user.Concurrency)
|
||||
canWait, err := h.concurrencyService.IncrementWaitCount(c.Request.Context(), user.ID, maxWait)
|
||||
canWait, err := h.concurrencyHelper.IncrementWaitCount(c.Request.Context(), user.ID, maxWait)
|
||||
if err != nil {
|
||||
log.Printf("Increment wait count failed: %v", err)
|
||||
// On error, allow request to proceed
|
||||
@@ -98,10 +92,10 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// 确保在函数退出时减少wait计数
|
||||
defer h.concurrencyService.DecrementWaitCount(c.Request.Context(), user.ID)
|
||||
defer h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), user.ID)
|
||||
|
||||
// 1. 首先获取用户并发槽位
|
||||
userReleaseFunc, err := h.acquireUserSlotWithWait(c, user, req.Stream, &streamStarted)
|
||||
userReleaseFunc, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, user, req.Stream, &streamStarted)
|
||||
if err != nil {
|
||||
log.Printf("User concurrency acquire failed: %v", err)
|
||||
h.handleConcurrencyError(c, err, "user", streamStarted)
|
||||
@@ -139,7 +133,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 3. 获取账号并发槽位
|
||||
accountReleaseFunc, err := h.acquireAccountSlotWithWait(c, account, req.Stream, &streamStarted)
|
||||
accountReleaseFunc, err := h.concurrencyHelper.AcquireAccountSlotWithWait(c, account, req.Stream, &streamStarted)
|
||||
if err != nil {
|
||||
log.Printf("Account concurrency acquire failed: %v", err)
|
||||
h.handleConcurrencyError(c, err, "account", streamStarted)
|
||||
@@ -173,135 +167,25 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
}()
|
||||
}
|
||||
|
||||
// acquireUserSlotWithWait acquires a user concurrency slot, waiting if necessary
|
||||
// For streaming requests, sends ping events during the wait
|
||||
// streamStarted is updated if streaming response has begun
|
||||
func (h *GatewayHandler) acquireUserSlotWithWait(c *gin.Context, user *model.User, isStream bool, streamStarted *bool) (func(), error) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Try to acquire immediately
|
||||
result, err := h.concurrencyService.AcquireUserSlot(ctx, user.ID, user.Concurrency)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.Acquired {
|
||||
return result.ReleaseFunc, nil
|
||||
}
|
||||
|
||||
// Need to wait - handle streaming ping if needed
|
||||
return h.waitForSlotWithPing(c, "user", user.ID, user.Concurrency, isStream, streamStarted)
|
||||
}
|
||||
|
||||
// acquireAccountSlotWithWait acquires an account concurrency slot, waiting if necessary
|
||||
// For streaming requests, sends ping events during the wait
|
||||
// streamStarted is updated if streaming response has begun
|
||||
func (h *GatewayHandler) acquireAccountSlotWithWait(c *gin.Context, account *model.Account, isStream bool, streamStarted *bool) (func(), error) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Try to acquire immediately
|
||||
result, err := h.concurrencyService.AcquireAccountSlot(ctx, account.ID, account.Concurrency)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.Acquired {
|
||||
return result.ReleaseFunc, nil
|
||||
}
|
||||
|
||||
// Need to wait - handle streaming ping if needed
|
||||
return h.waitForSlotWithPing(c, "account", account.ID, account.Concurrency, isStream, streamStarted)
|
||||
}
|
||||
|
||||
// concurrencyError represents a concurrency limit error with context
|
||||
type concurrencyError struct {
|
||||
SlotType string
|
||||
IsTimeout bool
|
||||
}
|
||||
|
||||
func (e *concurrencyError) Error() string {
|
||||
if e.IsTimeout {
|
||||
return fmt.Sprintf("timeout waiting for %s concurrency slot", e.SlotType)
|
||||
}
|
||||
return fmt.Sprintf("%s concurrency limit reached", e.SlotType)
|
||||
}
|
||||
|
||||
// waitForSlotWithPing waits for a concurrency slot, sending ping events for streaming requests
|
||||
// Note: For streaming requests, we send ping to keep the connection alive.
|
||||
// streamStarted pointer is updated when streaming begins (for proper error handling by caller)
|
||||
func (h *GatewayHandler) waitForSlotWithPing(c *gin.Context, slotType string, id int64, maxConcurrency int, isStream bool, streamStarted *bool) (func(), error) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), maxConcurrencyWait)
|
||||
defer cancel()
|
||||
|
||||
// For streaming requests, set up SSE headers for ping
|
||||
var flusher http.Flusher
|
||||
if isStream {
|
||||
var ok bool
|
||||
flusher, ok = c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("streaming not supported")
|
||||
}
|
||||
}
|
||||
|
||||
pingTicker := time.NewTicker(pingInterval)
|
||||
defer pingTicker.Stop()
|
||||
|
||||
pollTicker := time.NewTicker(100 * time.Millisecond)
|
||||
defer pollTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, &concurrencyError{
|
||||
SlotType: slotType,
|
||||
IsTimeout: true,
|
||||
}
|
||||
|
||||
case <-pingTicker.C:
|
||||
// Send ping for streaming requests to keep connection alive
|
||||
if isStream && flusher != nil {
|
||||
// Set headers on first ping (lazy initialization)
|
||||
if !*streamStarted {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
*streamStarted = true
|
||||
}
|
||||
if _, err := fmt.Fprintf(c.Writer, "data: {\"type\": \"ping\"}\n\n"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
case <-pollTicker.C:
|
||||
// Try to acquire slot
|
||||
var result *service.AcquireResult
|
||||
var err error
|
||||
|
||||
if slotType == "user" {
|
||||
result, err = h.concurrencyService.AcquireUserSlot(ctx, id, maxConcurrency)
|
||||
} else {
|
||||
result, err = h.concurrencyService.AcquireAccountSlot(ctx, id, maxConcurrency)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.Acquired {
|
||||
return result.ReleaseFunc, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Models handles listing available models
|
||||
// GET /v1/models
|
||||
// Returns different model lists based on the API key's group platform
|
||||
func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
apiKey, _ := middleware.GetApiKeyFromContext(c)
|
||||
|
||||
// Return OpenAI models for OpenAI platform groups
|
||||
if apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform == "openai" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": openai.DefaultModels,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Default: Claude models
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": claude.DefaultModels,
|
||||
"object": "list",
|
||||
"data": claude.DefaultModels,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
180
backend/internal/handler/gateway_helper.go
Normal file
180
backend/internal/handler/gateway_helper.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"sub2api/internal/model"
|
||||
"sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxConcurrencyWait is the maximum time to wait for a concurrency slot
|
||||
maxConcurrencyWait = 30 * time.Second
|
||||
// pingInterval is the interval for sending ping events during slot wait
|
||||
pingInterval = 15 * time.Second
|
||||
)
|
||||
|
||||
// SSEPingFormat defines the format of SSE ping events for different platforms
|
||||
type SSEPingFormat string
|
||||
|
||||
const (
|
||||
// SSEPingFormatClaude is the Claude/Anthropic SSE ping format
|
||||
SSEPingFormatClaude SSEPingFormat = "data: {\"type\": \"ping\"}\n\n"
|
||||
// SSEPingFormatNone indicates no ping should be sent (e.g., OpenAI has no ping spec)
|
||||
SSEPingFormatNone SSEPingFormat = ""
|
||||
)
|
||||
|
||||
// ConcurrencyError represents a concurrency limit error with context
|
||||
type ConcurrencyError struct {
|
||||
SlotType string
|
||||
IsTimeout bool
|
||||
}
|
||||
|
||||
func (e *ConcurrencyError) Error() string {
|
||||
if e.IsTimeout {
|
||||
return fmt.Sprintf("timeout waiting for %s concurrency slot", e.SlotType)
|
||||
}
|
||||
return fmt.Sprintf("%s concurrency limit reached", e.SlotType)
|
||||
}
|
||||
|
||||
// ConcurrencyHelper provides common concurrency slot management for gateway handlers
|
||||
type ConcurrencyHelper struct {
|
||||
concurrencyService *service.ConcurrencyService
|
||||
pingFormat SSEPingFormat
|
||||
}
|
||||
|
||||
// NewConcurrencyHelper creates a new ConcurrencyHelper
|
||||
func NewConcurrencyHelper(concurrencyService *service.ConcurrencyService, pingFormat SSEPingFormat) *ConcurrencyHelper {
|
||||
return &ConcurrencyHelper{
|
||||
concurrencyService: concurrencyService,
|
||||
pingFormat: pingFormat,
|
||||
}
|
||||
}
|
||||
|
||||
// IncrementWaitCount increments the wait count for a user
|
||||
func (h *ConcurrencyHelper) IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error) {
|
||||
return h.concurrencyService.IncrementWaitCount(ctx, userID, maxWait)
|
||||
}
|
||||
|
||||
// DecrementWaitCount decrements the wait count for a user
|
||||
func (h *ConcurrencyHelper) DecrementWaitCount(ctx context.Context, userID int64) {
|
||||
h.concurrencyService.DecrementWaitCount(ctx, userID)
|
||||
}
|
||||
|
||||
// AcquireUserSlotWithWait acquires a user concurrency slot, waiting if necessary.
|
||||
// For streaming requests, sends ping events during the wait.
|
||||
// streamStarted is updated if streaming response has begun.
|
||||
func (h *ConcurrencyHelper) AcquireUserSlotWithWait(c *gin.Context, user *model.User, isStream bool, streamStarted *bool) (func(), error) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Try to acquire immediately
|
||||
result, err := h.concurrencyService.AcquireUserSlot(ctx, user.ID, user.Concurrency)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.Acquired {
|
||||
return result.ReleaseFunc, nil
|
||||
}
|
||||
|
||||
// Need to wait - handle streaming ping if needed
|
||||
return h.waitForSlotWithPing(c, "user", user.ID, user.Concurrency, isStream, streamStarted)
|
||||
}
|
||||
|
||||
// AcquireAccountSlotWithWait acquires an account concurrency slot, waiting if necessary.
|
||||
// For streaming requests, sends ping events during the wait.
|
||||
// streamStarted is updated if streaming response has begun.
|
||||
func (h *ConcurrencyHelper) AcquireAccountSlotWithWait(c *gin.Context, account *model.Account, isStream bool, streamStarted *bool) (func(), error) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Try to acquire immediately
|
||||
result, err := h.concurrencyService.AcquireAccountSlot(ctx, account.ID, account.Concurrency)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.Acquired {
|
||||
return result.ReleaseFunc, nil
|
||||
}
|
||||
|
||||
// Need to wait - handle streaming ping if needed
|
||||
return h.waitForSlotWithPing(c, "account", account.ID, account.Concurrency, isStream, streamStarted)
|
||||
}
|
||||
|
||||
// waitForSlotWithPing waits for a concurrency slot, sending ping events for streaming requests.
|
||||
// streamStarted pointer is updated when streaming begins (for proper error handling by caller).
|
||||
func (h *ConcurrencyHelper) waitForSlotWithPing(c *gin.Context, slotType string, id int64, maxConcurrency int, isStream bool, streamStarted *bool) (func(), error) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), maxConcurrencyWait)
|
||||
defer cancel()
|
||||
|
||||
// Determine if ping is needed (streaming + ping format defined)
|
||||
needPing := isStream && h.pingFormat != ""
|
||||
|
||||
var flusher http.Flusher
|
||||
if needPing {
|
||||
var ok bool
|
||||
flusher, ok = c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("streaming not supported")
|
||||
}
|
||||
}
|
||||
|
||||
// Only create ping ticker if ping is needed
|
||||
var pingCh <-chan time.Time
|
||||
if needPing {
|
||||
pingTicker := time.NewTicker(pingInterval)
|
||||
defer pingTicker.Stop()
|
||||
pingCh = pingTicker.C
|
||||
}
|
||||
|
||||
pollTicker := time.NewTicker(100 * time.Millisecond)
|
||||
defer pollTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, &ConcurrencyError{
|
||||
SlotType: slotType,
|
||||
IsTimeout: true,
|
||||
}
|
||||
|
||||
case <-pingCh:
|
||||
// Send ping to keep connection alive
|
||||
if !*streamStarted {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
*streamStarted = true
|
||||
}
|
||||
if _, err := fmt.Fprint(c.Writer, string(h.pingFormat)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
flusher.Flush()
|
||||
|
||||
case <-pollTicker.C:
|
||||
// Try to acquire slot
|
||||
var result *service.AcquireResult
|
||||
var err error
|
||||
|
||||
if slotType == "user" {
|
||||
result, err = h.concurrencyService.AcquireUserSlot(ctx, id, maxConcurrency)
|
||||
} else {
|
||||
result, err = h.concurrencyService.AcquireAccountSlot(ctx, id, maxConcurrency)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.Acquired {
|
||||
return result.ReleaseFunc, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ type AdminHandlers struct {
|
||||
Group *admin.GroupHandler
|
||||
Account *admin.AccountHandler
|
||||
OAuth *admin.OAuthHandler
|
||||
OpenAIOAuth *admin.OpenAIOAuthHandler
|
||||
Proxy *admin.ProxyHandler
|
||||
Redeem *admin.RedeemHandler
|
||||
Setting *admin.SettingHandler
|
||||
@@ -21,15 +22,16 @@ type AdminHandlers struct {
|
||||
|
||||
// Handlers contains all HTTP handlers
|
||||
type Handlers struct {
|
||||
Auth *AuthHandler
|
||||
User *UserHandler
|
||||
APIKey *APIKeyHandler
|
||||
Usage *UsageHandler
|
||||
Redeem *RedeemHandler
|
||||
Subscription *SubscriptionHandler
|
||||
Admin *AdminHandlers
|
||||
Gateway *GatewayHandler
|
||||
Setting *SettingHandler
|
||||
Auth *AuthHandler
|
||||
User *UserHandler
|
||||
APIKey *APIKeyHandler
|
||||
Usage *UsageHandler
|
||||
Redeem *RedeemHandler
|
||||
Subscription *SubscriptionHandler
|
||||
Admin *AdminHandlers
|
||||
Gateway *GatewayHandler
|
||||
OpenAIGateway *OpenAIGatewayHandler
|
||||
Setting *SettingHandler
|
||||
}
|
||||
|
||||
// BuildInfo contains build-time information
|
||||
|
||||
209
backend/internal/handler/openai_gateway_handler.go
Normal file
209
backend/internal/handler/openai_gateway_handler.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"sub2api/internal/middleware"
|
||||
"sub2api/internal/pkg/openai"
|
||||
"sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OpenAIGatewayHandler handles OpenAI API gateway requests
|
||||
type OpenAIGatewayHandler struct {
|
||||
gatewayService *service.OpenAIGatewayService
|
||||
billingCacheService *service.BillingCacheService
|
||||
concurrencyHelper *ConcurrencyHelper
|
||||
}
|
||||
|
||||
// NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler
|
||||
func NewOpenAIGatewayHandler(
|
||||
gatewayService *service.OpenAIGatewayService,
|
||||
concurrencyService *service.ConcurrencyService,
|
||||
billingCacheService *service.BillingCacheService,
|
||||
) *OpenAIGatewayHandler {
|
||||
return &OpenAIGatewayHandler{
|
||||
gatewayService: gatewayService,
|
||||
billingCacheService: billingCacheService,
|
||||
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatNone),
|
||||
}
|
||||
}
|
||||
|
||||
// Responses handles OpenAI Responses API endpoint
|
||||
// POST /openai/v1/responses
|
||||
func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
// Get apiKey and user from context (set by ApiKeyAuth middleware)
|
||||
apiKey, ok := middleware.GetApiKeyFromContext(c)
|
||||
if !ok {
|
||||
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := middleware.GetUserFromContext(c)
|
||||
if !ok {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Read request body
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
|
||||
return
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body to map for potential modification
|
||||
var reqBody map[string]any
|
||||
if err := json.Unmarshal(body, &reqBody); err != nil {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract model and stream
|
||||
reqModel, _ := reqBody["model"].(string)
|
||||
reqStream, _ := reqBody["stream"].(bool)
|
||||
|
||||
// For non-Codex CLI requests, set default instructions
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
if !openai.IsCodexCLIRequest(userAgent) {
|
||||
reqBody["instructions"] = openai.DefaultInstructions
|
||||
// Re-serialize body
|
||||
body, err = json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to process request")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Track if we've started streaming (for error handling)
|
||||
streamStarted := false
|
||||
|
||||
// Get subscription info (may be nil)
|
||||
subscription, _ := middleware.GetSubscriptionFromContext(c)
|
||||
|
||||
// 0. Check if wait queue is full
|
||||
maxWait := service.CalculateMaxWait(user.Concurrency)
|
||||
canWait, err := h.concurrencyHelper.IncrementWaitCount(c.Request.Context(), user.ID, maxWait)
|
||||
if err != nil {
|
||||
log.Printf("Increment wait count failed: %v", err)
|
||||
// On error, allow request to proceed
|
||||
} else if !canWait {
|
||||
h.errorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later")
|
||||
return
|
||||
}
|
||||
// Ensure wait count is decremented when function exits
|
||||
defer h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), user.ID)
|
||||
|
||||
// 1. First acquire user concurrency slot
|
||||
userReleaseFunc, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, user, reqStream, &streamStarted)
|
||||
if err != nil {
|
||||
log.Printf("User concurrency acquire failed: %v", err)
|
||||
h.handleConcurrencyError(c, err, "user", streamStarted)
|
||||
return
|
||||
}
|
||||
if userReleaseFunc != nil {
|
||||
defer userReleaseFunc()
|
||||
}
|
||||
|
||||
// 2. Re-check billing eligibility after wait
|
||||
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), user, apiKey, apiKey.Group, subscription); err != nil {
|
||||
log.Printf("Billing eligibility check failed after wait: %v", err)
|
||||
h.handleStreamingAwareError(c, http.StatusForbidden, "billing_error", err.Error(), streamStarted)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate session hash (from header for OpenAI)
|
||||
sessionHash := h.gatewayService.GenerateSessionHash(c)
|
||||
|
||||
// Select account supporting the requested model
|
||||
log.Printf("[OpenAI Handler] Selecting account: groupID=%v model=%s", apiKey.GroupID, reqModel)
|
||||
account, err := h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, reqModel)
|
||||
if err != nil {
|
||||
log.Printf("[OpenAI Handler] SelectAccount failed: %v", err)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
|
||||
return
|
||||
}
|
||||
log.Printf("[OpenAI Handler] Selected account: id=%d name=%s", account.ID, account.Name)
|
||||
|
||||
// 3. Acquire account concurrency slot
|
||||
accountReleaseFunc, err := h.concurrencyHelper.AcquireAccountSlotWithWait(c, account, reqStream, &streamStarted)
|
||||
if err != nil {
|
||||
log.Printf("Account concurrency acquire failed: %v", err)
|
||||
h.handleConcurrencyError(c, err, "account", streamStarted)
|
||||
return
|
||||
}
|
||||
if accountReleaseFunc != nil {
|
||||
defer accountReleaseFunc()
|
||||
}
|
||||
|
||||
// Forward request
|
||||
result, err := h.gatewayService.Forward(c.Request.Context(), c, account, body)
|
||||
if err != nil {
|
||||
// Error response already handled in Forward, just log
|
||||
log.Printf("Forward request failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Async record usage
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
|
||||
Result: result,
|
||||
ApiKey: apiKey,
|
||||
User: user,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
}); err != nil {
|
||||
log.Printf("Record usage failed: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// handleConcurrencyError handles concurrency-related errors with proper 429 response
|
||||
func (h *OpenAIGatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) {
|
||||
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||
fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType), streamStarted)
|
||||
}
|
||||
|
||||
// handleStreamingAwareError handles errors that may occur after streaming has started
|
||||
func (h *OpenAIGatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
|
||||
if streamStarted {
|
||||
// Stream already started, send error as SSE event then close
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if ok {
|
||||
// Send error event in OpenAI SSE format
|
||||
errorEvent := fmt.Sprintf(`event: error`+"\n"+`data: {"error": {"type": "%s", "message": "%s"}}`+"\n\n", errType, message)
|
||||
if _, err := fmt.Fprint(c.Writer, errorEvent); err != nil {
|
||||
_ = c.Error(err)
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Normal case: return JSON response with proper status code
|
||||
h.errorResponse(c, status, errType, message)
|
||||
}
|
||||
|
||||
// errorResponse returns OpenAI API format error response
|
||||
func (h *OpenAIGatewayHandler) errorResponse(c *gin.Context, status int, errType, message string) {
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"type": errType,
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -26,6 +26,12 @@ type ChangePasswordRequest struct {
|
||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
// UpdateProfileRequest represents the update profile request payload
|
||||
type UpdateProfileRequest struct {
|
||||
Username *string `json:"username"`
|
||||
Wechat *string `json:"wechat"`
|
||||
}
|
||||
|
||||
// GetProfile handles getting user profile
|
||||
// GET /api/v1/users/me
|
||||
func (h *UserHandler) GetProfile(c *gin.Context) {
|
||||
@@ -47,6 +53,9 @@ func (h *UserHandler) GetProfile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 清空notes字段,普通用户不应看到备注
|
||||
userData.Notes = ""
|
||||
|
||||
response.Success(c, userData)
|
||||
}
|
||||
|
||||
@@ -83,3 +92,40 @@ func (h *UserHandler) ChangePassword(c *gin.Context) {
|
||||
|
||||
response.Success(c, gin.H{"message": "Password changed successfully"})
|
||||
}
|
||||
|
||||
// UpdateProfile handles updating user profile
|
||||
// PUT /api/v1/users/me
|
||||
func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
userValue, exists := c.Get("user")
|
||||
if !exists {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := userValue.(*model.User)
|
||||
if !ok {
|
||||
response.InternalError(c, "Invalid user context")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
svcReq := service.UpdateProfileRequest{
|
||||
Username: req.Username,
|
||||
Wechat: req.Wechat,
|
||||
}
|
||||
updatedUser, err := h.userService.UpdateProfile(c.Request.Context(), user.ID, svcReq)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Failed to update profile: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 清空notes字段,普通用户不应看到备注
|
||||
updatedUser.Notes = ""
|
||||
|
||||
response.Success(c, updatedUser)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ func ProvideAdminHandlers(
|
||||
groupHandler *admin.GroupHandler,
|
||||
accountHandler *admin.AccountHandler,
|
||||
oauthHandler *admin.OAuthHandler,
|
||||
openaiOAuthHandler *admin.OpenAIOAuthHandler,
|
||||
proxyHandler *admin.ProxyHandler,
|
||||
redeemHandler *admin.RedeemHandler,
|
||||
settingHandler *admin.SettingHandler,
|
||||
@@ -27,6 +28,7 @@ func ProvideAdminHandlers(
|
||||
Group: groupHandler,
|
||||
Account: accountHandler,
|
||||
OAuth: oauthHandler,
|
||||
OpenAIOAuth: openaiOAuthHandler,
|
||||
Proxy: proxyHandler,
|
||||
Redeem: redeemHandler,
|
||||
Setting: settingHandler,
|
||||
@@ -56,18 +58,20 @@ func ProvideHandlers(
|
||||
subscriptionHandler *SubscriptionHandler,
|
||||
adminHandlers *AdminHandlers,
|
||||
gatewayHandler *GatewayHandler,
|
||||
openaiGatewayHandler *OpenAIGatewayHandler,
|
||||
settingHandler *SettingHandler,
|
||||
) *Handlers {
|
||||
return &Handlers{
|
||||
Auth: authHandler,
|
||||
User: userHandler,
|
||||
APIKey: apiKeyHandler,
|
||||
Usage: usageHandler,
|
||||
Redeem: redeemHandler,
|
||||
Subscription: subscriptionHandler,
|
||||
Admin: adminHandlers,
|
||||
Gateway: gatewayHandler,
|
||||
Setting: settingHandler,
|
||||
Auth: authHandler,
|
||||
User: userHandler,
|
||||
APIKey: apiKeyHandler,
|
||||
Usage: usageHandler,
|
||||
Redeem: redeemHandler,
|
||||
Subscription: subscriptionHandler,
|
||||
Admin: adminHandlers,
|
||||
Gateway: gatewayHandler,
|
||||
OpenAIGateway: openaiGatewayHandler,
|
||||
Setting: settingHandler,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +85,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewRedeemHandler,
|
||||
NewSubscriptionHandler,
|
||||
NewGatewayHandler,
|
||||
NewOpenAIGatewayHandler,
|
||||
ProvideSettingHandler,
|
||||
|
||||
// Admin handlers
|
||||
@@ -89,6 +94,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewGroupHandler,
|
||||
admin.NewAccountHandler,
|
||||
admin.NewOAuthHandler,
|
||||
admin.NewOpenAIOAuthHandler,
|
||||
admin.NewProxyHandler,
|
||||
admin.NewRedeemHandler,
|
||||
admin.NewSettingHandler,
|
||||
|
||||
@@ -68,7 +68,8 @@ type Account struct {
|
||||
AccountGroups []AccountGroup `gorm:"foreignKey:AccountID" json:"account_groups,omitempty"`
|
||||
|
||||
// 虚拟字段 (不存储到数据库)
|
||||
GroupIDs []int64 `gorm:"-" json:"group_ids,omitempty"`
|
||||
GroupIDs []int64 `gorm:"-" json:"group_ids,omitempty"`
|
||||
Groups []*Group `gorm:"-" json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
func (Account) TableName() string {
|
||||
@@ -277,3 +278,138 @@ func (a *Account) IsInterceptWarmupEnabled() bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// =============== OpenAI 相关方法 ===============
|
||||
|
||||
// IsOpenAI 检查是否为 OpenAI 平台账号
|
||||
func (a *Account) IsOpenAI() bool {
|
||||
return a.Platform == PlatformOpenAI
|
||||
}
|
||||
|
||||
// IsAnthropic 检查是否为 Anthropic 平台账号
|
||||
func (a *Account) IsAnthropic() bool {
|
||||
return a.Platform == PlatformAnthropic
|
||||
}
|
||||
|
||||
// IsOpenAIOAuth 检查是否为 OpenAI OAuth 类型账号
|
||||
func (a *Account) IsOpenAIOAuth() bool {
|
||||
return a.IsOpenAI() && a.Type == AccountTypeOAuth
|
||||
}
|
||||
|
||||
// IsOpenAIApiKey 检查是否为 OpenAI API Key 类型账号(Response 账号)
|
||||
func (a *Account) IsOpenAIApiKey() bool {
|
||||
return a.IsOpenAI() && a.Type == AccountTypeApiKey
|
||||
}
|
||||
|
||||
// GetOpenAIBaseURL 获取 OpenAI API 基础 URL
|
||||
// 对于 API Key 类型账号,从 credentials 中获取 base_url
|
||||
// 对于 OAuth 类型账号,返回默认的 OpenAI API URL
|
||||
func (a *Account) GetOpenAIBaseURL() string {
|
||||
if !a.IsOpenAI() {
|
||||
return ""
|
||||
}
|
||||
if a.Type == AccountTypeApiKey {
|
||||
baseURL := a.GetCredential("base_url")
|
||||
if baseURL != "" {
|
||||
return baseURL
|
||||
}
|
||||
}
|
||||
return "https://api.openai.com" // OpenAI 默认 API URL
|
||||
}
|
||||
|
||||
// GetOpenAIAccessToken 获取 OpenAI 访问令牌
|
||||
func (a *Account) GetOpenAIAccessToken() string {
|
||||
if !a.IsOpenAI() {
|
||||
return ""
|
||||
}
|
||||
return a.GetCredential("access_token")
|
||||
}
|
||||
|
||||
// GetOpenAIRefreshToken 获取 OpenAI 刷新令牌
|
||||
func (a *Account) GetOpenAIRefreshToken() string {
|
||||
if !a.IsOpenAIOAuth() {
|
||||
return ""
|
||||
}
|
||||
return a.GetCredential("refresh_token")
|
||||
}
|
||||
|
||||
// GetOpenAIIDToken 获取 OpenAI ID Token(JWT,包含用户信息)
|
||||
func (a *Account) GetOpenAIIDToken() string {
|
||||
if !a.IsOpenAIOAuth() {
|
||||
return ""
|
||||
}
|
||||
return a.GetCredential("id_token")
|
||||
}
|
||||
|
||||
// GetOpenAIApiKey 获取 OpenAI API Key(用于 Response 账号)
|
||||
func (a *Account) GetOpenAIApiKey() string {
|
||||
if !a.IsOpenAIApiKey() {
|
||||
return ""
|
||||
}
|
||||
return a.GetCredential("api_key")
|
||||
}
|
||||
|
||||
// GetOpenAIUserAgent 获取 OpenAI 自定义 User-Agent
|
||||
// 返回空字符串表示透传原始 User-Agent
|
||||
func (a *Account) GetOpenAIUserAgent() string {
|
||||
if !a.IsOpenAI() {
|
||||
return ""
|
||||
}
|
||||
return a.GetCredential("user_agent")
|
||||
}
|
||||
|
||||
// GetChatGPTAccountID 获取 ChatGPT 账号 ID(从 ID Token 解析)
|
||||
func (a *Account) GetChatGPTAccountID() string {
|
||||
if !a.IsOpenAIOAuth() {
|
||||
return ""
|
||||
}
|
||||
return a.GetCredential("chatgpt_account_id")
|
||||
}
|
||||
|
||||
// GetChatGPTUserID 获取 ChatGPT 用户 ID(从 ID Token 解析)
|
||||
func (a *Account) GetChatGPTUserID() string {
|
||||
if !a.IsOpenAIOAuth() {
|
||||
return ""
|
||||
}
|
||||
return a.GetCredential("chatgpt_user_id")
|
||||
}
|
||||
|
||||
// GetOpenAIOrganizationID 获取 OpenAI 组织 ID
|
||||
func (a *Account) GetOpenAIOrganizationID() string {
|
||||
if !a.IsOpenAIOAuth() {
|
||||
return ""
|
||||
}
|
||||
return a.GetCredential("organization_id")
|
||||
}
|
||||
|
||||
// GetOpenAITokenExpiresAt 获取 OpenAI Token 过期时间
|
||||
func (a *Account) GetOpenAITokenExpiresAt() *time.Time {
|
||||
if !a.IsOpenAIOAuth() {
|
||||
return nil
|
||||
}
|
||||
expiresAtStr := a.GetCredential("expires_at")
|
||||
if expiresAtStr == "" {
|
||||
return nil
|
||||
}
|
||||
// 尝试解析时间
|
||||
t, err := time.Parse(time.RFC3339, expiresAtStr)
|
||||
if err != nil {
|
||||
// 尝试解析为 Unix 时间戳
|
||||
if v, ok := a.Credentials["expires_at"].(float64); ok {
|
||||
t = time.Unix(int64(v), 0)
|
||||
return &t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
// IsOpenAITokenExpired 检查 OpenAI Token 是否过期
|
||||
func (a *Account) IsOpenAITokenExpired() bool {
|
||||
expiresAt := a.GetOpenAITokenExpiresAt()
|
||||
if expiresAt == nil {
|
||||
return false // 没有过期时间信息,假设未过期
|
||||
}
|
||||
// 提前 60 秒认为过期,便于刷新
|
||||
return time.Now().Add(60 * time.Second).After(*expiresAt)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ type RedeemCode struct {
|
||||
Status string `gorm:"size:20;default:unused;not null" json:"status"` // unused/used
|
||||
UsedBy *int64 `gorm:"index" json:"used_by"`
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
Notes string `gorm:"type:text" json:"notes"`
|
||||
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
||||
|
||||
// 订阅类型专用字段
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
type User struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
Email string `gorm:"uniqueIndex;size:255;not null" json:"email"`
|
||||
Username string `gorm:"size:100;default:''" json:"username"`
|
||||
Wechat string `gorm:"size:100;default:''" json:"wechat"`
|
||||
Notes string `gorm:"type:text;default:''" json:"notes"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
Role string `gorm:"size:20;default:user;not null" json:"role"` // admin/user
|
||||
Balance float64 `gorm:"type:decimal(20,8);default:0;not null" json:"balance"`
|
||||
@@ -22,7 +25,8 @@ type User struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// 关联
|
||||
ApiKeys []ApiKey `gorm:"foreignKey:UserID" json:"api_keys,omitempty"`
|
||||
ApiKeys []ApiKey `gorm:"foreignKey:UserID" json:"api_keys,omitempty"`
|
||||
Subscriptions []UserSubscription `gorm:"foreignKey:UserID" json:"subscriptions,omitempty"`
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
|
||||
@@ -43,18 +43,25 @@ type OAuthSession struct {
|
||||
type SessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*OAuthSession
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewSessionStore creates a new session store
|
||||
func NewSessionStore() *SessionStore {
|
||||
store := &SessionStore{
|
||||
sessions: make(map[string]*OAuthSession),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
// Start cleanup goroutine
|
||||
go store.cleanup()
|
||||
return store
|
||||
}
|
||||
|
||||
// Stop stops the cleanup goroutine
|
||||
func (s *SessionStore) Stop() {
|
||||
close(s.stopCh)
|
||||
}
|
||||
|
||||
// Set stores a session
|
||||
func (s *SessionStore) Set(sessionID string, session *OAuthSession) {
|
||||
s.mu.Lock()
|
||||
@@ -87,14 +94,20 @@ func (s *SessionStore) Delete(sessionID string) {
|
||||
// cleanup removes expired sessions periodically
|
||||
func (s *SessionStore) cleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
for range ticker.C {
|
||||
s.mu.Lock()
|
||||
for id, session := range s.sessions {
|
||||
if time.Since(session.CreatedAt) > SessionTTL {
|
||||
delete(s.sessions, id)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.mu.Lock()
|
||||
for id, session := range s.sessions {
|
||||
if time.Since(session.CreatedAt) > SessionTTL {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
42
backend/internal/pkg/openai/constants.go
Normal file
42
backend/internal/pkg/openai/constants.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package openai
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// Model represents an OpenAI model
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// DefaultModels OpenAI models list
|
||||
var DefaultModels = []Model{
|
||||
{ID: "gpt-5.2", Object: "model", Created: 1733875200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.2"},
|
||||
{ID: "gpt-5.2-codex", Object: "model", Created: 1733011200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.2 Codex"},
|
||||
{ID: "gpt-5.1-codex-max", Object: "model", Created: 1730419200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.1 Codex Max"},
|
||||
{ID: "gpt-5.1-codex", Object: "model", Created: 1730419200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.1 Codex"},
|
||||
{ID: "gpt-5.1", Object: "model", Created: 1731456000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.1"},
|
||||
{ID: "gpt-5.1-codex-mini", Object: "model", Created: 1730419200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.1 Codex Mini"},
|
||||
{ID: "gpt-5", Object: "model", Created: 1722988800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5"},
|
||||
}
|
||||
|
||||
// DefaultModelIDs returns the default model ID list
|
||||
func DefaultModelIDs() []string {
|
||||
ids := make([]string, len(DefaultModels))
|
||||
for i, m := range DefaultModels {
|
||||
ids[i] = m.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// DefaultTestModel default model for testing OpenAI accounts
|
||||
const DefaultTestModel = "gpt-5.1-codex"
|
||||
|
||||
// DefaultInstructions default instructions for non-Codex CLI requests
|
||||
// Content loaded from instructions.txt at compile time
|
||||
//
|
||||
//go:embed instructions.txt
|
||||
var DefaultInstructions string
|
||||
118
backend/internal/pkg/openai/instructions.txt
Normal file
118
backend/internal/pkg/openai/instructions.txt
Normal file
@@ -0,0 +1,118 @@
|
||||
You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.
|
||||
|
||||
## General
|
||||
|
||||
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
|
||||
|
||||
## Editing constraints
|
||||
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like \"Assigns the value to the variable\", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
|
||||
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||
- You may be in a dirty git worktree.
|
||||
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
|
||||
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
|
||||
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
|
||||
* If the changes are in unrelated files, just ignore them and don't revert them.
|
||||
- Do not amend a commit unless explicitly requested to do so.
|
||||
- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.
|
||||
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
|
||||
|
||||
## Plan tool
|
||||
|
||||
When using the planning tool:
|
||||
- Skip using the planning tool for straightforward tasks (roughly the easiest 25%).
|
||||
- Do not make single-step plans.
|
||||
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
|
||||
|
||||
## Codex CLI harness, sandboxing, and approvals
|
||||
|
||||
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
|
||||
|
||||
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
|
||||
- **read-only**: The sandbox only permits reading files.
|
||||
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
|
||||
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
|
||||
|
||||
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
|
||||
- **restricted**: Requires approval
|
||||
- **enabled**: No approval needed
|
||||
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe \"read\" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (for all of these, you should weigh alternative paths that do not require approval)
|
||||
|
||||
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
|
||||
|
||||
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to \"never\", in which case never ask for approvals.
|
||||
|
||||
When requesting approval to execute a command that will require escalated privileges:
|
||||
- Provide the `sandbox_permissions` parameter with the value `\"require_escalated\"`
|
||||
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
|
||||
|
||||
## Special user requests
|
||||
|
||||
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
|
||||
- If the user asks for a \"review\", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
|
||||
|
||||
## Frontend tasks
|
||||
When doing frontend design tasks, avoid collapsing into \"AI slop\" or safe, average-looking layouts.
|
||||
Aim for interfaces that feel intentional, bold, and a bit surprising.
|
||||
- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
|
||||
- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
|
||||
- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
|
||||
- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
|
||||
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
|
||||
- Ensure the page loads properly on both desktop and mobile
|
||||
|
||||
Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
|
||||
|
||||
## Presenting your work and final message
|
||||
|
||||
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
|
||||
|
||||
- Default: be very concise; friendly coding teammate tone.
|
||||
- Ask only when needed; suggest ideas; mirror the user's style.
|
||||
- For substantial work, summarize clearly; follow final‑answer formatting.
|
||||
- Skip heavy formatting for simple confirmations.
|
||||
- Don't dump large files you've written; reference paths only.
|
||||
- No \"save/copy this file\" - User is on the same machine.
|
||||
- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.
|
||||
- For code changes:
|
||||
* Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with \"summary\", just jump right in.
|
||||
* If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.
|
||||
* When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
|
||||
- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
|
||||
|
||||
### Final answer structure and style guidelines
|
||||
|
||||
- Plain text; CLI handles styling. Use structure only when it helps scanability.
|
||||
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
|
||||
- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.
|
||||
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
|
||||
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
|
||||
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
|
||||
- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no \"above/below\"; parallel wording.
|
||||
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
|
||||
- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.
|
||||
- File References: When referencing files in your response follow the below rules:
|
||||
* Use inline code to make file paths clickable.
|
||||
* Each reference should have a stand alone path. Even if it's the same file.
|
||||
* Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.
|
||||
* Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
|
||||
* Do not use URIs like file://, vscode://, or https://.
|
||||
* Do not provide range of lines
|
||||
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5
|
||||
|
||||
366
backend/internal/pkg/openai/oauth.go
Normal file
366
backend/internal/pkg/openai/oauth.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OpenAI OAuth Constants (from CRS project - Codex CLI client)
|
||||
const (
|
||||
// OAuth Client ID for OpenAI (Codex CLI official)
|
||||
ClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
|
||||
// OAuth endpoints
|
||||
AuthorizeURL = "https://auth.openai.com/oauth/authorize"
|
||||
TokenURL = "https://auth.openai.com/oauth/token"
|
||||
|
||||
// Default redirect URI (can be customized)
|
||||
DefaultRedirectURI = "http://localhost:1455/auth/callback"
|
||||
|
||||
// Scopes
|
||||
DefaultScopes = "openid profile email offline_access"
|
||||
// RefreshScopes - scope for token refresh (without offline_access, aligned with CRS project)
|
||||
RefreshScopes = "openid profile email"
|
||||
|
||||
// Session TTL
|
||||
SessionTTL = 30 * time.Minute
|
||||
)
|
||||
|
||||
// OAuthSession stores OAuth flow state for OpenAI
|
||||
type OAuthSession struct {
|
||||
State string `json:"state"`
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
ProxyURL string `json:"proxy_url,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SessionStore manages OAuth sessions in memory
|
||||
type SessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*OAuthSession
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewSessionStore creates a new session store
|
||||
func NewSessionStore() *SessionStore {
|
||||
store := &SessionStore{
|
||||
sessions: make(map[string]*OAuthSession),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
// Start cleanup goroutine
|
||||
go store.cleanup()
|
||||
return store
|
||||
}
|
||||
|
||||
// Set stores a session
|
||||
func (s *SessionStore) Set(sessionID string, session *OAuthSession) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[sessionID] = session
|
||||
}
|
||||
|
||||
// Get retrieves a session
|
||||
func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
session, ok := s.sessions[sessionID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
// Check if expired
|
||||
if time.Since(session.CreatedAt) > SessionTTL {
|
||||
return nil, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
// Delete removes a session
|
||||
func (s *SessionStore) Delete(sessionID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, sessionID)
|
||||
}
|
||||
|
||||
// Stop stops the cleanup goroutine
|
||||
func (s *SessionStore) Stop() {
|
||||
close(s.stopCh)
|
||||
}
|
||||
|
||||
// cleanup removes expired sessions periodically
|
||||
func (s *SessionStore) cleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.mu.Lock()
|
||||
for id, session := range s.sessions {
|
||||
if time.Since(session.CreatedAt) > SessionTTL {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateRandomBytes generates cryptographically secure random bytes
|
||||
func GenerateRandomBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// GenerateState generates a random state string for OAuth
|
||||
func GenerateState() (string, error) {
|
||||
bytes, err := GenerateRandomBytes(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// GenerateSessionID generates a unique session ID
|
||||
func GenerateSessionID() (string, error) {
|
||||
bytes, err := GenerateRandomBytes(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// GenerateCodeVerifier generates a PKCE code verifier (64 bytes -> hex for OpenAI)
|
||||
// OpenAI uses hex encoding instead of base64url
|
||||
func GenerateCodeVerifier() (string, error) {
|
||||
bytes, err := GenerateRandomBytes(64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// GenerateCodeChallenge generates a PKCE code challenge using S256 method
|
||||
// Uses base64url encoding as per RFC 7636
|
||||
func GenerateCodeChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
return base64URLEncode(hash[:])
|
||||
}
|
||||
|
||||
// base64URLEncode encodes bytes to base64url without padding
|
||||
func base64URLEncode(data []byte) string {
|
||||
encoded := base64.URLEncoding.EncodeToString(data)
|
||||
// Remove padding
|
||||
return strings.TrimRight(encoded, "=")
|
||||
}
|
||||
|
||||
// BuildAuthorizationURL builds the OpenAI OAuth authorization URL
|
||||
func BuildAuthorizationURL(state, codeChallenge, redirectURI string) string {
|
||||
if redirectURI == "" {
|
||||
redirectURI = DefaultRedirectURI
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("response_type", "code")
|
||||
params.Set("client_id", ClientID)
|
||||
params.Set("redirect_uri", redirectURI)
|
||||
params.Set("scope", DefaultScopes)
|
||||
params.Set("state", state)
|
||||
params.Set("code_challenge", codeChallenge)
|
||||
params.Set("code_challenge_method", "S256")
|
||||
// OpenAI specific parameters
|
||||
params.Set("id_token_add_organizations", "true")
|
||||
params.Set("codex_cli_simplified_flow", "true")
|
||||
|
||||
return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode())
|
||||
}
|
||||
|
||||
// TokenRequest represents the token exchange request body
|
||||
type TokenRequest struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
ClientID string `json:"client_id"`
|
||||
Code string `json:"code"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
}
|
||||
|
||||
// TokenResponse represents the token response from OpenAI OAuth
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// RefreshTokenRequest represents the refresh token request
|
||||
type RefreshTokenRequest struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ClientID string `json:"client_id"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// IDTokenClaims represents the claims from OpenAI ID Token
|
||||
type IDTokenClaims struct {
|
||||
// Standard claims
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Iss string `json:"iss"`
|
||||
Aud []string `json:"aud"` // OpenAI returns aud as an array
|
||||
Exp int64 `json:"exp"`
|
||||
Iat int64 `json:"iat"`
|
||||
|
||||
// OpenAI specific claims (nested under https://api.openai.com/auth)
|
||||
OpenAIAuth *OpenAIAuthClaims `json:"https://api.openai.com/auth,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIAuthClaims represents the OpenAI specific auth claims
|
||||
type OpenAIAuthClaims struct {
|
||||
ChatGPTAccountID string `json:"chatgpt_account_id"`
|
||||
ChatGPTUserID string `json:"chatgpt_user_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Organizations []OrganizationClaim `json:"organizations"`
|
||||
}
|
||||
|
||||
// OrganizationClaim represents an organization in the ID Token
|
||||
type OrganizationClaim struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Title string `json:"title"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
// BuildTokenRequest creates a token exchange request for OpenAI
|
||||
func BuildTokenRequest(code, codeVerifier, redirectURI string) *TokenRequest {
|
||||
if redirectURI == "" {
|
||||
redirectURI = DefaultRedirectURI
|
||||
}
|
||||
return &TokenRequest{
|
||||
GrantType: "authorization_code",
|
||||
ClientID: ClientID,
|
||||
Code: code,
|
||||
RedirectURI: redirectURI,
|
||||
CodeVerifier: codeVerifier,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRefreshTokenRequest creates a refresh token request for OpenAI
|
||||
func BuildRefreshTokenRequest(refreshToken string) *RefreshTokenRequest {
|
||||
return &RefreshTokenRequest{
|
||||
GrantType: "refresh_token",
|
||||
RefreshToken: refreshToken,
|
||||
ClientID: ClientID,
|
||||
Scope: RefreshScopes,
|
||||
}
|
||||
}
|
||||
|
||||
// ToFormData converts TokenRequest to URL-encoded form data
|
||||
func (r *TokenRequest) ToFormData() string {
|
||||
params := url.Values{}
|
||||
params.Set("grant_type", r.GrantType)
|
||||
params.Set("client_id", r.ClientID)
|
||||
params.Set("code", r.Code)
|
||||
params.Set("redirect_uri", r.RedirectURI)
|
||||
params.Set("code_verifier", r.CodeVerifier)
|
||||
return params.Encode()
|
||||
}
|
||||
|
||||
// ToFormData converts RefreshTokenRequest to URL-encoded form data
|
||||
func (r *RefreshTokenRequest) ToFormData() string {
|
||||
params := url.Values{}
|
||||
params.Set("grant_type", r.GrantType)
|
||||
params.Set("client_id", r.ClientID)
|
||||
params.Set("refresh_token", r.RefreshToken)
|
||||
params.Set("scope", r.Scope)
|
||||
return params.Encode()
|
||||
}
|
||||
|
||||
// ParseIDToken parses the ID Token JWT and extracts claims
|
||||
// Note: This does NOT verify the signature - it only decodes the payload
|
||||
// For production, you should verify the token signature using OpenAI's public keys
|
||||
func ParseIDToken(idToken string) (*IDTokenClaims, error) {
|
||||
parts := strings.Split(idToken, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
// Decode payload (second part)
|
||||
payload := parts[1]
|
||||
// Add padding if necessary
|
||||
switch len(payload) % 4 {
|
||||
case 2:
|
||||
payload += "=="
|
||||
case 3:
|
||||
payload += "="
|
||||
}
|
||||
|
||||
decoded, err := base64.URLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
// Try standard encoding
|
||||
decoded, err = base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode JWT payload: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var claims IDTokenClaims
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JWT claims: %w", err)
|
||||
}
|
||||
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
// ExtractUserInfo extracts user information from ID Token claims
|
||||
type UserInfo struct {
|
||||
Email string
|
||||
ChatGPTAccountID string
|
||||
ChatGPTUserID string
|
||||
UserID string
|
||||
OrganizationID string
|
||||
Organizations []OrganizationClaim
|
||||
}
|
||||
|
||||
// GetUserInfo extracts user info from ID Token claims
|
||||
func (c *IDTokenClaims) GetUserInfo() *UserInfo {
|
||||
info := &UserInfo{
|
||||
Email: c.Email,
|
||||
}
|
||||
|
||||
if c.OpenAIAuth != nil {
|
||||
info.ChatGPTAccountID = c.OpenAIAuth.ChatGPTAccountID
|
||||
info.ChatGPTUserID = c.OpenAIAuth.ChatGPTUserID
|
||||
info.UserID = c.OpenAIAuth.UserID
|
||||
info.Organizations = c.OpenAIAuth.Organizations
|
||||
|
||||
// Get default organization ID
|
||||
for _, org := range c.OpenAIAuth.Organizations {
|
||||
if org.IsDefault {
|
||||
info.OrganizationID = org.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
// If no default, use first org
|
||||
if info.OrganizationID == "" && len(c.OpenAIAuth.Organizations) > 0 {
|
||||
info.OrganizationID = c.OpenAIAuth.Organizations[0].ID
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
18
backend/internal/pkg/openai/request.go
Normal file
18
backend/internal/pkg/openai/request.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package openai
|
||||
|
||||
// CodexCLIUserAgentPrefixes matches Codex CLI User-Agent patterns
|
||||
// Examples: "codex_vscode/1.0.0", "codex_cli_rs/0.1.2"
|
||||
var CodexCLIUserAgentPrefixes = []string{
|
||||
"codex_vscode/",
|
||||
"codex_cli_rs/",
|
||||
}
|
||||
|
||||
// IsCodexCLIRequest checks if the User-Agent indicates a Codex CLI request
|
||||
func IsCodexCLIRequest(userAgent string) bool {
|
||||
for _, prefix := range CodexCLIUserAgentPrefixes {
|
||||
if len(userAgent) >= len(prefix) && userAgent[:len(prefix)] == prefix {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
// 填充 GroupIDs 虚拟字段
|
||||
// 填充 GroupIDs 和 Groups 虚拟字段
|
||||
account.GroupIDs = make([]int64, 0, len(account.AccountGroups))
|
||||
account.Groups = make([]*model.Group, 0, len(account.AccountGroups))
|
||||
for _, ag := range account.AccountGroups {
|
||||
account.GroupIDs = append(account.GroupIDs, ag.GroupID)
|
||||
if ag.Group != nil {
|
||||
account.Groups = append(account.Groups, ag.Group)
|
||||
}
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
@@ -78,15 +82,19 @@ func (r *AccountRepository) ListWithFilters(ctx context.Context, params paginati
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err := db.Preload("Proxy").Preload("AccountGroups").Offset(params.Offset()).Limit(params.Limit()).Order("id DESC").Find(&accounts).Error; err != nil {
|
||||
if err := db.Preload("Proxy").Preload("AccountGroups.Group").Offset(params.Offset()).Limit(params.Limit()).Order("id DESC").Find(&accounts).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 填充每个 Account 的 GroupIDs 虚拟字段
|
||||
// 填充每个 Account 的虚拟字段(GroupIDs 和 Groups)
|
||||
for i := range accounts {
|
||||
accounts[i].GroupIDs = make([]int64, 0, len(accounts[i].AccountGroups))
|
||||
accounts[i].Groups = make([]*model.Group, 0, len(accounts[i].AccountGroups))
|
||||
for _, ag := range accounts[i].AccountGroups {
|
||||
accounts[i].GroupIDs = append(accounts[i].GroupIDs, ag.GroupID)
|
||||
if ag.Group != nil {
|
||||
accounts[i].Groups = append(accounts[i].Groups, ag.Group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +230,38 @@ func (r *AccountRepository) ListSchedulableByGroupID(ctx context.Context, groupI
|
||||
return accounts, err
|
||||
}
|
||||
|
||||
// ListSchedulableByPlatform 按平台获取可调度的账号
|
||||
func (r *AccountRepository) ListSchedulableByPlatform(ctx context.Context, platform string) ([]model.Account, error) {
|
||||
var accounts []model.Account
|
||||
now := time.Now()
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("platform = ?", platform).
|
||||
Where("status = ? AND schedulable = ?", model.StatusActive, true).
|
||||
Where("(overload_until IS NULL OR overload_until <= ?)", now).
|
||||
Where("(rate_limit_reset_at IS NULL OR rate_limit_reset_at <= ?)", now).
|
||||
Preload("Proxy").
|
||||
Order("priority ASC").
|
||||
Find(&accounts).Error
|
||||
return accounts, err
|
||||
}
|
||||
|
||||
// ListSchedulableByGroupIDAndPlatform 按组和平台获取可调度的账号
|
||||
func (r *AccountRepository) ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]model.Account, error) {
|
||||
var accounts []model.Account
|
||||
now := time.Now()
|
||||
err := r.db.WithContext(ctx).
|
||||
Joins("JOIN account_groups ON account_groups.account_id = accounts.id").
|
||||
Where("account_groups.group_id = ?", groupID).
|
||||
Where("accounts.platform = ?", platform).
|
||||
Where("accounts.status = ? AND accounts.schedulable = ?", model.StatusActive, true).
|
||||
Where("(accounts.overload_until IS NULL OR accounts.overload_until <= ?)", now).
|
||||
Where("(accounts.rate_limit_reset_at IS NULL OR accounts.rate_limit_reset_at <= ?)", now).
|
||||
Preload("Proxy").
|
||||
Order("account_groups.priority ASC, accounts.priority ASC").
|
||||
Find(&accounts).Error
|
||||
return accounts, err
|
||||
}
|
||||
|
||||
// SetRateLimited 标记账号为限流状态(429)
|
||||
func (r *AccountRepository) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error {
|
||||
now := time.Now()
|
||||
@@ -267,3 +307,31 @@ func (r *AccountRepository) SetSchedulable(ctx context.Context, id int64, schedu
|
||||
return r.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", id).
|
||||
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"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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) {
|
||||
client := createReqClient(proxyURL)
|
||||
|
||||
// Parse code which may contain state in format "authCode#state"
|
||||
authCode := code
|
||||
codeState := ""
|
||||
if len(code) > 0 {
|
||||
parts := make([]string, 0, 2)
|
||||
for i, part := range []rune(code) {
|
||||
if part == '#' {
|
||||
authCode = code[:i]
|
||||
codeState = code[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
authCode = code
|
||||
}
|
||||
if idx := strings.Index(code, "#"); idx != -1 {
|
||||
authCode = code[:idx]
|
||||
codeState = code[idx+1:]
|
||||
}
|
||||
|
||||
reqBody := map[string]any{
|
||||
|
||||
@@ -6,15 +6,18 @@ import (
|
||||
"time"
|
||||
|
||||
"sub2api/internal/config"
|
||||
"sub2api/internal/service"
|
||||
"sub2api/internal/service/ports"
|
||||
)
|
||||
|
||||
type claudeUpstreamService struct {
|
||||
// httpUpstreamService is a generic HTTP upstream service that can be used for
|
||||
// making requests to any HTTP API (Claude, OpenAI, etc.) with optional proxy support.
|
||||
type httpUpstreamService struct {
|
||||
defaultClient *http.Client
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewClaudeUpstream(cfg *config.Config) service.ClaudeUpstream {
|
||||
// NewHTTPUpstream creates a new generic HTTP upstream service
|
||||
func NewHTTPUpstream(cfg *config.Config) ports.HTTPUpstream {
|
||||
responseHeaderTimeout := time.Duration(cfg.Gateway.ResponseHeaderTimeout) * time.Second
|
||||
if responseHeaderTimeout == 0 {
|
||||
responseHeaderTimeout = 300 * time.Second
|
||||
@@ -27,13 +30,13 @@ func NewClaudeUpstream(cfg *config.Config) service.ClaudeUpstream {
|
||||
ResponseHeaderTimeout: responseHeaderTimeout,
|
||||
}
|
||||
|
||||
return &claudeUpstreamService{
|
||||
return &httpUpstreamService{
|
||||
defaultClient: &http.Client{Transport: transport},
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *claudeUpstreamService) Do(req *http.Request, proxyURL string) (*http.Response, error) {
|
||||
func (s *httpUpstreamService) Do(req *http.Request, proxyURL string) (*http.Response, error) {
|
||||
if proxyURL == "" {
|
||||
return s.defaultClient.Do(req)
|
||||
}
|
||||
@@ -41,7 +44,7 @@ func (s *claudeUpstreamService) Do(req *http.Request, proxyURL string) (*http.Re
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
func (s *claudeUpstreamService) createProxyClient(proxyURL string) *http.Client {
|
||||
func (s *httpUpstreamService) createProxyClient(proxyURL string) *http.Client {
|
||||
parsedURL, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return s.defaultClient
|
||||
92
backend/internal/repository/openai_oauth_service.go
Normal file
92
backend/internal/repository/openai_oauth_service.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"sub2api/internal/pkg/openai"
|
||||
"sub2api/internal/service/ports"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
|
||||
type openaiOAuthService struct{}
|
||||
|
||||
// NewOpenAIOAuthClient creates a new OpenAI OAuth client
|
||||
func NewOpenAIOAuthClient() ports.OpenAIOAuthClient {
|
||||
return &openaiOAuthService{}
|
||||
}
|
||||
|
||||
func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL string) (*openai.TokenResponse, error) {
|
||||
client := createOpenAIReqClient(proxyURL)
|
||||
|
||||
if redirectURI == "" {
|
||||
redirectURI = openai.DefaultRedirectURI
|
||||
}
|
||||
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
formData.Set("client_id", openai.ClientID)
|
||||
formData.Set("code", code)
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
formData.Set("code_verifier", codeVerifier)
|
||||
|
||||
var tokenResp openai.TokenResponse
|
||||
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetFormDataFromValues(formData).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(openai.TokenURL)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
return nil, fmt.Errorf("token exchange failed: status %d, body: %s", resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
func (s *openaiOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*openai.TokenResponse, error) {
|
||||
client := createOpenAIReqClient(proxyURL)
|
||||
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "refresh_token")
|
||||
formData.Set("refresh_token", refreshToken)
|
||||
formData.Set("client_id", openai.ClientID)
|
||||
formData.Set("scope", openai.RefreshScopes)
|
||||
|
||||
var tokenResp openai.TokenResponse
|
||||
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetFormDataFromValues(formData).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(openai.TokenURL)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
return nil, fmt.Errorf("token refresh failed: status %d, body: %s", resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
func createOpenAIReqClient(proxyURL string) *req.Client {
|
||||
client := req.C().
|
||||
SetTimeout(60 * time.Second)
|
||||
|
||||
if proxyURL != "" {
|
||||
client.SetProxyURL(proxyURL)
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
@@ -937,7 +937,7 @@ func (r *UsageLogRepository) GetUsageTrendWithFilters(ctx context.Context, start
|
||||
}
|
||||
|
||||
// GetModelStatsWithFilters returns model statistics with optional user/api_key filters
|
||||
func (r *UsageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID int64) ([]ModelStat, error) {
|
||||
func (r *UsageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID int64) ([]ModelStat, error) {
|
||||
var results []ModelStat
|
||||
|
||||
db := r.db.WithContext(ctx).Model(&model.UsageLog{}).
|
||||
@@ -958,6 +958,9 @@ func (r *UsageLogRepository) GetModelStatsWithFilters(ctx context.Context, start
|
||||
if apiKeyID > 0 {
|
||||
db = db.Where("api_key_id = ?", apiKeyID)
|
||||
}
|
||||
if accountID > 0 {
|
||||
db = db.Where("account_id = ?", accountID)
|
||||
}
|
||||
|
||||
err := db.Group("model").Order("total_tokens DESC").Scan(&results).Error
|
||||
if err != nil {
|
||||
@@ -1007,3 +1010,209 @@ func (r *UsageLogRepository) GetGlobalStats(ctx context.Context, startTime, endT
|
||||
AverageDurationMs: stats.AverageDurationMs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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) {
|
||||
daysCount := int(endTime.Sub(startTime).Hours()/24) + 1
|
||||
if daysCount <= 0 {
|
||||
daysCount = 30
|
||||
}
|
||||
|
||||
// Get daily history
|
||||
var historyResults []struct {
|
||||
Date string `gorm:"column:date"`
|
||||
Requests int64 `gorm:"column:requests"`
|
||||
Tokens int64 `gorm:"column:tokens"`
|
||||
Cost float64 `gorm:"column:cost"`
|
||||
ActualCost float64 `gorm:"column:actual_cost"`
|
||||
}
|
||||
|
||||
err := r.db.WithContext(ctx).Model(&model.UsageLog{}).
|
||||
Select(`
|
||||
TO_CHAR(created_at, 'YYYY-MM-DD') as date,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens,
|
||||
COALESCE(SUM(total_cost), 0) as cost,
|
||||
COALESCE(SUM(actual_cost), 0) as actual_cost
|
||||
`).
|
||||
Where("account_id = ? AND created_at >= ? AND created_at < ?", accountID, startTime, endTime).
|
||||
Group("date").
|
||||
Order("date ASC").
|
||||
Scan(&historyResults).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build history with labels
|
||||
history := make([]AccountUsageHistory, 0, len(historyResults))
|
||||
for _, h := range historyResults {
|
||||
// Parse date to get label (MM/DD)
|
||||
t, _ := time.Parse("2006-01-02", h.Date)
|
||||
label := t.Format("01/02")
|
||||
history = append(history, AccountUsageHistory{
|
||||
Date: h.Date,
|
||||
Label: label,
|
||||
Requests: h.Requests,
|
||||
Tokens: h.Tokens,
|
||||
Cost: h.Cost,
|
||||
ActualCost: h.ActualCost,
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate summary
|
||||
var totalActualCost, totalStandardCost float64
|
||||
var totalRequests, totalTokens int64
|
||||
var highestCostDay, highestRequestDay *AccountUsageHistory
|
||||
|
||||
for i := range history {
|
||||
h := &history[i]
|
||||
totalActualCost += h.ActualCost
|
||||
totalStandardCost += h.Cost
|
||||
totalRequests += h.Requests
|
||||
totalTokens += h.Tokens
|
||||
|
||||
if highestCostDay == nil || h.ActualCost > highestCostDay.ActualCost {
|
||||
highestCostDay = h
|
||||
}
|
||||
if highestRequestDay == nil || h.Requests > highestRequestDay.Requests {
|
||||
highestRequestDay = h
|
||||
}
|
||||
}
|
||||
|
||||
actualDaysUsed := len(history)
|
||||
if actualDaysUsed == 0 {
|
||||
actualDaysUsed = 1
|
||||
}
|
||||
|
||||
// Get average duration
|
||||
var avgDuration struct {
|
||||
AvgDurationMs float64 `gorm:"column:avg_duration_ms"`
|
||||
}
|
||||
r.db.WithContext(ctx).Model(&model.UsageLog{}).
|
||||
Select("COALESCE(AVG(duration_ms), 0) as avg_duration_ms").
|
||||
Where("account_id = ? AND created_at >= ? AND created_at < ?", accountID, startTime, endTime).
|
||||
Scan(&avgDuration)
|
||||
|
||||
summary := AccountUsageSummary{
|
||||
Days: daysCount,
|
||||
ActualDaysUsed: actualDaysUsed,
|
||||
TotalCost: totalActualCost,
|
||||
TotalStandardCost: totalStandardCost,
|
||||
TotalRequests: totalRequests,
|
||||
TotalTokens: totalTokens,
|
||||
AvgDailyCost: totalActualCost / float64(actualDaysUsed),
|
||||
AvgDailyRequests: float64(totalRequests) / float64(actualDaysUsed),
|
||||
AvgDailyTokens: float64(totalTokens) / float64(actualDaysUsed),
|
||||
AvgDurationMs: avgDuration.AvgDurationMs,
|
||||
}
|
||||
|
||||
// Set today's stats
|
||||
todayStr := timezone.Now().Format("2006-01-02")
|
||||
for i := range history {
|
||||
if history[i].Date == todayStr {
|
||||
summary.Today = &struct {
|
||||
Date string `json:"date"`
|
||||
Cost float64 `json:"cost"`
|
||||
Requests int64 `json:"requests"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
}{
|
||||
Date: history[i].Date,
|
||||
Cost: history[i].ActualCost,
|
||||
Requests: history[i].Requests,
|
||||
Tokens: history[i].Tokens,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Set highest cost day
|
||||
if highestCostDay != nil {
|
||||
summary.HighestCostDay = &struct {
|
||||
Date string `json:"date"`
|
||||
Label string `json:"label"`
|
||||
Cost float64 `json:"cost"`
|
||||
Requests int64 `json:"requests"`
|
||||
}{
|
||||
Date: highestCostDay.Date,
|
||||
Label: highestCostDay.Label,
|
||||
Cost: highestCostDay.ActualCost,
|
||||
Requests: highestCostDay.Requests,
|
||||
}
|
||||
}
|
||||
|
||||
// Set highest request day
|
||||
if highestRequestDay != nil {
|
||||
summary.HighestRequestDay = &struct {
|
||||
Date string `json:"date"`
|
||||
Label string `json:"label"`
|
||||
Requests int64 `json:"requests"`
|
||||
Cost float64 `json:"cost"`
|
||||
}{
|
||||
Date: highestRequestDay.Date,
|
||||
Label: highestRequestDay.Label,
|
||||
Requests: highestRequestDay.Requests,
|
||||
Cost: highestRequestDay.ActualCost,
|
||||
}
|
||||
}
|
||||
|
||||
// Get model statistics using the unified method
|
||||
models, err := r.GetModelStatsWithFilters(ctx, startTime, endTime, 0, 0, accountID)
|
||||
if err != nil {
|
||||
models = []ModelStat{}
|
||||
}
|
||||
|
||||
return &AccountUsageStatsResponse{
|
||||
History: history,
|
||||
Summary: summary,
|
||||
Models: models,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -66,17 +66,47 @@ func (r *UserRepository) ListWithFilters(ctx context.Context, params pagination.
|
||||
}
|
||||
if search != "" {
|
||||
searchPattern := "%" + search + "%"
|
||||
db = db.Where("email ILIKE ?", searchPattern)
|
||||
db = db.Where(
|
||||
"email ILIKE ? OR username ILIKE ? OR wechat ILIKE ?",
|
||||
searchPattern, searchPattern, searchPattern,
|
||||
)
|
||||
}
|
||||
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Query users with pagination (reuse the same db with filters applied)
|
||||
if err := db.Offset(params.Offset()).Limit(params.Limit()).Order("id DESC").Find(&users).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Batch load subscriptions for all users (avoid N+1)
|
||||
if len(users) > 0 {
|
||||
userIDs := make([]int64, len(users))
|
||||
userMap := make(map[int64]*model.User, len(users))
|
||||
for i := range users {
|
||||
userIDs[i] = users[i].ID
|
||||
userMap[users[i].ID] = &users[i]
|
||||
}
|
||||
|
||||
// Query active subscriptions with groups in one query
|
||||
var subscriptions []model.UserSubscription
|
||||
if err := r.db.WithContext(ctx).
|
||||
Preload("Group").
|
||||
Where("user_id IN ? AND status = ?", userIDs, model.SubscriptionStatusActive).
|
||||
Find(&subscriptions).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Associate subscriptions with users
|
||||
for i := range subscriptions {
|
||||
if user, ok := userMap[subscriptions[i].UserID]; ok {
|
||||
user.Subscriptions = append(user.Subscriptions, subscriptions[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pages := int(total) / params.Limit()
|
||||
if int(total)%params.Limit() > 0 {
|
||||
pages++
|
||||
|
||||
@@ -36,7 +36,8 @@ var ProviderSet = wire.NewSet(
|
||||
NewProxyExitInfoProber,
|
||||
NewClaudeUsageFetcher,
|
||||
NewClaudeOAuthClient,
|
||||
NewClaudeUpstream,
|
||||
NewHTTPUpstream,
|
||||
NewOpenAIOAuthClient,
|
||||
|
||||
// Bind concrete repositories to service port interfaces
|
||||
wire.Bind(new(ports.UserRepository), new(*UserRepository)),
|
||||
|
||||
@@ -82,6 +82,7 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep
|
||||
{
|
||||
user.GET("/profile", h.User.GetProfile)
|
||||
user.PUT("/password", h.User.ChangePassword)
|
||||
user.PUT("", h.User.UpdateProfile)
|
||||
}
|
||||
|
||||
// API Key管理
|
||||
@@ -192,7 +193,7 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep
|
||||
accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels)
|
||||
accounts.POST("/batch", h.Admin.Account.BatchCreate)
|
||||
|
||||
// OAuth routes
|
||||
// Claude OAuth routes
|
||||
accounts.POST("/generate-auth-url", h.Admin.OAuth.GenerateAuthURL)
|
||||
accounts.POST("/generate-setup-token-url", h.Admin.OAuth.GenerateSetupTokenURL)
|
||||
accounts.POST("/exchange-code", h.Admin.OAuth.ExchangeCode)
|
||||
@@ -201,6 +202,16 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep
|
||||
accounts.POST("/setup-token-cookie-auth", h.Admin.OAuth.SetupTokenCookieAuth)
|
||||
}
|
||||
|
||||
// OpenAI OAuth routes
|
||||
openai := admin.Group("/openai")
|
||||
{
|
||||
openai.POST("/generate-auth-url", h.Admin.OpenAIOAuth.GenerateAuthURL)
|
||||
openai.POST("/exchange-code", h.Admin.OpenAIOAuth.ExchangeCode)
|
||||
openai.POST("/refresh-token", h.Admin.OpenAIOAuth.RefreshToken)
|
||||
openai.POST("/accounts/:id/refresh", h.Admin.OpenAIOAuth.RefreshAccountToken)
|
||||
openai.POST("/create-from-oauth", h.Admin.OpenAIOAuth.CreateAccountFromOAuth)
|
||||
}
|
||||
|
||||
// 代理管理
|
||||
proxies := admin.Group("/proxies")
|
||||
{
|
||||
@@ -289,5 +300,10 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep
|
||||
gateway.POST("/messages/count_tokens", h.Gateway.CountTokens)
|
||||
gateway.GET("/models", h.Gateway.Models)
|
||||
gateway.GET("/usage", h.Gateway.Usage)
|
||||
// OpenAI Responses API
|
||||
gateway.POST("/responses", h.OpenAIGateway.Responses)
|
||||
}
|
||||
|
||||
// OpenAI Responses API(不带v1前缀的别名)
|
||||
r.POST("/responses", middleware.ApiKeyAuthWithSubscription(s.ApiKey, s.Subscription), h.OpenAIGateway.Responses)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sub2api/internal/model"
|
||||
"sub2api/internal/pkg/claude"
|
||||
"sub2api/internal/pkg/openai"
|
||||
"sub2api/internal/service/ports"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -22,7 +24,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
testClaudeAPIURL = "https://api.anthropic.com/v1/messages"
|
||||
testClaudeAPIURL = "https://api.anthropic.com/v1/messages"
|
||||
testOpenAIAPIURL = "https://api.openai.com/v1/responses"
|
||||
chatgptCodexAPIURL = "https://chatgpt.com/backend-api/codex/responses"
|
||||
)
|
||||
|
||||
// TestEvent represents a SSE event for account testing
|
||||
@@ -36,17 +40,19 @@ type TestEvent struct {
|
||||
|
||||
// AccountTestService handles account testing operations
|
||||
type AccountTestService struct {
|
||||
accountRepo ports.AccountRepository
|
||||
oauthService *OAuthService
|
||||
claudeUpstream ClaudeUpstream
|
||||
accountRepo ports.AccountRepository
|
||||
oauthService *OAuthService
|
||||
openaiOAuthService *OpenAIOAuthService
|
||||
httpUpstream ports.HTTPUpstream
|
||||
}
|
||||
|
||||
// NewAccountTestService creates a new AccountTestService
|
||||
func NewAccountTestService(accountRepo ports.AccountRepository, oauthService *OAuthService, claudeUpstream ClaudeUpstream) *AccountTestService {
|
||||
func NewAccountTestService(accountRepo ports.AccountRepository, oauthService *OAuthService, openaiOAuthService *OpenAIOAuthService, httpUpstream ports.HTTPUpstream) *AccountTestService {
|
||||
return &AccountTestService{
|
||||
accountRepo: accountRepo,
|
||||
oauthService: oauthService,
|
||||
claudeUpstream: claudeUpstream,
|
||||
accountRepo: accountRepo,
|
||||
oauthService: oauthService,
|
||||
openaiOAuthService: openaiOAuthService,
|
||||
httpUpstream: httpUpstream,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +120,18 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
|
||||
return s.sendErrorAndEnd(c, "Account not found")
|
||||
}
|
||||
|
||||
// Route to platform-specific test method
|
||||
if account.IsOpenAI() {
|
||||
return s.testOpenAIAccountConnection(c, account, modelID)
|
||||
}
|
||||
|
||||
return s.testClaudeAccountConnection(c, account, modelID)
|
||||
}
|
||||
|
||||
// testClaudeAccountConnection tests an Anthropic Claude account's connection
|
||||
func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account *model.Account, modelID string) error {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Determine the model to use
|
||||
testModelID := modelID
|
||||
if testModelID == "" {
|
||||
@@ -222,7 +240,7 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.claudeUpstream.Do(req, proxyURL)
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@@ -234,11 +252,153 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
|
||||
}
|
||||
|
||||
// Process SSE stream
|
||||
return s.processStream(c, resp.Body)
|
||||
return s.processClaudeStream(c, resp.Body)
|
||||
}
|
||||
|
||||
// processStream processes the SSE stream from Claude API
|
||||
func (s *AccountTestService) processStream(c *gin.Context, body io.Reader) error {
|
||||
// testOpenAIAccountConnection tests an OpenAI account's connection
|
||||
func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account *model.Account, modelID string) error {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Default to openai.DefaultTestModel for OpenAI testing
|
||||
testModelID := modelID
|
||||
if testModelID == "" {
|
||||
testModelID = openai.DefaultTestModel
|
||||
}
|
||||
|
||||
// For API Key accounts with model mapping, map the model
|
||||
if account.Type == "apikey" {
|
||||
mapping := account.GetModelMapping()
|
||||
if len(mapping) > 0 {
|
||||
if mappedModel, exists := mapping[testModelID]; exists {
|
||||
testModelID = mappedModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine authentication method and API URL
|
||||
var authToken string
|
||||
var apiURL string
|
||||
var isOAuth bool
|
||||
var chatgptAccountID string
|
||||
|
||||
if account.IsOAuth() {
|
||||
isOAuth = true
|
||||
// OAuth - use Bearer token with ChatGPT internal API
|
||||
authToken = account.GetOpenAIAccessToken()
|
||||
if authToken == "" {
|
||||
return s.sendErrorAndEnd(c, "No access token available")
|
||||
}
|
||||
|
||||
// Check if token is expired and refresh if needed
|
||||
if account.IsOpenAITokenExpired() && s.openaiOAuthService != nil {
|
||||
tokenInfo, err := s.openaiOAuthService.RefreshAccountToken(ctx, account)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to refresh token: %s", err.Error()))
|
||||
}
|
||||
authToken = tokenInfo.AccessToken
|
||||
}
|
||||
|
||||
// OAuth uses ChatGPT internal API
|
||||
apiURL = chatgptCodexAPIURL
|
||||
chatgptAccountID = account.GetChatGPTAccountID()
|
||||
} else if account.Type == "apikey" {
|
||||
// API Key - use Platform API
|
||||
authToken = account.GetOpenAIApiKey()
|
||||
if authToken == "" {
|
||||
return s.sendErrorAndEnd(c, "No API key available")
|
||||
}
|
||||
|
||||
baseURL := account.GetOpenAIBaseURL()
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com"
|
||||
}
|
||||
apiURL = strings.TrimSuffix(baseURL, "/") + "/v1/responses"
|
||||
} else {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type))
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
c.Writer.Flush()
|
||||
|
||||
// Create OpenAI Responses API payload
|
||||
payload := createOpenAITestPayload(testModelID, isOAuth)
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
|
||||
// Send test_start event
|
||||
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(payloadBytes))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, "Failed to create request")
|
||||
}
|
||||
|
||||
// Set common headers
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||
|
||||
// Set OAuth-specific headers for ChatGPT internal API
|
||||
if isOAuth {
|
||||
req.Host = "chatgpt.com"
|
||||
req.Header.Set("accept", "text/event-stream")
|
||||
if chatgptAccountID != "" {
|
||||
req.Header.Set("chatgpt-account-id", chatgptAccountID)
|
||||
}
|
||||
}
|
||||
|
||||
// Get proxy URL
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body)))
|
||||
}
|
||||
|
||||
// Process SSE stream
|
||||
return s.processOpenAIStream(c, resp.Body)
|
||||
}
|
||||
|
||||
// createOpenAITestPayload creates a test payload for OpenAI Responses API
|
||||
func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any {
|
||||
payload := map[string]any{
|
||||
"model": modelID,
|
||||
"input": []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"content": []map[string]any{
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": "hi",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"stream": true,
|
||||
}
|
||||
|
||||
// OAuth accounts using ChatGPT internal API require store: false and instructions
|
||||
if isOAuth {
|
||||
payload["store"] = false
|
||||
payload["instructions"] = openai.DefaultInstructions
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
// processClaudeStream processes the SSE stream from Claude API
|
||||
func (s *AccountTestService) processClaudeStream(c *gin.Context, body io.Reader) error {
|
||||
reader := bufio.NewReader(body)
|
||||
|
||||
for {
|
||||
@@ -291,6 +451,59 @@ func (s *AccountTestService) processStream(c *gin.Context, body io.Reader) error
|
||||
}
|
||||
}
|
||||
|
||||
// processOpenAIStream processes the SSE stream from OpenAI Responses API
|
||||
func (s *AccountTestService) processOpenAIStream(c *gin.Context, body io.Reader) error {
|
||||
reader := bufio.NewReader(body)
|
||||
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
return nil
|
||||
}
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Stream read error: %s", err.Error()))
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
|
||||
jsonStr := strings.TrimPrefix(line, "data: ")
|
||||
if jsonStr == "[DONE]" {
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
eventType, _ := data["type"].(string)
|
||||
|
||||
switch eventType {
|
||||
case "response.output_text.delta":
|
||||
// OpenAI Responses API uses "delta" field for text content
|
||||
if delta, ok := data["delta"].(string); ok && delta != "" {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: delta})
|
||||
}
|
||||
case "response.completed":
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
return nil
|
||||
case "error":
|
||||
errorMsg := "Unknown error"
|
||||
if errData, ok := data["error"].(map[string]any); ok {
|
||||
if msg, ok := errData["message"].(string); ok {
|
||||
errorMsg = msg
|
||||
}
|
||||
}
|
||||
return s.sendErrorAndEnd(c, errorMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendEvent sends a SSE event to the client
|
||||
func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) {
|
||||
eventJSON, _ := json.Marshal(event)
|
||||
|
||||
@@ -22,7 +22,7 @@ type AdminService interface {
|
||||
CreateUser(ctx context.Context, input *CreateUserInput) (*model.User, error)
|
||||
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*model.User, 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)
|
||||
GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error)
|
||||
|
||||
@@ -71,6 +71,9 @@ type AdminService interface {
|
||||
type CreateUserInput struct {
|
||||
Email string
|
||||
Password string
|
||||
Username string
|
||||
Wechat string
|
||||
Notes string
|
||||
Balance float64
|
||||
Concurrency int
|
||||
AllowedGroups []int64
|
||||
@@ -79,6 +82,9 @@ type CreateUserInput struct {
|
||||
type UpdateUserInput struct {
|
||||
Email string
|
||||
Password string
|
||||
Username *string
|
||||
Wechat *string
|
||||
Notes *string
|
||||
Balance *float64 // 使用指针区分"未提供"和"设置为0"
|
||||
Concurrency *int // 使用指针区分"未提供"和"设置为0"
|
||||
Status string
|
||||
@@ -237,6 +243,9 @@ func (s *adminServiceImpl) GetUser(ctx context.Context, id int64) (*model.User,
|
||||
func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInput) (*model.User, error) {
|
||||
user := &model.User{
|
||||
Email: input.Email,
|
||||
Username: input.Username,
|
||||
Wechat: input.Wechat,
|
||||
Notes: input.Notes,
|
||||
Role: "user", // Always create as regular user, never admin
|
||||
Balance: input.Balance,
|
||||
Concurrency: input.Concurrency,
|
||||
@@ -262,8 +271,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
||||
return nil, errors.New("cannot disable admin user")
|
||||
}
|
||||
|
||||
// Track balance and concurrency changes for logging
|
||||
oldBalance := user.Balance
|
||||
oldConcurrency := user.Concurrency
|
||||
|
||||
if input.Email != "" {
|
||||
@@ -274,22 +281,25 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Role is not allowed to be changed via API to prevent privilege escalation
|
||||
|
||||
if input.Username != nil {
|
||||
user.Username = *input.Username
|
||||
}
|
||||
if input.Wechat != nil {
|
||||
user.Wechat = *input.Wechat
|
||||
}
|
||||
if input.Notes != nil {
|
||||
user.Notes = *input.Notes
|
||||
}
|
||||
|
||||
if input.Status != "" {
|
||||
user.Status = input.Status
|
||||
}
|
||||
|
||||
// 只在指针非 nil 时更新 Balance(支持设置为 0)
|
||||
if input.Balance != nil {
|
||||
user.Balance = *input.Balance
|
||||
}
|
||||
|
||||
// 只在指针非 nil 时更新 Concurrency(支持设置为任意值)
|
||||
if input.Concurrency != nil {
|
||||
user.Concurrency = *input.Concurrency
|
||||
}
|
||||
|
||||
// 只在指针非 nil 时更新 AllowedGroups
|
||||
if input.AllowedGroups != nil {
|
||||
user.AllowedGroups = *input.AllowedGroups
|
||||
}
|
||||
@@ -298,41 +308,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
||||
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
|
||||
if concurrencyDiff != 0 {
|
||||
code, err := model.GenerateRedeemCode()
|
||||
@@ -369,12 +344,14 @@ func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oldBalance := user.Balance
|
||||
|
||||
switch operation {
|
||||
case "set":
|
||||
user.Balance = balance
|
||||
@@ -384,11 +361,14 @@ func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64,
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 失效余额缓存
|
||||
if s.billingCacheService != nil {
|
||||
go func() {
|
||||
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
@@ -399,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
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ var (
|
||||
ErrTokenExpired = errors.New("token has expired")
|
||||
ErrEmailVerifyRequired = errors.New("email verification is required")
|
||||
ErrRegDisabled = errors.New("registration is currently disabled")
|
||||
ErrServiceUnavailable = errors.New("service temporarily unavailable")
|
||||
)
|
||||
|
||||
// JWTClaims JWT载荷数据
|
||||
@@ -90,7 +91,8 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
|
||||
// 检查邮箱是否已存在
|
||||
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("check email exists: %w", err)
|
||||
log.Printf("[Auth] Database error checking email exists: %v", err)
|
||||
return "", nil, ErrServiceUnavailable
|
||||
}
|
||||
if existsEmail {
|
||||
return "", nil, ErrEmailExists
|
||||
@@ -121,7 +123,8 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||
return "", nil, fmt.Errorf("create user: %w", err)
|
||||
log.Printf("[Auth] Database error creating user: %v", err)
|
||||
return "", nil, ErrServiceUnavailable
|
||||
}
|
||||
|
||||
// 生成token
|
||||
@@ -148,7 +151,8 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error {
|
||||
// 检查邮箱是否已存在
|
||||
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check email exists: %w", err)
|
||||
log.Printf("[Auth] Database error checking email exists: %v", err)
|
||||
return ErrServiceUnavailable
|
||||
}
|
||||
if existsEmail {
|
||||
return ErrEmailExists
|
||||
@@ -181,8 +185,8 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S
|
||||
// 检查邮箱是否已存在
|
||||
existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Error checking email exists: %v", err)
|
||||
return nil, fmt.Errorf("check email exists: %w", err)
|
||||
log.Printf("[Auth] Database error checking email exists: %v", err)
|
||||
return nil, ErrServiceUnavailable
|
||||
}
|
||||
if existsEmail {
|
||||
log.Printf("[Auth] Email already exists: %s", email)
|
||||
@@ -254,7 +258,9 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil, ErrInvalidCredentials
|
||||
}
|
||||
return "", nil, fmt.Errorf("get user by email: %w", err)
|
||||
// 记录数据库错误但不暴露给用户
|
||||
log.Printf("[Auth] Database error during login: %v", err)
|
||||
return "", nil, ErrServiceUnavailable
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
@@ -354,7 +360,8 @@ func (s *AuthService) RefreshToken(ctx context.Context, oldTokenString string) (
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
return "", fmt.Errorf("get user: %w", err)
|
||||
log.Printf("[Auth] Database error refreshing token: %v", err)
|
||||
return "", ErrServiceUnavailable
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
|
||||
@@ -24,11 +24,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ClaudeUpstream handles HTTP requests to Claude API
|
||||
type ClaudeUpstream interface {
|
||||
Do(req *http.Request, proxyURL string) (*http.Response, error)
|
||||
}
|
||||
|
||||
const (
|
||||
claudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true"
|
||||
claudeAPICountTokensURL = "https://api.anthropic.com/v1/messages/count_tokens?beta=true"
|
||||
@@ -87,7 +82,7 @@ type GatewayService struct {
|
||||
rateLimitService *RateLimitService
|
||||
billingCacheService *BillingCacheService
|
||||
identityService *IdentityService
|
||||
claudeUpstream ClaudeUpstream
|
||||
httpUpstream ports.HTTPUpstream
|
||||
}
|
||||
|
||||
// NewGatewayService creates a new GatewayService
|
||||
@@ -102,7 +97,7 @@ func NewGatewayService(
|
||||
rateLimitService *RateLimitService,
|
||||
billingCacheService *BillingCacheService,
|
||||
identityService *IdentityService,
|
||||
claudeUpstream ClaudeUpstream,
|
||||
httpUpstream ports.HTTPUpstream,
|
||||
) *GatewayService {
|
||||
return &GatewayService{
|
||||
accountRepo: accountRepo,
|
||||
@@ -115,7 +110,7 @@ func NewGatewayService(
|
||||
rateLimitService: rateLimitService,
|
||||
billingCacheService: billingCacheService,
|
||||
identityService: identityService,
|
||||
claudeUpstream: claudeUpstream,
|
||||
httpUpstream: httpUpstream,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,13 +280,13 @@ func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 获取可调度账号列表(排除限流和过载的账号)
|
||||
// 2. 获取可调度账号列表(排除限流和过载的账号,仅限 Anthropic 平台)
|
||||
var accounts []model.Account
|
||||
var err error
|
||||
if groupID != nil {
|
||||
accounts, err = s.accountRepo.ListSchedulableByGroupID(ctx, *groupID)
|
||||
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, model.PlatformAnthropic)
|
||||
} else {
|
||||
accounts, err = s.accountRepo.ListSchedulable(ctx)
|
||||
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, model.PlatformAnthropic)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query accounts failed: %w", err)
|
||||
@@ -407,7 +402,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *m
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
resp, err := s.claudeUpstream.Do(upstreamReq, proxyURL)
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upstream request failed: %w", err)
|
||||
}
|
||||
@@ -481,7 +476,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
||||
|
||||
// 设置认证头
|
||||
if tokenType == "oauth" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("authorization", "Bearer "+token)
|
||||
} else {
|
||||
req.Header.Set("x-api-key", token)
|
||||
}
|
||||
@@ -502,8 +497,8 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
||||
}
|
||||
|
||||
// 确保必要的headers存在
|
||||
if req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if req.Header.Get("content-type") == "" {
|
||||
req.Header.Set("content-type", "application/json")
|
||||
}
|
||||
if req.Header.Get("anthropic-version") == "" {
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
@@ -739,7 +734,7 @@ func (s *GatewayService) replaceModelInSSELine(line, fromModel, toModel string)
|
||||
}
|
||||
|
||||
func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) {
|
||||
// 解析message_start获取input tokens
|
||||
// 解析message_start获取input tokens(标准Claude API格式)
|
||||
var msgStart struct {
|
||||
Type string `json:"type"`
|
||||
Message struct {
|
||||
@@ -752,15 +747,30 @@ func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) {
|
||||
usage.CacheReadInputTokens = msgStart.Message.Usage.CacheReadInputTokens
|
||||
}
|
||||
|
||||
// 解析message_delta获取output tokens
|
||||
// 解析message_delta获取tokens(兼容GLM等把所有usage放在delta中的API)
|
||||
var msgDelta struct {
|
||||
Type string `json:"type"`
|
||||
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"`
|
||||
}
|
||||
if json.Unmarshal([]byte(data), &msgDelta) == nil && msgDelta.Type == "message_delta" {
|
||||
// output_tokens 总是从 message_delta 获取
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,7 +992,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
resp, err := s.claudeUpstream.Do(upstreamReq, proxyURL)
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL)
|
||||
if err != nil {
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed")
|
||||
return fmt.Errorf("upstream request failed: %w", err)
|
||||
@@ -1049,7 +1059,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
||||
|
||||
// 设置认证头
|
||||
if tokenType == "oauth" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("authorization", "Bearer "+token)
|
||||
} else {
|
||||
req.Header.Set("x-api-key", token)
|
||||
}
|
||||
@@ -1073,8 +1083,8 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
||||
}
|
||||
|
||||
// 确保必要的 headers 存在
|
||||
if req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if req.Header.Get("content-type") == "" {
|
||||
req.Header.Set("content-type", "application/json")
|
||||
}
|
||||
if req.Header.Get("anthropic-version") == "" {
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
@@ -114,12 +114,12 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *ports.Fingerpr
|
||||
return
|
||||
}
|
||||
|
||||
// 设置User-Agent
|
||||
// 设置user-agent
|
||||
if fp.UserAgent != "" {
|
||||
req.Header.Set("User-Agent", fp.UserAgent)
|
||||
req.Header.Set("user-agent", fp.UserAgent)
|
||||
}
|
||||
|
||||
// 设置x-stainless-*头(使用正确的大小写)
|
||||
// 设置x-stainless-*头
|
||||
if fp.StainlessLang != "" {
|
||||
req.Header.Set("X-Stainless-Lang", fp.StainlessLang)
|
||||
}
|
||||
|
||||
@@ -284,3 +284,8 @@ func (s *OAuthService) RefreshAccountToken(ctx context.Context, account *model.A
|
||||
|
||||
return s.RefreshToken(ctx, refreshToken, proxyURL)
|
||||
}
|
||||
|
||||
// Stop stops the session store cleanup goroutine
|
||||
func (s *OAuthService) Stop() {
|
||||
s.sessionStore.Stop()
|
||||
}
|
||||
|
||||
836
backend/internal/service/openai_gateway_service.go
Normal file
836
backend/internal/service/openai_gateway_service.go
Normal file
@@ -0,0 +1,836 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sub2api/internal/config"
|
||||
"sub2api/internal/model"
|
||||
"sub2api/internal/service/ports"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
// ChatGPT internal API for OAuth accounts
|
||||
chatgptCodexURL = "https://chatgpt.com/backend-api/codex/responses"
|
||||
// OpenAI Platform API for API Key accounts (fallback)
|
||||
openaiPlatformAPIURL = "https://api.openai.com/v1/responses"
|
||||
openaiStickySessionTTL = time.Hour // 粘性会话TTL
|
||||
)
|
||||
|
||||
// OpenAI allowed headers whitelist (for non-OAuth accounts)
|
||||
var openaiAllowedHeaders = map[string]bool{
|
||||
"accept-language": true,
|
||||
"content-type": true,
|
||||
"user-agent": true,
|
||||
"originator": 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
|
||||
type OpenAIUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"`
|
||||
CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIForwardResult represents the result of forwarding
|
||||
type OpenAIForwardResult struct {
|
||||
RequestID string
|
||||
Usage OpenAIUsage
|
||||
Model string
|
||||
Stream bool
|
||||
Duration time.Duration
|
||||
FirstTokenMs *int
|
||||
}
|
||||
|
||||
// OpenAIGatewayService handles OpenAI API gateway operations
|
||||
type OpenAIGatewayService struct {
|
||||
accountRepo ports.AccountRepository
|
||||
usageLogRepo ports.UsageLogRepository
|
||||
userRepo ports.UserRepository
|
||||
userSubRepo ports.UserSubscriptionRepository
|
||||
cache ports.GatewayCache
|
||||
cfg *config.Config
|
||||
billingService *BillingService
|
||||
rateLimitService *RateLimitService
|
||||
billingCacheService *BillingCacheService
|
||||
httpUpstream ports.HTTPUpstream
|
||||
}
|
||||
|
||||
// NewOpenAIGatewayService creates a new OpenAIGatewayService
|
||||
func NewOpenAIGatewayService(
|
||||
accountRepo ports.AccountRepository,
|
||||
usageLogRepo ports.UsageLogRepository,
|
||||
userRepo ports.UserRepository,
|
||||
userSubRepo ports.UserSubscriptionRepository,
|
||||
cache ports.GatewayCache,
|
||||
cfg *config.Config,
|
||||
billingService *BillingService,
|
||||
rateLimitService *RateLimitService,
|
||||
billingCacheService *BillingCacheService,
|
||||
httpUpstream ports.HTTPUpstream,
|
||||
) *OpenAIGatewayService {
|
||||
return &OpenAIGatewayService{
|
||||
accountRepo: accountRepo,
|
||||
usageLogRepo: usageLogRepo,
|
||||
userRepo: userRepo,
|
||||
userSubRepo: userSubRepo,
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
billingService: billingService,
|
||||
rateLimitService: rateLimitService,
|
||||
billingCacheService: billingCacheService,
|
||||
httpUpstream: httpUpstream,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateSessionHash generates session hash from header (OpenAI uses session_id header)
|
||||
func (s *OpenAIGatewayService) GenerateSessionHash(c *gin.Context) string {
|
||||
sessionID := c.GetHeader("session_id")
|
||||
if sessionID == "" {
|
||||
return ""
|
||||
}
|
||||
hash := sha256.Sum256([]byte(sessionID))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// SelectAccount selects an OpenAI account with sticky session support
|
||||
func (s *OpenAIGatewayService) SelectAccount(ctx context.Context, groupID *int64, sessionHash string) (*model.Account, error) {
|
||||
return s.SelectAccountForModel(ctx, groupID, sessionHash, "")
|
||||
}
|
||||
|
||||
// SelectAccountForModel selects an account supporting the requested model
|
||||
func (s *OpenAIGatewayService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*model.Account, error) {
|
||||
// 1. Check sticky session
|
||||
if sessionHash != "" {
|
||||
accountID, err := s.cache.GetSessionAccountID(ctx, "openai:"+sessionHash)
|
||||
if err == nil && accountID > 0 {
|
||||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||
if err == nil && account.IsSchedulable() && account.IsOpenAI() && (requestedModel == "" || account.IsModelSupported(requestedModel)) {
|
||||
// Refresh sticky session TTL
|
||||
_ = s.cache.RefreshSessionTTL(ctx, "openai:"+sessionHash, openaiStickySessionTTL)
|
||||
return account, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get schedulable OpenAI accounts
|
||||
var accounts []model.Account
|
||||
var err error
|
||||
if groupID != nil {
|
||||
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, model.PlatformOpenAI)
|
||||
} else {
|
||||
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, model.PlatformOpenAI)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query accounts failed: %w", err)
|
||||
}
|
||||
|
||||
// 3. Select by priority + LRU
|
||||
var selected *model.Account
|
||||
for i := range accounts {
|
||||
acc := &accounts[i]
|
||||
// Check model support
|
||||
if requestedModel != "" && !acc.IsModelSupported(requestedModel) {
|
||||
continue
|
||||
}
|
||||
if selected == nil {
|
||||
selected = acc
|
||||
continue
|
||||
}
|
||||
// Lower priority value means higher priority
|
||||
if acc.Priority < selected.Priority {
|
||||
selected = acc
|
||||
} else if acc.Priority == selected.Priority {
|
||||
// Same priority, select least recently used
|
||||
if acc.LastUsedAt == nil || (selected.LastUsedAt != nil && acc.LastUsedAt.Before(*selected.LastUsedAt)) {
|
||||
selected = acc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if selected == nil {
|
||||
if requestedModel != "" {
|
||||
return nil, fmt.Errorf("no available OpenAI accounts supporting model: %s", requestedModel)
|
||||
}
|
||||
return nil, errors.New("no available OpenAI accounts")
|
||||
}
|
||||
|
||||
// 4. Set sticky session
|
||||
if sessionHash != "" {
|
||||
_ = s.cache.SetSessionAccountID(ctx, "openai:"+sessionHash, selected.ID, openaiStickySessionTTL)
|
||||
}
|
||||
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
// GetAccessToken gets the access token for an OpenAI account
|
||||
func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *model.Account) (string, string, error) {
|
||||
switch account.Type {
|
||||
case model.AccountTypeOAuth:
|
||||
accessToken := account.GetOpenAIAccessToken()
|
||||
if accessToken == "" {
|
||||
return "", "", errors.New("access_token not found in credentials")
|
||||
}
|
||||
return accessToken, "oauth", nil
|
||||
case model.AccountTypeApiKey:
|
||||
apiKey := account.GetOpenAIApiKey()
|
||||
if apiKey == "" {
|
||||
return "", "", errors.New("api_key not found in credentials")
|
||||
}
|
||||
return apiKey, "apikey", nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("unsupported account type: %s", account.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Forward forwards request to OpenAI API
|
||||
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *model.Account, body []byte) (*OpenAIForwardResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Parse request body once (avoid multiple parse/serialize cycles)
|
||||
var reqBody map[string]any
|
||||
if err := json.Unmarshal(body, &reqBody); err != nil {
|
||||
return nil, fmt.Errorf("parse request: %w", err)
|
||||
}
|
||||
|
||||
// Extract model and stream from parsed body
|
||||
reqModel, _ := reqBody["model"].(string)
|
||||
reqStream, _ := reqBody["stream"].(bool)
|
||||
|
||||
// Track if body needs re-serialization
|
||||
bodyModified := false
|
||||
originalModel := reqModel
|
||||
|
||||
// Apply model mapping
|
||||
mappedModel := account.GetMappedModel(reqModel)
|
||||
if mappedModel != reqModel {
|
||||
reqBody["model"] = mappedModel
|
||||
bodyModified = true
|
||||
}
|
||||
|
||||
// For OAuth accounts using ChatGPT internal API, add store: false
|
||||
if account.Type == model.AccountTypeOAuth {
|
||||
reqBody["store"] = false
|
||||
bodyModified = true
|
||||
}
|
||||
|
||||
// Re-serialize body only if modified
|
||||
if bodyModified {
|
||||
var err error
|
||||
body, err = json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serialize request body: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get access token
|
||||
token, _, err := s.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build upstream request
|
||||
upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, body, token, reqStream)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get proxy URL
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
// Send request
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upstream request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// Handle error response
|
||||
if resp.StatusCode >= 400 {
|
||||
return s.handleErrorResponse(ctx, resp, c, account)
|
||||
}
|
||||
|
||||
// Handle normal response
|
||||
var usage *OpenAIUsage
|
||||
var firstTokenMs *int
|
||||
if reqStream {
|
||||
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, mappedModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usage = streamResult.usage
|
||||
firstTokenMs = streamResult.firstTokenMs
|
||||
} else {
|
||||
usage, err = s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, mappedModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 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{
|
||||
RequestID: resp.Header.Get("x-request-id"),
|
||||
Usage: *usage,
|
||||
Model: originalModel,
|
||||
Stream: reqStream,
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *model.Account, body []byte, token string, isStream bool) (*http.Request, error) {
|
||||
// Determine target URL based on account type
|
||||
var targetURL string
|
||||
switch account.Type {
|
||||
case model.AccountTypeOAuth:
|
||||
// OAuth accounts use ChatGPT internal API
|
||||
targetURL = chatgptCodexURL
|
||||
case model.AccountTypeApiKey:
|
||||
// API Key accounts use Platform API or custom base URL
|
||||
baseURL := account.GetOpenAIBaseURL()
|
||||
if baseURL != "" {
|
||||
targetURL = baseURL + "/v1/responses"
|
||||
} else {
|
||||
targetURL = openaiPlatformAPIURL
|
||||
}
|
||||
default:
|
||||
targetURL = openaiPlatformAPIURL
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set authentication header
|
||||
req.Header.Set("authorization", "Bearer "+token)
|
||||
|
||||
// Set headers specific to OAuth accounts (ChatGPT internal API)
|
||||
if account.Type == model.AccountTypeOAuth {
|
||||
// Required: set Host for ChatGPT API (must use req.Host, not Header.Set)
|
||||
req.Host = "chatgpt.com"
|
||||
// Required: set chatgpt-account-id header
|
||||
chatgptAccountID := account.GetChatGPTAccountID()
|
||||
if chatgptAccountID != "" {
|
||||
req.Header.Set("chatgpt-account-id", chatgptAccountID)
|
||||
}
|
||||
// Set accept header based on stream mode
|
||||
if isStream {
|
||||
req.Header.Set("accept", "text/event-stream")
|
||||
} else {
|
||||
req.Header.Set("accept", "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
// Whitelist passthrough headers
|
||||
for key, values := range c.Request.Header {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if openaiAllowedHeaders[lowerKey] {
|
||||
for _, v := range values {
|
||||
req.Header.Add(key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply custom User-Agent if configured
|
||||
customUA := account.GetOpenAIUserAgent()
|
||||
if customUA != "" {
|
||||
req.Header.Set("user-agent", customUA)
|
||||
}
|
||||
|
||||
// Ensure required headers exist
|
||||
if req.Header.Get("content-type") == "" {
|
||||
req.Header.Set("content-type", "application/json")
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *model.Account) (*OpenAIForwardResult, error) {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// Check custom error codes
|
||||
if !account.ShouldHandleErrorCode(resp.StatusCode) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "upstream_error",
|
||||
"message": "Upstream gateway error",
|
||||
},
|
||||
})
|
||||
return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Handle upstream error (mark account status)
|
||||
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
|
||||
|
||||
// Return appropriate error response
|
||||
var errType, errMsg string
|
||||
var statusCode int
|
||||
|
||||
switch resp.StatusCode {
|
||||
case 401:
|
||||
statusCode = http.StatusBadGateway
|
||||
errType = "upstream_error"
|
||||
errMsg = "Upstream authentication failed, please contact administrator"
|
||||
case 403:
|
||||
statusCode = http.StatusBadGateway
|
||||
errType = "upstream_error"
|
||||
errMsg = "Upstream access forbidden, please contact administrator"
|
||||
case 429:
|
||||
statusCode = http.StatusTooManyRequests
|
||||
errType = "rate_limit_error"
|
||||
errMsg = "Upstream rate limit exceeded, please retry later"
|
||||
default:
|
||||
statusCode = http.StatusBadGateway
|
||||
errType = "upstream_error"
|
||||
errMsg = "Upstream request failed"
|
||||
}
|
||||
|
||||
c.JSON(statusCode, gin.H{
|
||||
"error": gin.H{
|
||||
"type": errType,
|
||||
"message": errMsg,
|
||||
},
|
||||
})
|
||||
|
||||
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// openaiStreamingResult streaming response result
|
||||
type openaiStreamingResult struct {
|
||||
usage *OpenAIUsage
|
||||
firstTokenMs *int
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *model.Account, startTime time.Time, originalModel, mappedModel string) (*openaiStreamingResult, error) {
|
||||
// Set SSE response headers
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
// Pass through other headers
|
||||
if v := resp.Header.Get("x-request-id"); v != "" {
|
||||
c.Header("x-request-id", v)
|
||||
}
|
||||
|
||||
w := c.Writer
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
return nil, errors.New("streaming not supported")
|
||||
}
|
||||
|
||||
usage := &OpenAIUsage{}
|
||||
var firstTokenMs *int
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
|
||||
needModelReplace := originalModel != mappedModel
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Replace model in response if needed
|
||||
if needModelReplace && strings.HasPrefix(line, "data: ") {
|
||||
line = s.replaceModelInSSELine(line, mappedModel, originalModel)
|
||||
}
|
||||
|
||||
// Forward line
|
||||
if _, err := fmt.Fprintf(w, "%s\n", line); err != nil {
|
||||
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, err
|
||||
}
|
||||
flusher.Flush()
|
||||
|
||||
// Parse usage data
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
data := line[6:]
|
||||
// Record first token time
|
||||
if firstTokenMs == nil && data != "" && data != "[DONE]" {
|
||||
ms := int(time.Since(startTime).Milliseconds())
|
||||
firstTokenMs = &ms
|
||||
}
|
||||
s.parseSSEUsage(data, usage)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream read error: %w", err)
|
||||
}
|
||||
|
||||
return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) replaceModelInSSELine(line, fromModel, toModel string) string {
|
||||
data := line[6:]
|
||||
if data == "" || data == "[DONE]" {
|
||||
return line
|
||||
}
|
||||
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
return line
|
||||
}
|
||||
|
||||
// Replace model in response
|
||||
if m, ok := event["model"].(string); ok && m == fromModel {
|
||||
event["model"] = toModel
|
||||
newData, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
return "data: " + string(newData)
|
||||
}
|
||||
|
||||
// Check nested response
|
||||
if response, ok := event["response"].(map[string]any); ok {
|
||||
if m, ok := response["model"].(string); ok && m == fromModel {
|
||||
response["model"] = toModel
|
||||
newData, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
return "data: " + string(newData)
|
||||
}
|
||||
}
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) parseSSEUsage(data string, usage *OpenAIUsage) {
|
||||
// Parse response.completed event for usage (OpenAI Responses format)
|
||||
var event struct {
|
||||
Type string `json:"type"`
|
||||
Response struct {
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
InputTokenDetails struct {
|
||||
CachedTokens int `json:"cached_tokens"`
|
||||
} `json:"input_tokens_details"`
|
||||
} `json:"usage"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
if json.Unmarshal([]byte(data), &event) == nil && event.Type == "response.completed" {
|
||||
usage.InputTokens = event.Response.Usage.InputTokens
|
||||
usage.OutputTokens = event.Response.Usage.OutputTokens
|
||||
usage.CacheReadInputTokens = event.Response.Usage.InputTokenDetails.CachedTokens
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *model.Account, originalModel, mappedModel string) (*OpenAIUsage, error) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse usage
|
||||
var response struct {
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
InputTokenDetails struct {
|
||||
CachedTokens int `json:"cached_tokens"`
|
||||
} `json:"input_tokens_details"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
usage := &OpenAIUsage{
|
||||
InputTokens: response.Usage.InputTokens,
|
||||
OutputTokens: response.Usage.OutputTokens,
|
||||
CacheReadInputTokens: response.Usage.InputTokenDetails.CachedTokens,
|
||||
}
|
||||
|
||||
// Replace model in response if needed
|
||||
if originalModel != mappedModel {
|
||||
body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
|
||||
}
|
||||
|
||||
// Pass through headers
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Header(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
c.Data(resp.StatusCode, "application/json", body)
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel, toModel string) []byte {
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return body
|
||||
}
|
||||
|
||||
model, ok := resp["model"].(string)
|
||||
if !ok || model != fromModel {
|
||||
return body
|
||||
}
|
||||
|
||||
resp["model"] = toModel
|
||||
newBody, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
|
||||
return newBody
|
||||
}
|
||||
|
||||
// OpenAIRecordUsageInput input for recording usage
|
||||
type OpenAIRecordUsageInput struct {
|
||||
Result *OpenAIForwardResult
|
||||
ApiKey *model.ApiKey
|
||||
User *model.User
|
||||
Account *model.Account
|
||||
Subscription *model.UserSubscription
|
||||
}
|
||||
|
||||
// RecordUsage records usage and deducts balance
|
||||
func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRecordUsageInput) error {
|
||||
result := input.Result
|
||||
apiKey := input.ApiKey
|
||||
user := input.User
|
||||
account := input.Account
|
||||
subscription := input.Subscription
|
||||
|
||||
// 计算实际的新输入token(减去缓存读取的token)
|
||||
// 因为 input_tokens 包含了 cache_read_tokens,而缓存读取的token不应按输入价格计费
|
||||
actualInputTokens := result.Usage.InputTokens - result.Usage.CacheReadInputTokens
|
||||
if actualInputTokens < 0 {
|
||||
actualInputTokens = 0
|
||||
}
|
||||
|
||||
// Calculate cost
|
||||
tokens := UsageTokens{
|
||||
InputTokens: actualInputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
||||
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
||||
}
|
||||
|
||||
// Get rate multiplier
|
||||
multiplier := s.cfg.Default.RateMultiplier
|
||||
if apiKey.GroupID != nil && apiKey.Group != nil {
|
||||
multiplier = apiKey.Group.RateMultiplier
|
||||
}
|
||||
|
||||
cost, err := s.billingService.CalculateCost(result.Model, tokens, multiplier)
|
||||
if err != nil {
|
||||
cost = &CostBreakdown{ActualCost: 0}
|
||||
}
|
||||
|
||||
// Determine billing type
|
||||
isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
|
||||
billingType := model.BillingTypeBalance
|
||||
if isSubscriptionBilling {
|
||||
billingType = model.BillingTypeSubscription
|
||||
}
|
||||
|
||||
// Create usage log
|
||||
durationMs := int(result.Duration.Milliseconds())
|
||||
usageLog := &model.UsageLog{
|
||||
UserID: user.ID,
|
||||
ApiKeyID: apiKey.ID,
|
||||
AccountID: account.ID,
|
||||
RequestID: result.RequestID,
|
||||
Model: result.Model,
|
||||
InputTokens: actualInputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
||||
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
||||
InputCost: cost.InputCost,
|
||||
OutputCost: cost.OutputCost,
|
||||
CacheCreationCost: cost.CacheCreationCost,
|
||||
CacheReadCost: cost.CacheReadCost,
|
||||
TotalCost: cost.TotalCost,
|
||||
ActualCost: cost.ActualCost,
|
||||
RateMultiplier: multiplier,
|
||||
BillingType: billingType,
|
||||
Stream: result.Stream,
|
||||
DurationMs: &durationMs,
|
||||
FirstTokenMs: result.FirstTokenMs,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if apiKey.GroupID != nil {
|
||||
usageLog.GroupID = apiKey.GroupID
|
||||
}
|
||||
if subscription != nil {
|
||||
usageLog.SubscriptionID = &subscription.ID
|
||||
}
|
||||
|
||||
_ = s.usageLogRepo.Create(ctx, usageLog)
|
||||
|
||||
// Deduct based on billing type
|
||||
if isSubscriptionBilling {
|
||||
if cost.TotalCost > 0 {
|
||||
_ = s.userSubRepo.IncrementUsage(ctx, subscription.ID, cost.TotalCost)
|
||||
go func() {
|
||||
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = s.billingCacheService.UpdateSubscriptionUsage(cacheCtx, user.ID, *apiKey.GroupID, cost.TotalCost)
|
||||
}()
|
||||
}
|
||||
} else {
|
||||
if cost.ActualCost > 0 {
|
||||
_ = s.userRepo.DeductBalance(ctx, user.ID, cost.ActualCost)
|
||||
go func() {
|
||||
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = s.billingCacheService.DeductBalanceCache(cacheCtx, user.ID, cost.ActualCost)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Update account last used
|
||||
_ = s.accountRepo.UpdateLastUsed(ctx, account.ID)
|
||||
|
||||
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)
|
||||
}()
|
||||
}
|
||||
257
backend/internal/service/openai_oauth_service.go
Normal file
257
backend/internal/service/openai_oauth_service.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"sub2api/internal/model"
|
||||
"sub2api/internal/pkg/openai"
|
||||
"sub2api/internal/service/ports"
|
||||
)
|
||||
|
||||
// OpenAIOAuthService handles OpenAI OAuth authentication flows
|
||||
type OpenAIOAuthService struct {
|
||||
sessionStore *openai.SessionStore
|
||||
proxyRepo ports.ProxyRepository
|
||||
oauthClient ports.OpenAIOAuthClient
|
||||
}
|
||||
|
||||
// NewOpenAIOAuthService creates a new OpenAI OAuth service
|
||||
func NewOpenAIOAuthService(proxyRepo ports.ProxyRepository, oauthClient ports.OpenAIOAuthClient) *OpenAIOAuthService {
|
||||
return &OpenAIOAuthService{
|
||||
sessionStore: openai.NewSessionStore(),
|
||||
proxyRepo: proxyRepo,
|
||||
oauthClient: oauthClient,
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAIAuthURLResult contains the authorization URL and session info
|
||||
type OpenAIAuthURLResult struct {
|
||||
AuthURL string `json:"auth_url"`
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
// GenerateAuthURL generates an OpenAI OAuth authorization URL
|
||||
func (s *OpenAIOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64, redirectURI string) (*OpenAIAuthURLResult, error) {
|
||||
// Generate PKCE values
|
||||
state, err := openai.GenerateState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate state: %w", err)
|
||||
}
|
||||
|
||||
codeVerifier, err := openai.GenerateCodeVerifier()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate code verifier: %w", err)
|
||||
}
|
||||
|
||||
codeChallenge := openai.GenerateCodeChallenge(codeVerifier)
|
||||
|
||||
// Generate session ID
|
||||
sessionID, err := openai.GenerateSessionID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate session ID: %w", err)
|
||||
}
|
||||
|
||||
// Get proxy URL if specified
|
||||
var proxyURL string
|
||||
if proxyID != nil {
|
||||
proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
|
||||
if err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
// Use default redirect URI if not specified
|
||||
if redirectURI == "" {
|
||||
redirectURI = openai.DefaultRedirectURI
|
||||
}
|
||||
|
||||
// Store session
|
||||
session := &openai.OAuthSession{
|
||||
State: state,
|
||||
CodeVerifier: codeVerifier,
|
||||
RedirectURI: redirectURI,
|
||||
ProxyURL: proxyURL,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
s.sessionStore.Set(sessionID, session)
|
||||
|
||||
// Build authorization URL
|
||||
authURL := openai.BuildAuthorizationURL(state, codeChallenge, redirectURI)
|
||||
|
||||
return &OpenAIAuthURLResult{
|
||||
AuthURL: authURL,
|
||||
SessionID: sessionID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenAIExchangeCodeInput represents the input for code exchange
|
||||
type OpenAIExchangeCodeInput struct {
|
||||
SessionID string
|
||||
Code string
|
||||
RedirectURI string
|
||||
ProxyID *int64
|
||||
}
|
||||
|
||||
// OpenAITokenInfo represents the token information for OpenAI
|
||||
type OpenAITokenInfo struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ChatGPTAccountID string `json:"chatgpt_account_id,omitempty"`
|
||||
ChatGPTUserID string `json:"chatgpt_user_id,omitempty"`
|
||||
OrganizationID string `json:"organization_id,omitempty"`
|
||||
}
|
||||
|
||||
// ExchangeCode exchanges authorization code for tokens
|
||||
func (s *OpenAIOAuthService) ExchangeCode(ctx context.Context, input *OpenAIExchangeCodeInput) (*OpenAITokenInfo, error) {
|
||||
// Get session
|
||||
session, ok := s.sessionStore.Get(input.SessionID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("session not found or expired")
|
||||
}
|
||||
|
||||
// Get proxy URL
|
||||
proxyURL := session.ProxyURL
|
||||
if input.ProxyID != nil {
|
||||
proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID)
|
||||
if err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
// Use redirect URI from session or input
|
||||
redirectURI := session.RedirectURI
|
||||
if input.RedirectURI != "" {
|
||||
redirectURI = input.RedirectURI
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
tokenResp, err := s.oauthClient.ExchangeCode(ctx, input.Code, session.CodeVerifier, redirectURI, proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
|
||||
// Parse ID token to get user info
|
||||
var userInfo *openai.UserInfo
|
||||
if tokenResp.IDToken != "" {
|
||||
claims, err := openai.ParseIDToken(tokenResp.IDToken)
|
||||
if err == nil {
|
||||
userInfo = claims.GetUserInfo()
|
||||
}
|
||||
}
|
||||
|
||||
// Delete session after successful exchange
|
||||
s.sessionStore.Delete(input.SessionID)
|
||||
|
||||
tokenInfo := &OpenAITokenInfo{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
IDToken: tokenResp.IDToken,
|
||||
ExpiresIn: int64(tokenResp.ExpiresIn),
|
||||
ExpiresAt: time.Now().Unix() + int64(tokenResp.ExpiresIn),
|
||||
}
|
||||
|
||||
if userInfo != nil {
|
||||
tokenInfo.Email = userInfo.Email
|
||||
tokenInfo.ChatGPTAccountID = userInfo.ChatGPTAccountID
|
||||
tokenInfo.ChatGPTUserID = userInfo.ChatGPTUserID
|
||||
tokenInfo.OrganizationID = userInfo.OrganizationID
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
// RefreshToken refreshes an OpenAI OAuth token
|
||||
func (s *OpenAIOAuthService) RefreshToken(ctx context.Context, refreshToken string, proxyURL string) (*OpenAITokenInfo, error) {
|
||||
tokenResp, err := s.oauthClient.RefreshToken(ctx, refreshToken, proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse ID token to get user info
|
||||
var userInfo *openai.UserInfo
|
||||
if tokenResp.IDToken != "" {
|
||||
claims, err := openai.ParseIDToken(tokenResp.IDToken)
|
||||
if err == nil {
|
||||
userInfo = claims.GetUserInfo()
|
||||
}
|
||||
}
|
||||
|
||||
tokenInfo := &OpenAITokenInfo{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
IDToken: tokenResp.IDToken,
|
||||
ExpiresIn: int64(tokenResp.ExpiresIn),
|
||||
ExpiresAt: time.Now().Unix() + int64(tokenResp.ExpiresIn),
|
||||
}
|
||||
|
||||
if userInfo != nil {
|
||||
tokenInfo.Email = userInfo.Email
|
||||
tokenInfo.ChatGPTAccountID = userInfo.ChatGPTAccountID
|
||||
tokenInfo.ChatGPTUserID = userInfo.ChatGPTUserID
|
||||
tokenInfo.OrganizationID = userInfo.OrganizationID
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
// RefreshAccountToken refreshes token for an OpenAI account
|
||||
func (s *OpenAIOAuthService) RefreshAccountToken(ctx context.Context, account *model.Account) (*OpenAITokenInfo, error) {
|
||||
if !account.IsOpenAI() {
|
||||
return nil, fmt.Errorf("account is not an OpenAI account")
|
||||
}
|
||||
|
||||
refreshToken := account.GetOpenAIRefreshToken()
|
||||
if refreshToken == "" {
|
||||
return nil, fmt.Errorf("no refresh token available")
|
||||
}
|
||||
|
||||
var proxyURL string
|
||||
if account.ProxyID != nil {
|
||||
proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
|
||||
if err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
return s.RefreshToken(ctx, refreshToken, proxyURL)
|
||||
}
|
||||
|
||||
// BuildAccountCredentials builds credentials map from token info
|
||||
func (s *OpenAIOAuthService) BuildAccountCredentials(tokenInfo *OpenAITokenInfo) map[string]any {
|
||||
expiresAt := time.Unix(tokenInfo.ExpiresAt, 0).Format(time.RFC3339)
|
||||
|
||||
creds := map[string]any{
|
||||
"access_token": tokenInfo.AccessToken,
|
||||
"refresh_token": tokenInfo.RefreshToken,
|
||||
"expires_at": expiresAt,
|
||||
}
|
||||
|
||||
if tokenInfo.IDToken != "" {
|
||||
creds["id_token"] = tokenInfo.IDToken
|
||||
}
|
||||
if tokenInfo.Email != "" {
|
||||
creds["email"] = tokenInfo.Email
|
||||
}
|
||||
if tokenInfo.ChatGPTAccountID != "" {
|
||||
creds["chatgpt_account_id"] = tokenInfo.ChatGPTAccountID
|
||||
}
|
||||
if tokenInfo.ChatGPTUserID != "" {
|
||||
creds["chatgpt_user_id"] = tokenInfo.ChatGPTUserID
|
||||
}
|
||||
if tokenInfo.OrganizationID != "" {
|
||||
creds["organization_id"] = tokenInfo.OrganizationID
|
||||
}
|
||||
|
||||
return creds
|
||||
}
|
||||
|
||||
// Stop stops the session store cleanup goroutine
|
||||
func (s *OpenAIOAuthService) Stop() {
|
||||
s.sessionStore.Stop()
|
||||
}
|
||||
@@ -27,9 +27,12 @@ type AccountRepository interface {
|
||||
|
||||
ListSchedulable(ctx context.Context) ([]model.Account, error)
|
||||
ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]model.Account, error)
|
||||
ListSchedulableByPlatform(ctx context.Context, platform string) ([]model.Account, error)
|
||||
ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]model.Account, error)
|
||||
|
||||
SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error
|
||||
SetOverloaded(ctx context.Context, id int64, until time.Time) error
|
||||
ClearRateLimit(ctx context.Context, id int64) 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
|
||||
}
|
||||
|
||||
9
backend/internal/service/ports/http_upstream.go
Normal file
9
backend/internal/service/ports/http_upstream.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package ports
|
||||
|
||||
import "net/http"
|
||||
|
||||
// HTTPUpstream interface for making HTTP requests to upstream APIs (Claude, OpenAI, etc.)
|
||||
// This is a generic interface that can be used for any HTTP-based upstream service.
|
||||
type HTTPUpstream interface {
|
||||
Do(req *http.Request, proxyURL string) (*http.Response, error)
|
||||
}
|
||||
13
backend/internal/service/ports/openai_oauth.go
Normal file
13
backend/internal/service/ports/openai_oauth.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"sub2api/internal/pkg/openai"
|
||||
)
|
||||
|
||||
// OpenAIOAuthClient interface for OpenAI OAuth operations
|
||||
type OpenAIOAuthClient interface {
|
||||
ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL string) (*openai.TokenResponse, error)
|
||||
RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*openai.TokenResponse, error)
|
||||
}
|
||||
@@ -9,11 +9,13 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"sub2api/internal/config"
|
||||
"sub2api/internal/pkg/openai"
|
||||
)
|
||||
|
||||
// LiteLLMModelPricing LiteLLM价格数据结构
|
||||
@@ -419,8 +421,17 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 基于模型系列匹配
|
||||
return s.matchByModelFamily(modelLower)
|
||||
// 4. 基于模型系列匹配(Claude)
|
||||
if pricing := s.matchByModelFamily(modelLower); pricing != nil {
|
||||
return pricing
|
||||
}
|
||||
|
||||
// 5. OpenAI 模型回退策略
|
||||
if strings.HasPrefix(modelLower, "gpt-") {
|
||||
return s.matchOpenAIModel(modelLower)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractBaseName 提取基础模型名称(去掉日期版本号)
|
||||
@@ -514,6 +525,70 @@ func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing {
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchOpenAIModel OpenAI 模型回退匹配策略
|
||||
// 回退顺序:
|
||||
// 1. gpt-5.2-codex -> gpt-5.2(去掉后缀如 -codex, -mini, -max 等)
|
||||
// 2. gpt-5.2-20251222 -> gpt-5.2(去掉日期版本号)
|
||||
// 3. 最终回退到 DefaultTestModel (gpt-5.1-codex)
|
||||
func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing {
|
||||
// 正则匹配日期后缀 (如 -20251222)
|
||||
datePattern := regexp.MustCompile(`-\d{8}$`)
|
||||
|
||||
// 尝试的回退变体
|
||||
variants := s.generateOpenAIModelVariants(model, datePattern)
|
||||
|
||||
for _, variant := range variants {
|
||||
if pricing, ok := s.pricingData[variant]; ok {
|
||||
log.Printf("[Pricing] OpenAI fallback matched %s -> %s", model, variant)
|
||||
return pricing
|
||||
}
|
||||
}
|
||||
|
||||
// 最终回退到 DefaultTestModel
|
||||
defaultModel := strings.ToLower(openai.DefaultTestModel)
|
||||
if pricing, ok := s.pricingData[defaultModel]; ok {
|
||||
log.Printf("[Pricing] OpenAI fallback to default model %s -> %s", model, defaultModel)
|
||||
return pricing
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateOpenAIModelVariants 生成 OpenAI 模型的回退变体列表
|
||||
func (s *PricingService) generateOpenAIModelVariants(model string, datePattern *regexp.Regexp) []string {
|
||||
seen := make(map[string]bool)
|
||||
var variants []string
|
||||
|
||||
addVariant := func(v string) {
|
||||
if v != model && !seen[v] {
|
||||
seen[v] = true
|
||||
variants = append(variants, v)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 去掉日期版本号: gpt-5.2-20251222 -> gpt-5.2
|
||||
withoutDate := datePattern.ReplaceAllString(model, "")
|
||||
if withoutDate != model {
|
||||
addVariant(withoutDate)
|
||||
}
|
||||
|
||||
// 2. 提取基础版本号: gpt-5.2-codex -> gpt-5.2
|
||||
// 只匹配纯数字版本号格式 gpt-X 或 gpt-X.Y,不匹配 gpt-4o 这种带字母后缀的
|
||||
basePattern := regexp.MustCompile(`^(gpt-\d+(?:\.\d+)?)(?:-|$)`)
|
||||
if matches := basePattern.FindStringSubmatch(model); len(matches) > 1 {
|
||||
addVariant(matches[1])
|
||||
}
|
||||
|
||||
// 3. 同时去掉日期后再提取基础版本号
|
||||
if withoutDate != model {
|
||||
if matches := basePattern.FindStringSubmatch(withoutDate); len(matches) > 1 {
|
||||
addVariant(matches[1])
|
||||
}
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
|
||||
// GetStatus 获取服务状态
|
||||
func (s *PricingService) GetStatus() map[string]any {
|
||||
s.mu.RLock()
|
||||
|
||||
@@ -2,30 +2,32 @@ package service
|
||||
|
||||
// Services 服务集合容器
|
||||
type Services struct {
|
||||
Auth *AuthService
|
||||
User *UserService
|
||||
ApiKey *ApiKeyService
|
||||
Group *GroupService
|
||||
Account *AccountService
|
||||
Proxy *ProxyService
|
||||
Redeem *RedeemService
|
||||
Usage *UsageService
|
||||
Pricing *PricingService
|
||||
Billing *BillingService
|
||||
BillingCache *BillingCacheService
|
||||
Admin AdminService
|
||||
Gateway *GatewayService
|
||||
OAuth *OAuthService
|
||||
RateLimit *RateLimitService
|
||||
AccountUsage *AccountUsageService
|
||||
AccountTest *AccountTestService
|
||||
Setting *SettingService
|
||||
Email *EmailService
|
||||
EmailQueue *EmailQueueService
|
||||
Turnstile *TurnstileService
|
||||
Subscription *SubscriptionService
|
||||
Concurrency *ConcurrencyService
|
||||
Identity *IdentityService
|
||||
Update *UpdateService
|
||||
TokenRefresh *TokenRefreshService
|
||||
Auth *AuthService
|
||||
User *UserService
|
||||
ApiKey *ApiKeyService
|
||||
Group *GroupService
|
||||
Account *AccountService
|
||||
Proxy *ProxyService
|
||||
Redeem *RedeemService
|
||||
Usage *UsageService
|
||||
Pricing *PricingService
|
||||
Billing *BillingService
|
||||
BillingCache *BillingCacheService
|
||||
Admin AdminService
|
||||
Gateway *GatewayService
|
||||
OpenAIGateway *OpenAIGatewayService
|
||||
OAuth *OAuthService
|
||||
OpenAIOAuth *OpenAIOAuthService
|
||||
RateLimit *RateLimitService
|
||||
AccountUsage *AccountUsageService
|
||||
AccountTest *AccountTestService
|
||||
Setting *SettingService
|
||||
Email *EmailService
|
||||
EmailQueue *EmailQueueService
|
||||
Turnstile *TurnstileService
|
||||
Subscription *SubscriptionService
|
||||
Concurrency *ConcurrencyService
|
||||
Identity *IdentityService
|
||||
Update *UpdateService
|
||||
TokenRefresh *TokenRefreshService
|
||||
}
|
||||
|
||||
@@ -335,24 +335,67 @@ func (s *SubscriptionService) GetActiveSubscription(ctx context.Context, userID,
|
||||
|
||||
// ListUserSubscriptions 获取用户的所有订阅
|
||||
func (s *SubscriptionService) ListUserSubscriptions(ctx context.Context, userID int64) ([]model.UserSubscription, error) {
|
||||
return s.userSubRepo.ListByUserID(ctx, userID)
|
||||
subs, err := s.userSubRepo.ListByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizeExpiredWindows(subs)
|
||||
return subs, nil
|
||||
}
|
||||
|
||||
// ListActiveUserSubscriptions 获取用户的所有有效订阅
|
||||
func (s *SubscriptionService) ListActiveUserSubscriptions(ctx context.Context, userID int64) ([]model.UserSubscription, error) {
|
||||
return s.userSubRepo.ListActiveByUserID(ctx, userID)
|
||||
subs, err := s.userSubRepo.ListActiveByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizeExpiredWindows(subs)
|
||||
return subs, nil
|
||||
}
|
||||
|
||||
// ListGroupSubscriptions 获取分组的所有订阅
|
||||
func (s *SubscriptionService) ListGroupSubscriptions(ctx context.Context, groupID int64, page, pageSize int) ([]model.UserSubscription, *pagination.PaginationResult, error) {
|
||||
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
||||
return s.userSubRepo.ListByGroupID(ctx, groupID, params)
|
||||
subs, pag, err := s.userSubRepo.ListByGroupID(ctx, groupID, params)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
normalizeExpiredWindows(subs)
|
||||
return subs, pag, nil
|
||||
}
|
||||
|
||||
// List 获取所有订阅(分页,支持筛选)
|
||||
func (s *SubscriptionService) List(ctx context.Context, page, pageSize int, userID, groupID *int64, status string) ([]model.UserSubscription, *pagination.PaginationResult, error) {
|
||||
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
||||
return s.userSubRepo.List(ctx, params, userID, groupID, status)
|
||||
subs, pag, err := s.userSubRepo.List(ctx, params, userID, groupID, status)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
normalizeExpiredWindows(subs)
|
||||
return subs, pag, nil
|
||||
}
|
||||
|
||||
// normalizeExpiredWindows 将已过期窗口的数据清零(仅影响返回数据,不影响数据库)
|
||||
// 这确保前端显示正确的当前窗口状态,而不是过期窗口的历史数据
|
||||
func normalizeExpiredWindows(subs []model.UserSubscription) {
|
||||
for i := range subs {
|
||||
sub := &subs[i]
|
||||
// 日窗口过期:清零展示数据
|
||||
if sub.NeedsDailyReset() {
|
||||
sub.DailyWindowStart = nil
|
||||
sub.DailyUsageUSD = 0
|
||||
}
|
||||
// 周窗口过期:清零展示数据
|
||||
if sub.NeedsWeeklyReset() {
|
||||
sub.WeeklyWindowStart = nil
|
||||
sub.WeeklyUsageUSD = 0
|
||||
}
|
||||
// 月窗口过期:清零展示数据
|
||||
if sub.NeedsMonthlyReset() {
|
||||
sub.MonthlyWindowStart = nil
|
||||
sub.MonthlyUsageUSD = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startOfDay 返回给定时间所在日期的零点(保持原时区)
|
||||
|
||||
@@ -27,6 +27,7 @@ type TokenRefreshService struct {
|
||||
func NewTokenRefreshService(
|
||||
accountRepo ports.AccountRepository,
|
||||
oauthService *OAuthService,
|
||||
openaiOAuthService *OpenAIOAuthService,
|
||||
cfg *config.Config,
|
||||
) *TokenRefreshService {
|
||||
s := &TokenRefreshService{
|
||||
@@ -38,9 +39,7 @@ func NewTokenRefreshService(
|
||||
// 注册平台特定的刷新器
|
||||
s.refreshers = []TokenRefresher{
|
||||
NewClaudeTokenRefresher(oauthService),
|
||||
// 未来可以添加其他平台的刷新器:
|
||||
// NewOpenAITokenRefresher(...),
|
||||
// NewGeminiTokenRefresher(...),
|
||||
NewOpenAITokenRefresher(openaiOAuthService),
|
||||
}
|
||||
|
||||
return s
|
||||
|
||||
@@ -88,3 +88,54 @@ func (r *ClaudeTokenRefresher) Refresh(ctx context.Context, account *model.Accou
|
||||
|
||||
return newCredentials, nil
|
||||
}
|
||||
|
||||
// OpenAITokenRefresher 处理 OpenAI OAuth token刷新
|
||||
type OpenAITokenRefresher struct {
|
||||
openaiOAuthService *OpenAIOAuthService
|
||||
}
|
||||
|
||||
// NewOpenAITokenRefresher 创建 OpenAI token刷新器
|
||||
func NewOpenAITokenRefresher(openaiOAuthService *OpenAIOAuthService) *OpenAITokenRefresher {
|
||||
return &OpenAITokenRefresher{
|
||||
openaiOAuthService: openaiOAuthService,
|
||||
}
|
||||
}
|
||||
|
||||
// CanRefresh 检查是否能处理此账号
|
||||
// 只处理 openai 平台的 oauth 类型账号
|
||||
func (r *OpenAITokenRefresher) CanRefresh(account *model.Account) bool {
|
||||
return account.Platform == model.PlatformOpenAI &&
|
||||
account.Type == model.AccountTypeOAuth
|
||||
}
|
||||
|
||||
// NeedsRefresh 检查token是否需要刷新
|
||||
// 基于 expires_at 字段判断是否在刷新窗口内
|
||||
func (r *OpenAITokenRefresher) NeedsRefresh(account *model.Account, refreshWindow time.Duration) bool {
|
||||
expiresAt := account.GetOpenAITokenExpiresAt()
|
||||
if expiresAt == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Until(*expiresAt) < refreshWindow
|
||||
}
|
||||
|
||||
// Refresh 执行token刷新
|
||||
// 保留原有credentials中的所有字段,只更新token相关字段
|
||||
func (r *OpenAITokenRefresher) Refresh(ctx context.Context, account *model.Account) (map[string]any, error) {
|
||||
tokenInfo, err := r.openaiOAuthService.RefreshAccountToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 使用服务提供的方法构建新凭证,并保留原有字段
|
||||
newCredentials := r.openaiOAuthService.BuildAccountCredentials(tokenInfo)
|
||||
|
||||
// 保留原有credentials中非token相关字段
|
||||
for k, v := range account.Credentials {
|
||||
if _, exists := newCredentials[k]; !exists {
|
||||
newCredentials[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return newCredentials, nil
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ var (
|
||||
// UpdateProfileRequest 更新用户资料请求
|
||||
type UpdateProfileRequest struct {
|
||||
Email *string `json:"email"`
|
||||
Username *string `json:"username"`
|
||||
Wechat *string `json:"wechat"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
}
|
||||
|
||||
@@ -77,6 +79,14 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
|
||||
user.Email = *req.Email
|
||||
}
|
||||
|
||||
if req.Username != nil {
|
||||
user.Username = *req.Username
|
||||
}
|
||||
|
||||
if req.Wechat != nil {
|
||||
user.Wechat = *req.Wechat
|
||||
}
|
||||
|
||||
if req.Concurrency != nil {
|
||||
user.Concurrency = *req.Concurrency
|
||||
}
|
||||
|
||||
@@ -37,9 +37,10 @@ func ProvideEmailQueueService(emailService *EmailService) *EmailQueueService {
|
||||
func ProvideTokenRefreshService(
|
||||
accountRepo ports.AccountRepository,
|
||||
oauthService *OAuthService,
|
||||
openaiOAuthService *OpenAIOAuthService,
|
||||
cfg *config.Config,
|
||||
) *TokenRefreshService {
|
||||
svc := NewTokenRefreshService(accountRepo, oauthService, cfg)
|
||||
svc := NewTokenRefreshService(accountRepo, oauthService, openaiOAuthService, cfg)
|
||||
svc.Start()
|
||||
return svc
|
||||
}
|
||||
@@ -60,7 +61,9 @@ var ProviderSet = wire.NewSet(
|
||||
NewBillingCacheService,
|
||||
NewAdminService,
|
||||
NewGatewayService,
|
||||
NewOpenAIGatewayService,
|
||||
NewOAuthService,
|
||||
NewOpenAIOAuthService,
|
||||
NewRateLimitService,
|
||||
NewAccountUsageService,
|
||||
NewAccountTestService,
|
||||
|
||||
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 '备注说明(管理员调整时的原因说明)';
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
AccountUsageInfo,
|
||||
WindowStats,
|
||||
ClaudeModel,
|
||||
AccountUsageStatsResponse,
|
||||
} from '@/types';
|
||||
|
||||
/**
|
||||
@@ -126,27 +127,12 @@ export async function refreshCredentials(id: number): Promise<Account> {
|
||||
/**
|
||||
* Get account usage statistics
|
||||
* @param id - Account ID
|
||||
* @param period - Time period
|
||||
* @returns Account usage statistics
|
||||
* @param days - Number of days (default: 30)
|
||||
* @returns Account usage statistics with history, summary, and models
|
||||
*/
|
||||
export async function getStats(
|
||||
id: number,
|
||||
period: string = 'month'
|
||||
): Promise<{
|
||||
total_requests: number;
|
||||
successful_requests: number;
|
||||
failed_requests: number;
|
||||
total_tokens: number;
|
||||
average_response_time: number;
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
total_requests: number;
|
||||
successful_requests: number;
|
||||
failed_requests: number;
|
||||
total_tokens: number;
|
||||
average_response_time: number;
|
||||
}>(`/admin/accounts/${id}/stats`, {
|
||||
params: { period },
|
||||
export async function getStats(id: number, days: number = 30): Promise<AccountUsageStatsResponse> {
|
||||
const { data } = await apiClient.get<AccountUsageStatsResponse>(`/admin/accounts/${id}/stats`, {
|
||||
params: { days },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -84,16 +84,19 @@ export async function deleteUser(id: number): Promise<{ message: string }> {
|
||||
* @param id - User ID
|
||||
* @param balance - New balance
|
||||
* @param operation - Operation type ('set', 'add', 'subtract')
|
||||
* @param notes - Optional notes for the balance adjustment
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function updateBalance(
|
||||
id: number,
|
||||
balance: number,
|
||||
operation: 'set' | 'add' | 'subtract' = 'set'
|
||||
operation: 'set' | 'add' | 'subtract' = 'set',
|
||||
notes?: string
|
||||
): Promise<User> {
|
||||
const { data } = await apiClient.post<User>(`/admin/users/${id}/balance`, {
|
||||
balance,
|
||||
operation,
|
||||
notes: notes || '',
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,20 @@ import type { User, ChangePasswordRequest } from '@/types';
|
||||
* @returns User profile data
|
||||
*/
|
||||
export async function getProfile(): Promise<User> {
|
||||
const { data } = await apiClient.get<User>('/users/me');
|
||||
const { data } = await apiClient.get<User>('/user/profile');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current user profile
|
||||
* @param profile - Profile data to update
|
||||
* @returns Updated user profile data
|
||||
*/
|
||||
export async function updateProfile(profile: {
|
||||
username?: string;
|
||||
wechat?: string;
|
||||
}): Promise<User> {
|
||||
const { data } = await apiClient.put<User>('/user', profile);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -29,12 +42,13 @@ export async function changePassword(
|
||||
new_password: newPassword,
|
||||
};
|
||||
|
||||
const { data } = await apiClient.post<{ message: string }>('/users/me/password', payload);
|
||||
const { data } = await apiClient.put<{ message: string }>('/user/password', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const userAPI = {
|
||||
getProfile,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
};
|
||||
|
||||
|
||||
546
frontend/src/components/account/AccountStatsModal.vue
Normal file
546
frontend/src/components/account/AccountStatsModal.vue
Normal file
@@ -0,0 +1,546 @@
|
||||
<template>
|
||||
<Modal
|
||||
:show="show"
|
||||
:title="t('admin.accounts.usageStatistics')"
|
||||
size="2xl"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<!-- Account Info Header -->
|
||||
<div v-if="account" class="flex items-center justify-between p-3 bg-gradient-to-r from-primary-50 to-primary-100 dark:from-primary-900/20 dark:to-primary-800/20 rounded-xl border border-primary-200 dark:border-primary-700/50">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.last30DaysUsage') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'px-2.5 py-1 text-xs font-semibold rounded-full',
|
||||
account.status === 'active'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
{{ account.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
<template v-else-if="stats">
|
||||
<!-- Row 1: Main Stats Cards -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- 30-Day Total Cost -->
|
||||
<div class="card p-4 bg-gradient-to-br from-emerald-50 to-white dark:from-emerald-900/10 dark:to-dark-700 border-emerald-200 dark:border-emerald-800/30">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.totalCost') }}</span>
|
||||
<div class="p-1.5 rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<svg class="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">${{ formatCost(stats.summary.total_cost) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ t('admin.accounts.stats.accumulatedCost') }}
|
||||
<span class="text-gray-400 dark:text-gray-500">({{ t('admin.accounts.stats.standardCost') }}: ${{ formatCost(stats.summary.total_standard_cost) }})</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 30-Day Total Requests -->
|
||||
<div class="card p-4 bg-gradient-to-br from-blue-50 to-white dark:from-blue-900/10 dark:to-dark-700 border-blue-200 dark:border-blue-800/30">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.totalRequests') }}</span>
|
||||
<div class="p-1.5 rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ formatNumber(stats.summary.total_requests) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.stats.totalCalls') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Daily Average Cost -->
|
||||
<div class="card p-4 bg-gradient-to-br from-amber-50 to-white dark:from-amber-900/10 dark:to-dark-700 border-amber-200 dark:border-amber-800/30">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.avgDailyCost') }}</span>
|
||||
<div class="p-1.5 rounded-lg bg-amber-100 dark:bg-amber-900/30">
|
||||
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">${{ formatCost(stats.summary.avg_daily_cost) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.stats.basedOnActualDays', { days: stats.summary.actual_days_used }) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Daily Average Requests -->
|
||||
<div class="card p-4 bg-gradient-to-br from-purple-50 to-white dark:from-purple-900/10 dark:to-dark-700 border-purple-200 dark:border-purple-800/30">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.avgDailyRequests') }}</span>
|
||||
<div class="p-1.5 rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
||||
<svg class="w-4 h-4 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ formatNumber(Math.round(stats.summary.avg_daily_requests)) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.stats.avgDailyUsage') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Today, Highest Cost, Highest Requests -->
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<!-- Today Overview -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-cyan-100 dark:bg-cyan-900/30">
|
||||
<svg class="w-4 h-4 text-cyan-600 dark:text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.todayOverview') }}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.cost') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">${{ formatCost(stats.summary.today?.cost || 0) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.requests') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatNumber(stats.summary.today?.requests || 0) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Tokens</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatTokens(stats.summary.today?.tokens || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highest Cost Day -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-orange-100 dark:bg-orange-900/30">
|
||||
<svg class="w-4 h-4 text-orange-600 dark:text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.highestCostDay') }}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.date') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ stats.summary.highest_cost_day?.label || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.cost') }}</span>
|
||||
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400">${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.requests') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatNumber(stats.summary.highest_cost_day?.requests || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highest Request Day -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-indigo-100 dark:bg-indigo-900/30">
|
||||
<svg class="w-4 h-4 text-indigo-600 dark:text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.highestRequestDay') }}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.date') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ stats.summary.highest_request_day?.label || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.requests') }}</span>
|
||||
<span class="text-sm font-semibold text-indigo-600 dark:text-indigo-400">{{ formatNumber(stats.summary.highest_request_day?.requests || 0) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.cost') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Token Stats -->
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<!-- Accumulated Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-teal-100 dark:bg-teal-900/30">
|
||||
<svg class="w-4 h-4 text-teal-600 dark:text-teal-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.accumulatedTokens') }}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.totalTokens') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatTokens(stats.summary.total_tokens) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.dailyAvgTokens') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatTokens(Math.round(stats.summary.avg_daily_tokens)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-rose-100 dark:bg-rose-900/30">
|
||||
<svg class="w-4 h-4 text-rose-600 dark:text-rose-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.performance') }}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.avgResponseTime') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatDuration(stats.summary.avg_duration_ms) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.daysActive') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ stats.summary.actual_days_used }} / {{ stats.summary.days }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-lime-100 dark:bg-lime-900/30">
|
||||
<svg class="w-4 h-4 text-lime-600 dark:text-lime-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.recentActivity') }}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.todayRequests') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatNumber(stats.summary.today?.requests || 0) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.todayTokens') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatTokens(stats.summary.today?.tokens || 0) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.todayCost') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">${{ formatCost(stats.summary.today?.cost || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Trend Chart -->
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.accounts.stats.usageTrend') }}</h3>
|
||||
<div class="h-64">
|
||||
<Line v-if="trendChartData" :data="trendChartData" :options="lineChartOptions" />
|
||||
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Distribution -->
|
||||
<ModelDistributionChart
|
||||
:model-stats="stats.models"
|
||||
:loading="false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- No Data State -->
|
||||
<div v-else-if="!loading" class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-12 h-12 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<p class="text-sm">{{ t('admin.accounts.stats.noData') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-dark-600 hover:bg-gray-200 dark:hover:bg-dark-500 rounded-lg transition-colors"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, AccountUsageStatsResponse } from '@/types'
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
account: Account | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const stats = ref<AccountUsageStatsResponse | null>(null)
|
||||
|
||||
// Dark mode detection
|
||||
const isDarkMode = computed(() => {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
})
|
||||
|
||||
// Chart colors
|
||||
const chartColors = computed(() => ({
|
||||
text: isDarkMode.value ? '#e5e7eb' : '#374151',
|
||||
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
|
||||
}))
|
||||
|
||||
// Line chart data
|
||||
const trendChartData = computed(() => {
|
||||
if (!stats.value?.history?.length) return null
|
||||
|
||||
return {
|
||||
labels: stats.value.history.map(h => h.label),
|
||||
datasets: [
|
||||
{
|
||||
label: t('admin.accounts.stats.cost') + ' (USD)',
|
||||
data: stats.value.history.map(h => h.cost),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y',
|
||||
},
|
||||
{
|
||||
label: t('admin.accounts.stats.requests'),
|
||||
data: stats.value.history.map(h => h.requests),
|
||||
borderColor: '#f97316',
|
||||
backgroundColor: 'rgba(249, 115, 22, 0.1)',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y1',
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// Line chart options with dual Y-axis
|
||||
const lineChartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index' as const,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: chartColors.value.text,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
padding: 15,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const label = context.dataset.label || ''
|
||||
const value = context.raw
|
||||
if (label.includes('USD')) {
|
||||
return `${label}: $${formatCost(value)}`
|
||||
}
|
||||
return `${label}: ${formatNumber(value)}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.value.text,
|
||||
font: {
|
||||
size: 10,
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'left' as const,
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
},
|
||||
ticks: {
|
||||
color: '#3b82f6',
|
||||
font: {
|
||||
size: 10,
|
||||
},
|
||||
callback: (value: string | number) => '$' + formatCost(Number(value)),
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('admin.accounts.stats.cost') + ' (USD)',
|
||||
color: '#3b82f6',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'right' as const,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
color: '#f97316',
|
||||
font: {
|
||||
size: 10,
|
||||
},
|
||||
callback: (value: string | number) => formatNumber(Number(value)),
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('admin.accounts.stats.requests'),
|
||||
color: '#f97316',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Load stats when modal opens
|
||||
watch(() => props.show, async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
await loadStats()
|
||||
} else {
|
||||
stats.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const loadStats = async () => {
|
||||
if (!props.account) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
stats.value = await adminAPI.accounts.getStats(props.account.id, 30)
|
||||
} catch (error) {
|
||||
console.error('Failed to load account stats:', error)
|
||||
stats.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Format helpers
|
||||
const formatCost = (value: number): string => {
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(2) + 'K'
|
||||
} else if (value >= 1) {
|
||||
return value.toFixed(2)
|
||||
} else if (value >= 0.01) {
|
||||
return value.toFixed(3)
|
||||
}
|
||||
return value.toFixed(4)
|
||||
}
|
||||
|
||||
const formatNumber = (value: number): string => {
|
||||
if (value >= 1_000_000) {
|
||||
return (value / 1_000_000).toFixed(2) + 'M'
|
||||
} else if (value >= 1_000) {
|
||||
return (value / 1_000).toFixed(2) + 'K'
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(2)}B`
|
||||
} else if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(2)}M`
|
||||
} else if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(2)}K`
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms >= 1000) {
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="account.type === 'oauth' || account.type === 'setup-token'">
|
||||
<!-- OAuth accounts: fetch real usage data -->
|
||||
<template v-if="account.type === 'oauth'">
|
||||
<div v-if="showUsageWindows">
|
||||
<!-- Anthropic OAuth accounts: fetch real usage data -->
|
||||
<template v-if="account.platform === 'anthropic' && account.type === 'oauth'">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="space-y-1.5">
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -63,20 +63,49 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Setup Token accounts: show time-based window progress -->
|
||||
<template v-else-if="account.type === 'setup-token'">
|
||||
<!-- Anthropic Setup Token accounts: show time-based window progress -->
|
||||
<template v-else-if="account.platform === 'anthropic' && account.type === 'setup-token'">
|
||||
<SetupTokenTimeWindow :account="account" />
|
||||
</template>
|
||||
|
||||
<!-- OpenAI OAuth accounts: show Codex usage from extra field -->
|
||||
<template v-else-if="account.platform === 'openai' && account.type === 'oauth'">
|
||||
<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>
|
||||
|
||||
<!-- Other accounts: no usage window -->
|
||||
<template v-else>
|
||||
<div class="text-xs text-gray-400">-</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Non-OAuth accounts -->
|
||||
<!-- Non-OAuth/Setup-Token accounts -->
|
||||
<div v-else class="text-xs text-gray-400">
|
||||
-
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, AccountUsageInfo } from '@/types'
|
||||
import UsageProgressBar from './UsageProgressBar.vue'
|
||||
@@ -90,9 +119,50 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const usageInfo = ref<AccountUsageInfo | null>(null)
|
||||
|
||||
// Show usage windows for OAuth and Setup Token accounts
|
||||
const showUsageWindows = computed(() =>
|
||||
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 () => {
|
||||
// Only fetch usage for OAuth accounts (Setup Token uses local time-based calculation)
|
||||
if (props.account.type !== 'oauth') return
|
||||
// Only fetch usage for Anthropic OAuth accounts
|
||||
// OpenAI usage comes from account.extra field (updated during forwarding)
|
||||
if (props.account.platform !== 'anthropic' || props.account.type !== 'oauth') return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
@@ -47,83 +47,161 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Platform Selection - Segmented Control Style -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
<div class="grid grid-cols-2 gap-3 mt-2">
|
||||
<label
|
||||
<label class="input-label">{{ t('admin.accounts.platform') }}</label>
|
||||
<div class="flex rounded-lg bg-gray-100 dark:bg-dark-700 p-1 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="form.platform = 'anthropic'"
|
||||
:class="[
|
||||
'relative flex cursor-pointer rounded-lg border-2 p-4 transition-all',
|
||||
accountCategory === 'oauth-based'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 dark:border-dark-600 hover:border-primary-300'
|
||||
'flex-1 flex items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
|
||||
form.platform === 'anthropic'
|
||||
? 'bg-white dark:bg-dark-600 text-orange-600 dark:text-orange-400 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
]"
|
||||
>
|
||||
<input
|
||||
v-model="accountCategory"
|
||||
type="radio"
|
||||
value="oauth-based"
|
||||
class="sr-only"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500 to-orange-600">
|
||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.claudeCode') }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauthSetupToken') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="accountCategory === 'oauth-based'"
|
||||
class="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-primary-500"
|
||||
>
|
||||
<svg class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
|
||||
</svg>
|
||||
Anthropic
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="form.platform = 'openai'"
|
||||
:class="[
|
||||
'relative flex cursor-pointer rounded-lg border-2 p-4 transition-all',
|
||||
accountCategory === 'apikey'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 dark:border-dark-600 hover:border-primary-300'
|
||||
'flex-1 flex items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
|
||||
form.platform === 'openai'
|
||||
? 'bg-white dark:bg-dark-600 text-green-600 dark:text-green-400 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
]"
|
||||
>
|
||||
<input
|
||||
v-model="accountCategory"
|
||||
type="radio"
|
||||
value="apikey"
|
||||
class="sr-only"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-purple-500 to-purple-600">
|
||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.claudeConsole') }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.apiKey') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="accountCategory === 'apikey'"
|
||||
class="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-primary-500"
|
||||
>
|
||||
<svg class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</label>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
|
||||
</svg>
|
||||
OpenAI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Method (only for OAuth-based type) -->
|
||||
<div v-if="isOAuthFlow">
|
||||
<!-- Account Type Selection (Anthropic) -->
|
||||
<div v-if="form.platform === 'anthropic'">
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
<div class="grid grid-cols-2 gap-3 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'oauth-based'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 transition-all text-left',
|
||||
accountCategory === 'oauth-based'
|
||||
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/20'
|
||||
: 'border-gray-200 dark:border-dark-600 hover:border-orange-300 dark:hover:border-orange-700'
|
||||
]"
|
||||
>
|
||||
<div :class="[
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||
accountCategory === 'oauth-based'
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
|
||||
]">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.claudeCode') }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauthSetupToken') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'apikey'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 transition-all text-left',
|
||||
accountCategory === 'apikey'
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 dark:border-dark-600 hover:border-purple-300 dark:hover:border-purple-700'
|
||||
]"
|
||||
>
|
||||
<div :class="[
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||
accountCategory === 'apikey'
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
|
||||
]">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.claudeConsole') }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.apiKey') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Type Selection (OpenAI) -->
|
||||
<div v-if="form.platform === 'openai'">
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
<div class="grid grid-cols-2 gap-3 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'oauth-based'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 transition-all text-left',
|
||||
accountCategory === 'oauth-based'
|
||||
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
|
||||
: 'border-gray-200 dark:border-dark-600 hover:border-green-300 dark:hover:border-green-700'
|
||||
]"
|
||||
>
|
||||
<div :class="[
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||
accountCategory === 'oauth-based'
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
|
||||
]">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<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 OAuth</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'apikey'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 transition-all text-left',
|
||||
accountCategory === 'apikey'
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 dark:border-dark-600 hover:border-purple-300 dark:hover:border-purple-700'
|
||||
]"
|
||||
>
|
||||
<div :class="[
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||
accountCategory === 'apikey'
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
|
||||
]">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Responses API</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Method (only for Anthropic OAuth-based type) -->
|
||||
<div v-if="form.platform === 'anthropic' && isOAuthFlow">
|
||||
<label class="input-label">{{ t('admin.accounts.addMethod') }}</label>
|
||||
<div class="flex gap-4 mt-2">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
@@ -155,7 +233,7 @@
|
||||
v-model="apiKeyBaseUrl"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="https://api.anthropic.com"
|
||||
:placeholder="form.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
|
||||
</div>
|
||||
@@ -166,7 +244,7 @@
|
||||
type="password"
|
||||
required
|
||||
class="input font-mono"
|
||||
:placeholder="t('admin.accounts.apiKeyPlaceholder')"
|
||||
:placeholder="form.platform === 'openai' ? 'sk-proj-...' : 'sk-ant-...'"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.apiKeyHint') }}</p>
|
||||
</div>
|
||||
@@ -418,8 +496,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intercept Warmup Requests (all account types) -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<!-- Intercept Warmup Requests (Anthropic only) -->
|
||||
<div v-if="form.platform === 'anthropic'" class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.interceptWarmupRequests') }}</label>
|
||||
@@ -477,6 +555,7 @@
|
||||
<GroupSelector
|
||||
v-model="form.group_ids"
|
||||
:groups="groups"
|
||||
:platform="form.platform"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
@@ -510,14 +589,16 @@
|
||||
<div v-else class="space-y-5">
|
||||
<OAuthAuthorizationFlow
|
||||
ref="oauthFlowRef"
|
||||
:add-method="addMethod"
|
||||
:auth-url="oauth.authUrl.value"
|
||||
:session-id="oauth.sessionId.value"
|
||||
:loading="oauth.loading.value"
|
||||
:error="oauth.error.value"
|
||||
:show-help="true"
|
||||
:add-method="form.platform === 'openai' ? 'oauth' : addMethod"
|
||||
:auth-url="currentAuthUrl"
|
||||
:session-id="currentSessionId"
|
||||
:loading="currentOAuthLoading"
|
||||
:error="currentOAuthError"
|
||||
:show-help="form.platform !== 'openai'"
|
||||
:show-proxy-warning="!!form.proxy_id"
|
||||
:allow-multiple="true"
|
||||
:allow-multiple="form.platform !== 'openai'"
|
||||
:show-cookie-option="form.platform !== 'openai'"
|
||||
:platform="form.platform"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@cookie-auth="handleCookieAuth"
|
||||
/>
|
||||
@@ -538,7 +619,7 @@
|
||||
@click="handleExchangeCode"
|
||||
>
|
||||
<svg
|
||||
v-if="oauth.loading.value"
|
||||
v-if="currentOAuthLoading"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -546,7 +627,7 @@
|
||||
<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>
|
||||
{{ oauth.loading.value ? t('admin.accounts.oauth.verifying') : t('admin.accounts.oauth.completeAuth') }}
|
||||
{{ currentOAuthLoading ? t('admin.accounts.oauth.verifying') : t('admin.accounts.oauth.completeAuth') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -559,6 +640,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { useAccountOAuth, type AddMethod, type AuthInputMethod } from '@/composables/useAccountOAuth'
|
||||
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
|
||||
import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
@@ -590,8 +672,26 @@ const emit = defineEmits<{
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
// OAuth composable
|
||||
const oauth = useAccountOAuth()
|
||||
// OAuth composables
|
||||
const oauth = useAccountOAuth() // For Anthropic OAuth
|
||||
const openaiOAuth = useOpenAIOAuth() // For OpenAI OAuth
|
||||
|
||||
// Computed: current OAuth state for template binding
|
||||
const currentAuthUrl = computed(() => {
|
||||
return form.platform === 'openai' ? openaiOAuth.authUrl.value : oauth.authUrl.value
|
||||
})
|
||||
|
||||
const currentSessionId = computed(() => {
|
||||
return form.platform === 'openai' ? openaiOAuth.sessionId.value : oauth.sessionId.value
|
||||
})
|
||||
|
||||
const currentOAuthLoading = computed(() => {
|
||||
return form.platform === 'openai' ? openaiOAuth.loading.value : oauth.loading.value
|
||||
})
|
||||
|
||||
const currentOAuthError = computed(() => {
|
||||
return form.platform === 'openai' ? openaiOAuth.error.value : oauth.error.value
|
||||
})
|
||||
|
||||
// Refs
|
||||
const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
|
||||
@@ -617,8 +717,8 @@ const selectedErrorCodes = ref<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(null)
|
||||
const interceptWarmupRequests = ref(false)
|
||||
|
||||
// Common models for whitelist
|
||||
const commonModels = [
|
||||
// Common models for whitelist - Anthropic
|
||||
const anthropicModels = [
|
||||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
|
||||
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
|
||||
@@ -629,8 +729,24 @@ const commonModels = [
|
||||
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' }
|
||||
]
|
||||
|
||||
// Preset mappings for quick add
|
||||
const presetMappings = [
|
||||
// Common models for whitelist - OpenAI
|
||||
const openaiModels = [
|
||||
{ value: 'gpt-5.2-2025-12-11', label: 'GPT-5.2' },
|
||||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
|
||||
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
|
||||
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' }
|
||||
]
|
||||
|
||||
// Computed: current models based on platform
|
||||
const commonModels = computed(() => {
|
||||
return form.platform === 'openai' ? openaiModels : anthropicModels
|
||||
})
|
||||
|
||||
// Preset mappings for quick add - Anthropic
|
||||
const anthropicPresetMappings = [
|
||||
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: 'Opus 4.5', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
@@ -639,6 +755,21 @@ const presetMappings = [
|
||||
{ label: 'Opus->Sonnet', from: 'claude-opus-4-5-20251101', to: 'claude-sonnet-4-5-20250929', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
|
||||
]
|
||||
|
||||
// Preset mappings for quick add - OpenAI
|
||||
const openaiPresetMappings = [
|
||||
{ label: 'GPT-5.2', from: 'gpt-5.2-2025-12-11', to: 'gpt-5.2-2025-12-11', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
|
||||
{ label: 'GPT-5.2 Codex', from: 'gpt-5.2-codex', to: 'gpt-5.2-codex', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ label: 'GPT-5.1 Codex', from: 'gpt-5.1-codex', to: 'gpt-5.1-codex', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: 'Codex Max', from: 'gpt-5.1-codex-max', to: 'gpt-5.1-codex-max', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Codex Mini', from: 'gpt-5.1-codex-mini', to: 'gpt-5.1-codex-mini', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: 'Max->Codex', from: 'gpt-5.1-codex-max', to: 'gpt-5.1-codex', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
|
||||
]
|
||||
|
||||
// Computed: current preset mappings based on platform
|
||||
const presetMappings = computed(() => {
|
||||
return form.platform === 'openai' ? openaiPresetMappings : anthropicPresetMappings
|
||||
})
|
||||
|
||||
// Common HTTP error codes for quick selection
|
||||
const commonErrorCodes = [
|
||||
{ value: 401, label: 'Unauthorized' },
|
||||
@@ -670,6 +801,9 @@ const isManualInputMethod = computed(() => {
|
||||
|
||||
const canExchangeCode = computed(() => {
|
||||
const authCode = oauthFlowRef.value?.authCode || ''
|
||||
if (form.platform === 'openai') {
|
||||
return authCode.trim() && openaiOAuth.sessionId.value && !openaiOAuth.loading.value
|
||||
}
|
||||
return authCode.trim() && oauth.sessionId.value && !oauth.loading.value
|
||||
})
|
||||
|
||||
@@ -689,6 +823,20 @@ watch([accountCategory, addMethod], ([category, method]) => {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Reset platform-specific settings when platform changes
|
||||
watch(() => form.platform, (newPlatform) => {
|
||||
// Reset base URL based on platform
|
||||
apiKeyBaseUrl.value = newPlatform === 'openai'
|
||||
? 'https://api.openai.com'
|
||||
: 'https://api.anthropic.com'
|
||||
// Clear model-related settings
|
||||
allowedModels.value = []
|
||||
modelMappings.value = []
|
||||
// Reset OAuth states
|
||||
oauth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
})
|
||||
|
||||
// Model mapping helpers
|
||||
const addModelMapping = () => {
|
||||
modelMappings.value.push({ from: '', to: '' })
|
||||
@@ -786,6 +934,7 @@ const resetForm = () => {
|
||||
customErrorCodeInput.value = null
|
||||
interceptWarmupRequests.value = false
|
||||
oauth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
}
|
||||
|
||||
@@ -810,9 +959,14 @@ const handleSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine default base URL based on platform
|
||||
const defaultBaseUrl = form.platform === 'openai'
|
||||
? 'https://api.openai.com'
|
||||
: 'https://api.anthropic.com'
|
||||
|
||||
// Build credentials with optional model mapping
|
||||
const credentials: Record<string, unknown> = {
|
||||
base_url: apiKeyBaseUrl.value.trim() || 'https://api.anthropic.com',
|
||||
base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl,
|
||||
api_key: apiKeyValue.value.trim()
|
||||
}
|
||||
|
||||
@@ -837,7 +991,10 @@ const handleSubmit = async () => {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.accounts.create(form)
|
||||
await adminAPI.accounts.create({
|
||||
...form,
|
||||
group_ids: form.group_ids
|
||||
})
|
||||
appStore.showSuccess(t('admin.accounts.accountCreated'))
|
||||
emit('created')
|
||||
handleClose()
|
||||
@@ -851,15 +1008,72 @@ const handleSubmit = async () => {
|
||||
const goBackToBasicInfo = () => {
|
||||
step.value = 1
|
||||
oauth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
}
|
||||
|
||||
const handleGenerateUrl = async () => {
|
||||
await oauth.generateAuthUrl(addMethod.value, form.proxy_id)
|
||||
if (form.platform === 'openai') {
|
||||
await openaiOAuth.generateAuthUrl(form.proxy_id)
|
||||
} else {
|
||||
await oauth.generateAuthUrl(addMethod.value, form.proxy_id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExchangeCode = async () => {
|
||||
const authCode = oauthFlowRef.value?.authCode || ''
|
||||
|
||||
// For OpenAI
|
||||
if (form.platform === 'openai') {
|
||||
if (!authCode.trim() || !openaiOAuth.sessionId.value) return
|
||||
|
||||
openaiOAuth.loading.value = true
|
||||
openaiOAuth.error.value = ''
|
||||
|
||||
try {
|
||||
const tokenInfo = await openaiOAuth.exchangeAuthCode(
|
||||
authCode.trim(),
|
||||
openaiOAuth.sessionId.value,
|
||||
form.proxy_id
|
||||
)
|
||||
|
||||
if (!tokenInfo) {
|
||||
return // Error already handled by composable
|
||||
}
|
||||
|
||||
const credentials = openaiOAuth.buildCredentials(tokenInfo)
|
||||
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
|
||||
|
||||
// Merge interceptWarmupRequests into credentials
|
||||
if (interceptWarmupRequests.value) {
|
||||
credentials.intercept_warmup_requests = true
|
||||
}
|
||||
|
||||
await adminAPI.accounts.create({
|
||||
name: form.name,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
credentials,
|
||||
extra,
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
priority: form.priority,
|
||||
group_ids: form.group_ids
|
||||
})
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.accountCreated'))
|
||||
emit('created')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(openaiOAuth.error.value)
|
||||
} finally {
|
||||
openaiOAuth.loading.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For Anthropic
|
||||
if (!authCode.trim() || !oauth.sessionId.value) return
|
||||
|
||||
oauth.loading.value = true
|
||||
@@ -893,7 +1107,8 @@ const handleExchangeCode = async () => {
|
||||
extra,
|
||||
proxy_id: form.proxy_id,
|
||||
concurrency: form.concurrency,
|
||||
priority: form.priority
|
||||
priority: form.priority,
|
||||
group_ids: form.group_ids
|
||||
})
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.accountCreated'))
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
v-model="editBaseUrl"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="https://api.anthropic.com"
|
||||
:placeholder="account.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@
|
||||
v-model="editApiKey"
|
||||
type="password"
|
||||
class="input font-mono"
|
||||
:placeholder="t('admin.accounts.leaveEmptyToKeep')"
|
||||
:placeholder="account.platform === 'openai' ? 'sk-proj-...' : 'sk-ant-...'"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
|
||||
</div>
|
||||
@@ -286,8 +286,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intercept Warmup Requests (all account types) -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<!-- Intercept Warmup Requests (Anthropic only) -->
|
||||
<div v-if="account?.platform === 'anthropic'" class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.interceptWarmupRequests') }}</label>
|
||||
@@ -352,6 +352,7 @@
|
||||
<GroupSelector
|
||||
v-model="form.group_ids"
|
||||
:groups="groups"
|
||||
:platform="account?.platform"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
@@ -428,8 +429,8 @@ const selectedErrorCodes = ref<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(null)
|
||||
const interceptWarmupRequests = ref(false)
|
||||
|
||||
// Common models for whitelist
|
||||
const commonModels = [
|
||||
// Common models for whitelist - Anthropic
|
||||
const anthropicModels = [
|
||||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
|
||||
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
|
||||
@@ -440,8 +441,24 @@ const commonModels = [
|
||||
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' }
|
||||
]
|
||||
|
||||
// Preset mappings for quick add
|
||||
const presetMappings = [
|
||||
// Common models for whitelist - OpenAI
|
||||
const openaiModels = [
|
||||
{ value: 'gpt-5.2-2025-12-11', label: 'GPT-5.2' },
|
||||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
|
||||
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
|
||||
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' }
|
||||
]
|
||||
|
||||
// Computed: current models based on platform
|
||||
const commonModels = computed(() => {
|
||||
return props.account?.platform === 'openai' ? openaiModels : anthropicModels
|
||||
})
|
||||
|
||||
// Preset mappings for quick add - Anthropic
|
||||
const anthropicPresetMappings = [
|
||||
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: 'Opus 4.5', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
@@ -450,6 +467,26 @@ const presetMappings = [
|
||||
{ label: 'Opus->Sonnet', from: 'claude-opus-4-5-20251101', to: 'claude-sonnet-4-5-20250929', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
|
||||
]
|
||||
|
||||
// Preset mappings for quick add - OpenAI
|
||||
const openaiPresetMappings = [
|
||||
{ label: 'GPT-5.2', from: 'gpt-5.2-2025-12-11', to: 'gpt-5.2-2025-12-11', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
|
||||
{ label: 'GPT-5.2 Codex', from: 'gpt-5.2-codex', to: 'gpt-5.2-codex', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ label: 'GPT-5.1 Codex', from: 'gpt-5.1-codex', to: 'gpt-5.1-codex', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: 'Codex Max', from: 'gpt-5.1-codex-max', to: 'gpt-5.1-codex-max', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Codex Mini', from: 'gpt-5.1-codex-mini', to: 'gpt-5.1-codex-mini', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: 'Max->Codex', from: 'gpt-5.1-codex-max', to: 'gpt-5.1-codex', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
|
||||
]
|
||||
|
||||
// Computed: current preset mappings based on platform
|
||||
const presetMappings = computed(() => {
|
||||
return props.account?.platform === 'openai' ? openaiPresetMappings : anthropicPresetMappings
|
||||
})
|
||||
|
||||
// Computed: default base URL based on platform
|
||||
const defaultBaseUrl = computed(() => {
|
||||
return props.account?.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'
|
||||
})
|
||||
|
||||
// Common HTTP error codes for quick selection
|
||||
const commonErrorCodes = [
|
||||
{ value: 401, label: 'Unauthorized' },
|
||||
@@ -492,7 +529,8 @@ watch(() => props.account, (newAccount) => {
|
||||
// Initialize API Key fields for apikey type
|
||||
if (newAccount.type === 'apikey' && newAccount.credentials) {
|
||||
const credentials = newAccount.credentials as Record<string, unknown>
|
||||
editBaseUrl.value = credentials.base_url as string || 'https://api.anthropic.com'
|
||||
const platformDefaultUrl = newAccount.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'
|
||||
editBaseUrl.value = credentials.base_url as string || platformDefaultUrl
|
||||
|
||||
// Load model mappings and detect mode
|
||||
const existingMappings = credentials.model_mapping as Record<string, string> | undefined
|
||||
@@ -529,7 +567,8 @@ watch(() => props.account, (newAccount) => {
|
||||
selectedErrorCodes.value = []
|
||||
}
|
||||
} else {
|
||||
editBaseUrl.value = 'https://api.anthropic.com'
|
||||
const platformDefaultUrl = newAccount.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'
|
||||
editBaseUrl.value = platformDefaultUrl
|
||||
modelRestrictionMode.value = 'whitelist'
|
||||
modelMappings.value = []
|
||||
allowedModels.value = []
|
||||
@@ -628,7 +667,7 @@ const handleSubmit = async () => {
|
||||
// For apikey type, handle credentials update
|
||||
if (props.account.type === 'apikey') {
|
||||
const currentCredentials = props.account.credentials as Record<string, unknown> || {}
|
||||
const newBaseUrl = editBaseUrl.value.trim() || 'https://api.anthropic.com'
|
||||
const newBaseUrl = editBaseUrl.value.trim() || defaultBaseUrl.value
|
||||
const modelMapping = buildModelMappingObject()
|
||||
|
||||
// Always update credentials for apikey type to handle model mapping changes
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">{{ t('admin.accounts.oauth.title') }}</h4>
|
||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">{{ oauthTitle }}</h4>
|
||||
|
||||
<!-- Auth Method Selection -->
|
||||
<div class="mb-4">
|
||||
<div v-if="showCookieOption" class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
{{ methodLabel }}
|
||||
</label>
|
||||
@@ -132,7 +132,7 @@
|
||||
<!-- Manual Authorization Flow -->
|
||||
<div v-else class="space-y-4">
|
||||
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||
{{ t('admin.accounts.oauth.followSteps') }}
|
||||
{{ oauthFollowSteps }}
|
||||
</p>
|
||||
|
||||
<!-- Step 1: Generate Auth URL -->
|
||||
@@ -143,7 +143,7 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
{{ t('admin.accounts.oauth.step1GenerateUrl') }}
|
||||
{{ oauthStep1GenerateUrl }}
|
||||
</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
@@ -159,7 +159,7 @@
|
||||
<svg v-else class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
{{ loading ? t('admin.accounts.oauth.generating') : t('admin.accounts.oauth.generateAuthUrl') }}
|
||||
{{ loading ? t('admin.accounts.oauth.generating') : oauthGenerateAuthUrl }}
|
||||
</button>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -206,12 +206,18 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
{{ t('admin.accounts.oauth.step2OpenUrl') }}
|
||||
{{ oauthStep2OpenUrl }}
|
||||
</p>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t('admin.accounts.oauth.openUrlDesc') }}
|
||||
{{ oauthOpenUrlDesc }}
|
||||
</p>
|
||||
<div v-if="showProxyWarning" class="mt-2 rounded border border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/30 p-3">
|
||||
<!-- OpenAI Important Notice -->
|
||||
<div v-if="isOpenAI" class="mt-2 rounded border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/30 p-3">
|
||||
<p class="text-xs text-amber-800 dark:text-amber-300" v-html="oauthImportantNotice">
|
||||
</p>
|
||||
</div>
|
||||
<!-- Proxy Warning (for non-OpenAI) -->
|
||||
<div v-else-if="showProxyWarning" class="mt-2 rounded border border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/30 p-3">
|
||||
<p class="text-xs text-yellow-800 dark:text-yellow-300" v-html="t('admin.accounts.oauth.proxyWarning')">
|
||||
</p>
|
||||
</div>
|
||||
@@ -227,28 +233,28 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
{{ t('admin.accounts.oauth.step3EnterCode') }}
|
||||
{{ oauthStep3EnterCode }}
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300" v-html="t('admin.accounts.oauth.authCodeDesc')">
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300" v-html="oauthAuthCodeDesc">
|
||||
</p>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
<svg class="w-4 h-4 inline mr-1 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<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>
|
||||
{{ t('admin.accounts.oauth.authCode') }}
|
||||
{{ oauthAuthCode }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCodeInput"
|
||||
rows="3"
|
||||
class="input w-full font-mono text-sm resize-none"
|
||||
:placeholder="t('admin.accounts.oauth.authCodePlaceholder')"
|
||||
:placeholder="oauthAuthCodePlaceholder"
|
||||
></textarea>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-3 h-3 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
{{ t('admin.accounts.oauth.authCodeHint') }}
|
||||
{{ oauthAuthCodeHint }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -286,6 +292,8 @@ interface Props {
|
||||
showProxyWarning?: boolean
|
||||
allowMultiple?: boolean
|
||||
methodLabel?: string
|
||||
showCookieOption?: boolean // Whether to show cookie auto-auth option
|
||||
platform?: 'anthropic' | 'openai' // Platform type for different UI/text
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -296,7 +304,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
showHelp: true,
|
||||
showProxyWarning: true,
|
||||
allowMultiple: false,
|
||||
methodLabel: 'Authorization Method'
|
||||
methodLabel: 'Authorization Method',
|
||||
showCookieOption: true,
|
||||
platform: 'anthropic'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -308,8 +318,35 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Platform-specific translation helpers
|
||||
const isOpenAI = computed(() => props.platform === 'openai')
|
||||
|
||||
// Get translation key based on platform
|
||||
const getOAuthKey = (key: string) => {
|
||||
if (isOpenAI.value) {
|
||||
// Try OpenAI-specific key first
|
||||
const openaiKey = `admin.accounts.oauth.openai.${key}`
|
||||
return openaiKey
|
||||
}
|
||||
return `admin.accounts.oauth.${key}`
|
||||
}
|
||||
|
||||
// Computed translations for current platform
|
||||
const oauthTitle = computed(() => t(getOAuthKey('title')))
|
||||
const oauthFollowSteps = computed(() => t(getOAuthKey('followSteps')))
|
||||
const oauthStep1GenerateUrl = computed(() => t(getOAuthKey('step1GenerateUrl')))
|
||||
const oauthGenerateAuthUrl = computed(() => t(getOAuthKey('generateAuthUrl')))
|
||||
const oauthStep2OpenUrl = computed(() => t(getOAuthKey('step2OpenUrl')))
|
||||
const oauthOpenUrlDesc = computed(() => t(getOAuthKey('openUrlDesc')))
|
||||
const oauthStep3EnterCode = computed(() => t(getOAuthKey('step3EnterCode')))
|
||||
const oauthAuthCodeDesc = computed(() => t(getOAuthKey('authCodeDesc')))
|
||||
const oauthAuthCode = computed(() => t(getOAuthKey('authCode')))
|
||||
const oauthAuthCodePlaceholder = computed(() => t(getOAuthKey('authCodePlaceholder')))
|
||||
const oauthAuthCodeHint = computed(() => t(getOAuthKey('authCodeHint')))
|
||||
const oauthImportantNotice = computed(() => isOpenAI.value ? t('admin.accounts.oauth.openai.importantNotice') : '')
|
||||
|
||||
// Local state
|
||||
const inputMethod = ref<AuthInputMethod>('manual')
|
||||
const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual')
|
||||
const authCodeInput = ref('')
|
||||
const sessionKeyInput = ref('')
|
||||
const showHelpDialog = ref(false)
|
||||
@@ -327,6 +364,32 @@ watch(inputMethod, (newVal) => {
|
||||
emit('update:inputMethod', newVal)
|
||||
})
|
||||
|
||||
// Auto-extract code from OpenAI callback URL
|
||||
// e.g., http://localhost:1455/auth/callback?code=ac_xxx...&scope=...&state=...
|
||||
watch(authCodeInput, (newVal) => {
|
||||
if (!isOpenAI.value) return
|
||||
|
||||
const trimmed = newVal.trim()
|
||||
// Check if it looks like a URL with code parameter
|
||||
if (trimmed.includes('?') && trimmed.includes('code=')) {
|
||||
try {
|
||||
// Try to parse as URL
|
||||
const url = new URL(trimmed)
|
||||
const code = url.searchParams.get('code')
|
||||
if (code && code !== trimmed) {
|
||||
// Replace the input with just the code
|
||||
authCodeInput.value = code
|
||||
}
|
||||
} catch {
|
||||
// If URL parsing fails, try regex extraction
|
||||
const match = trimmed.match(/[?&]code=([^&]+)/)
|
||||
if (match && match[1] && match[1] !== trimmed) {
|
||||
authCodeInput.value = match[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Methods
|
||||
const handleGenerateUrl = () => {
|
||||
emit('generate-url')
|
||||
|
||||
@@ -9,20 +9,25 @@
|
||||
<!-- Account Info -->
|
||||
<div class="rounded-lg border border-gray-200 dark:border-dark-600 bg-gray-50 dark:bg-dark-700 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500 to-orange-600">
|
||||
<div :class="[
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
|
||||
isOpenAI ? 'from-green-500 to-green-600' : 'from-orange-500 to-orange-600'
|
||||
]">
|
||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block font-semibold text-gray-900 dark:text-white">{{ account.name }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.accounts.claudeCodeAccount') }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ isOpenAI ? t('admin.accounts.openaiAccount') : t('admin.accounts.claudeCodeAccount') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Method Selection -->
|
||||
<div>
|
||||
<!-- Add Method Selection (Claude only) -->
|
||||
<div v-if="!isOpenAI">
|
||||
<label class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</label>
|
||||
<div class="flex gap-4 mt-2">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
@@ -50,14 +55,16 @@
|
||||
<OAuthAuthorizationFlow
|
||||
ref="oauthFlowRef"
|
||||
:add-method="addMethod"
|
||||
:auth-url="oauth.authUrl.value"
|
||||
:session-id="oauth.sessionId.value"
|
||||
:loading="oauth.loading.value"
|
||||
:error="oauth.error.value"
|
||||
:show-help="false"
|
||||
:show-proxy-warning="false"
|
||||
:auth-url="currentAuthUrl"
|
||||
:session-id="currentSessionId"
|
||||
:loading="currentLoading"
|
||||
:error="currentError"
|
||||
:show-help="!isOpenAI"
|
||||
:show-proxy-warning="!isOpenAI"
|
||||
:show-cookie-option="!isOpenAI"
|
||||
:allow-multiple="false"
|
||||
:method-label="t('admin.accounts.inputMethod')"
|
||||
:platform="isOpenAI ? 'openai' : 'anthropic'"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@cookie-auth="handleCookieAuth"
|
||||
/>
|
||||
@@ -78,7 +85,7 @@
|
||||
@click="handleExchangeCode"
|
||||
>
|
||||
<svg
|
||||
v-if="oauth.loading.value"
|
||||
v-if="currentLoading"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -86,7 +93,7 @@
|
||||
<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>
|
||||
{{ oauth.loading.value ? t('admin.accounts.oauth.verifying') : t('admin.accounts.oauth.completeAuth') }}
|
||||
{{ currentLoading ? t('admin.accounts.oauth.verifying') : t('admin.accounts.oauth.completeAuth') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,6 +106,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { useAccountOAuth, type AddMethod, type AuthInputMethod } from '@/composables/useAccountOAuth'
|
||||
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
|
||||
import type { Account } from '@/types'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
|
||||
@@ -126,8 +134,9 @@ const emit = defineEmits<{
|
||||
const appStore = useAppStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// OAuth composable
|
||||
const oauth = useAccountOAuth()
|
||||
// OAuth composables - use both Claude and OpenAI
|
||||
const claudeOAuth = useAccountOAuth()
|
||||
const openaiOAuth = useOpenAIOAuth()
|
||||
|
||||
// Refs
|
||||
const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
|
||||
@@ -135,21 +144,33 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
|
||||
// State
|
||||
const addMethod = ref<AddMethod>('oauth')
|
||||
|
||||
// Computed - check if this is an OpenAI account
|
||||
const isOpenAI = computed(() => props.account?.platform === 'openai')
|
||||
|
||||
// Computed - current OAuth state based on platform
|
||||
const currentAuthUrl = computed(() => isOpenAI.value ? openaiOAuth.authUrl.value : claudeOAuth.authUrl.value)
|
||||
const currentSessionId = computed(() => isOpenAI.value ? openaiOAuth.sessionId.value : claudeOAuth.sessionId.value)
|
||||
const currentLoading = computed(() => isOpenAI.value ? openaiOAuth.loading.value : claudeOAuth.loading.value)
|
||||
const currentError = computed(() => isOpenAI.value ? openaiOAuth.error.value : claudeOAuth.error.value)
|
||||
|
||||
// Computed
|
||||
const isManualInputMethod = computed(() => {
|
||||
return oauthFlowRef.value?.inputMethod === 'manual'
|
||||
// OpenAI always uses manual input (no cookie auth option)
|
||||
return isOpenAI.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
})
|
||||
|
||||
const canExchangeCode = computed(() => {
|
||||
const authCode = oauthFlowRef.value?.authCode || ''
|
||||
return authCode.trim() && oauth.sessionId.value && !oauth.loading.value
|
||||
const sessionId = isOpenAI.value ? openaiOAuth.sessionId.value : claudeOAuth.sessionId.value
|
||||
const loading = isOpenAI.value ? openaiOAuth.loading.value : claudeOAuth.loading.value
|
||||
return authCode.trim() && sessionId && !loading
|
||||
})
|
||||
|
||||
// Watchers
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
// Initialize addMethod based on current account type
|
||||
if (props.account.type === 'oauth' || props.account.type === 'setup-token') {
|
||||
// Initialize addMethod based on current account type (Claude only)
|
||||
if (!isOpenAI.value && (props.account.type === 'oauth' || props.account.type === 'setup-token')) {
|
||||
addMethod.value = props.account.type as AddMethod
|
||||
}
|
||||
} else {
|
||||
@@ -160,7 +181,8 @@ watch(() => props.show, (newVal) => {
|
||||
// Methods
|
||||
const resetState = () => {
|
||||
addMethod.value = 'oauth'
|
||||
oauth.resetState()
|
||||
claudeOAuth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
}
|
||||
|
||||
@@ -170,55 +192,99 @@ const handleClose = () => {
|
||||
|
||||
const handleGenerateUrl = async () => {
|
||||
if (!props.account) return
|
||||
await oauth.generateAuthUrl(addMethod.value, props.account.proxy_id)
|
||||
|
||||
if (isOpenAI.value) {
|
||||
await openaiOAuth.generateAuthUrl(props.account.proxy_id)
|
||||
} else {
|
||||
await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExchangeCode = async () => {
|
||||
if (!props.account) return
|
||||
|
||||
const authCode = oauthFlowRef.value?.authCode || ''
|
||||
if (!authCode.trim() || !oauth.sessionId.value) return
|
||||
if (!authCode.trim()) return
|
||||
|
||||
oauth.loading.value = true
|
||||
oauth.error.value = ''
|
||||
if (isOpenAI.value) {
|
||||
// OpenAI OAuth flow
|
||||
const sessionId = openaiOAuth.sessionId.value
|
||||
if (!sessionId) return
|
||||
|
||||
try {
|
||||
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
|
||||
const endpoint = addMethod.value === 'oauth'
|
||||
? '/admin/accounts/exchange-code'
|
||||
: '/admin/accounts/exchange-setup-token-code'
|
||||
const tokenInfo = await openaiOAuth.exchangeAuthCode(authCode.trim(), sessionId, props.account.proxy_id)
|
||||
if (!tokenInfo) return
|
||||
|
||||
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
|
||||
session_id: oauth.sessionId.value,
|
||||
code: authCode.trim(),
|
||||
...proxyConfig
|
||||
})
|
||||
// Build credentials and extra info
|
||||
const credentials = openaiOAuth.buildCredentials(tokenInfo)
|
||||
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
|
||||
|
||||
const extra = oauth.buildExtraInfo(tokenInfo)
|
||||
try {
|
||||
// Update account with new credentials
|
||||
await adminAPI.accounts.update(props.account.id, {
|
||||
type: 'oauth', // OpenAI OAuth is always 'oauth' type
|
||||
credentials,
|
||||
extra
|
||||
})
|
||||
|
||||
// Update account with new credentials and type
|
||||
await adminAPI.accounts.update(props.account.id, {
|
||||
type: addMethod.value, // Update type based on selected method
|
||||
credentials: tokenInfo,
|
||||
extra
|
||||
})
|
||||
// Clear error status after successful re-authorization
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(oauth.error.value)
|
||||
} finally {
|
||||
oauth.loading.value = false
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(openaiOAuth.error.value)
|
||||
}
|
||||
} else {
|
||||
// Claude OAuth flow
|
||||
const sessionId = claudeOAuth.sessionId.value
|
||||
if (!sessionId) return
|
||||
|
||||
claudeOAuth.loading.value = true
|
||||
claudeOAuth.error.value = ''
|
||||
|
||||
try {
|
||||
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
|
||||
const endpoint = addMethod.value === 'oauth'
|
||||
? '/admin/accounts/exchange-code'
|
||||
: '/admin/accounts/exchange-setup-token-code'
|
||||
|
||||
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
|
||||
session_id: sessionId,
|
||||
code: authCode.trim(),
|
||||
...proxyConfig
|
||||
})
|
||||
|
||||
const extra = claudeOAuth.buildExtraInfo(tokenInfo)
|
||||
|
||||
// Update account with new credentials and type
|
||||
await adminAPI.accounts.update(props.account.id, {
|
||||
type: addMethod.value, // Update type based on selected method
|
||||
credentials: tokenInfo,
|
||||
extra
|
||||
})
|
||||
|
||||
// Clear error status after successful re-authorization
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
claudeOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(claudeOAuth.error.value)
|
||||
} finally {
|
||||
claudeOAuth.loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCookieAuth = async (sessionKey: string) => {
|
||||
if (!props.account) return
|
||||
if (!props.account || isOpenAI.value) return
|
||||
|
||||
oauth.loading.value = true
|
||||
oauth.error.value = ''
|
||||
claudeOAuth.loading.value = true
|
||||
claudeOAuth.error.value = ''
|
||||
|
||||
try {
|
||||
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
|
||||
@@ -232,7 +298,7 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
...proxyConfig
|
||||
})
|
||||
|
||||
const extra = oauth.buildExtraInfo(tokenInfo)
|
||||
const extra = claudeOAuth.buildExtraInfo(tokenInfo)
|
||||
|
||||
// Update account with new credentials and type
|
||||
await adminAPI.accounts.update(props.account.id, {
|
||||
@@ -241,13 +307,16 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
extra
|
||||
})
|
||||
|
||||
// Clear error status after successful re-authorization
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
|
||||
claudeOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
|
||||
} finally {
|
||||
oauth.loading.value = false
|
||||
claudeOAuth.loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,3 +5,6 @@ export { default as OAuthAuthorizationFlow } from './OAuthAuthorizationFlow.vue'
|
||||
export { default as AccountStatusIndicator } from './AccountStatusIndicator.vue'
|
||||
export { default as AccountUsageCell } from './AccountUsageCell.vue'
|
||||
export { default as UsageProgressBar } from './UsageProgressBar.vue'
|
||||
export { default as AccountStatsModal } from './AccountStatsModal.vue'
|
||||
export { default as AccountTestModal } from './AccountTestModal.vue'
|
||||
export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue'
|
||||
|
||||
@@ -2,49 +2,120 @@
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium transition-colors',
|
||||
isSubscription
|
||||
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
|
||||
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
badgeClass
|
||||
]"
|
||||
>
|
||||
<!-- Subscription type icon (calendar) -->
|
||||
<svg v-if="isSubscription" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||
</svg>
|
||||
<!-- Standard type icon (wallet) -->
|
||||
<svg v-else class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" />
|
||||
</svg>
|
||||
<!-- Platform logo -->
|
||||
<PlatformIcon v-if="platform" :platform="platform" size="sm" />
|
||||
<!-- Group name -->
|
||||
<span class="truncate">{{ name }}</span>
|
||||
<!-- Right side label -->
|
||||
<span
|
||||
v-if="showRate && rateMultiplier !== undefined"
|
||||
:class="[
|
||||
'px-1 py-0.5 rounded text-[10px] font-semibold',
|
||||
isSubscription
|
||||
? 'bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300'
|
||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
]"
|
||||
v-if="showLabel"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ rateMultiplier }}x
|
||||
{{ labelText }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { SubscriptionType } from '@/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SubscriptionType, GroupPlatform } from '@/types'
|
||||
import PlatformIcon from './PlatformIcon.vue'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
platform?: GroupPlatform
|
||||
subscriptionType?: SubscriptionType
|
||||
rateMultiplier?: number
|
||||
showRate?: boolean
|
||||
daysRemaining?: number | null // 剩余天数(订阅类型时使用)
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
subscriptionType: 'standard',
|
||||
showRate: true
|
||||
showRate: true,
|
||||
daysRemaining: null
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isSubscription = computed(() => props.subscriptionType === 'subscription')
|
||||
|
||||
// 是否显示右侧标签
|
||||
const showLabel = computed(() => {
|
||||
if (!props.showRate) return false
|
||||
// 订阅类型:显示天数或"订阅"
|
||||
if (isSubscription.value) return true
|
||||
// 标准类型:显示倍率
|
||||
return props.rateMultiplier !== undefined
|
||||
})
|
||||
|
||||
// Label text
|
||||
const labelText = computed(() => {
|
||||
if (isSubscription.value) {
|
||||
// 如果有剩余天数,显示天数
|
||||
if (props.daysRemaining !== null && props.daysRemaining !== undefined) {
|
||||
if (props.daysRemaining <= 0) {
|
||||
return t('admin.users.expired')
|
||||
}
|
||||
return t('admin.users.daysRemaining', { days: props.daysRemaining })
|
||||
}
|
||||
// 否则显示"订阅"
|
||||
return t('groups.subscription')
|
||||
}
|
||||
return props.rateMultiplier !== undefined ? `${props.rateMultiplier}x` : ''
|
||||
})
|
||||
|
||||
// Label style based on type and days remaining
|
||||
const labelClass = computed(() => {
|
||||
const base = 'px-1.5 py-0.5 rounded text-[10px] font-semibold'
|
||||
|
||||
if (!isSubscription.value) {
|
||||
// Standard: subtle background
|
||||
return `${base} bg-black/10 dark:bg-white/10`
|
||||
}
|
||||
|
||||
// 订阅类型:根据剩余天数显示不同颜色
|
||||
if (props.daysRemaining !== null && props.daysRemaining !== undefined) {
|
||||
if (props.daysRemaining <= 0 || props.daysRemaining <= 3) {
|
||||
// 已过期或紧急(<=3天):红色
|
||||
return `${base} bg-red-200/80 text-red-800 dark:bg-red-800/50 dark:text-red-300`
|
||||
}
|
||||
if (props.daysRemaining <= 7) {
|
||||
// 警告(<=7天):橙色
|
||||
return `${base} bg-amber-200/80 text-amber-800 dark:bg-amber-800/50 dark:text-amber-300`
|
||||
}
|
||||
}
|
||||
|
||||
// 正常状态或无天数:根据平台显示主题色
|
||||
if (props.platform === 'anthropic') {
|
||||
return `${base} bg-orange-200/60 text-orange-800 dark:bg-orange-800/40 dark:text-orange-300`
|
||||
}
|
||||
if (props.platform === 'openai') {
|
||||
return `${base} bg-emerald-200/60 text-emerald-800 dark:bg-emerald-800/40 dark:text-emerald-300`
|
||||
}
|
||||
return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
|
||||
})
|
||||
|
||||
// Badge color based on platform and subscription type
|
||||
const badgeClass = computed(() => {
|
||||
if (props.platform === 'anthropic') {
|
||||
// Claude: orange theme
|
||||
return isSubscription.value
|
||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
: 'bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400'
|
||||
} else if (props.platform === 'openai') {
|
||||
// OpenAI: green theme
|
||||
return isSubscription.value
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
: 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400'
|
||||
}
|
||||
// Fallback: original colors
|
||||
return isSubscription.value
|
||||
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
|
||||
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
class="grid grid-cols-2 gap-1 max-h-32 overflow-y-auto p-2 border border-gray-200 dark:border-dark-600 rounded-lg bg-gray-50 dark:bg-dark-800"
|
||||
>
|
||||
<label
|
||||
v-for="group in groups"
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.id"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white dark:hover:bg-dark-700 cursor-pointer transition-colors"
|
||||
:title="`${group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
|
||||
@@ -29,7 +29,7 @@
|
||||
<span class="text-xs text-gray-400 shrink-0">{{ group.account_count || 0 }}</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="groups.length === 0"
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="col-span-2 text-center text-sm text-gray-500 dark:text-gray-400 py-2"
|
||||
>
|
||||
No groups available
|
||||
@@ -39,12 +39,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import GroupBadge from './GroupBadge.vue'
|
||||
import type { Group } from '@/types'
|
||||
import type { Group, GroupPlatform } from '@/types'
|
||||
|
||||
interface Props {
|
||||
modelValue: number[]
|
||||
groups: Group[]
|
||||
platform?: GroupPlatform // Optional platform filter
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -52,6 +54,14 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: number[]]
|
||||
}>()
|
||||
|
||||
// Filter groups by platform if specified
|
||||
const filteredGroups = computed(() => {
|
||||
if (!props.platform) {
|
||||
return props.groups
|
||||
}
|
||||
return props.groups.filter(g => g.platform === props.platform)
|
||||
})
|
||||
|
||||
const handleChange = (groupId: number, checked: boolean) => {
|
||||
const newValue = checked
|
||||
? [...props.modelValue, groupId]
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -80,6 +80,7 @@ const sizeClasses = computed(() => {
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-5xl',
|
||||
full: 'max-w-4xl'
|
||||
}
|
||||
return sizes[props.size]
|
||||
|
||||
38
frontend/src/components/common/PlatformIcon.vue
Normal file
38
frontend/src/components/common/PlatformIcon.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<!-- Claude/Anthropic logo -->
|
||||
<svg v-if="platform === 'anthropic'" :class="sizeClass" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="m3.127 10.604 3.135-1.76.053-.153-.053-.085H6.11l-.525-.032-1.791-.048-1.554-.065-1.505-.08-.38-.081L0 7.832l.036-.234.32-.214.455.04 1.009.069 1.513.105 1.097.064 1.626.17h.259l.036-.105-.089-.065-.068-.064-1.566-1.062-1.695-1.121-.887-.646-.48-.327-.243-.306-.104-.67.435-.48.585.04.15.04.593.456 1.267.981 1.654 1.218.242.202.097-.068.012-.049-.109-.181-.9-1.626-.96-1.655-.428-.686-.113-.411a2 2 0 0 1-.068-.484l.496-.674L4.446 0l.662.089.279.242.411.94.666 1.48 1.033 2.014.302.597.162.553.06.17h.105v-.097l.085-1.134.157-1.392.154-1.792.052-.504.25-.605.497-.327.387.186.319.456-.045.294-.19 1.23-.37 1.93-.243 1.29h.142l.161-.16.654-.868 1.097-1.372.484-.545.565-.601.363-.287h.686l.505.751-.226.775-.707.895-.585.759-.839 1.13-.524.904.048.072.125-.012 1.897-.403 1.024-.186 1.223-.21.553.258.06.263-.218.536-1.307.323-1.533.307-2.284.54-.028.02.032.04 1.029.098.44.024h1.077l2.005.15.525.346.315.424-.053.323-.807.411-3.631-.863-.872-.218h-.12v.073l.726.71 1.331 1.202 1.667 1.55.084.383-.214.302-.226-.032-1.464-1.101-.565-.497-1.28-1.077h-.084v.113l.295.432 1.557 2.34.08.718-.112.234-.404.141-.444-.08-.911-1.28-.94-1.44-.759-1.291-.093.053-.448 4.821-.21.246-.484.186-.403-.307-.214-.496.214-.98.258-1.28.21-1.016.19-1.263.112-.42-.008-.028-.092.012-.953 1.307-1.448 1.957-1.146 1.227-.274.109-.477-.247.045-.44.266-.39 1.586-2.018.956-1.25.617-.723-.004-.105h-.036l-4.212 2.736-.75.096-.324-.302.04-.496.154-.162 1.267-.871z"/>
|
||||
</svg>
|
||||
<!-- OpenAI logo -->
|
||||
<svg v-else-if="platform === 'openai'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/>
|
||||
</svg>
|
||||
<!-- Fallback: generic platform icon -->
|
||||
<svg v-else :class="sizeClass" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { GroupPlatform } from '@/types'
|
||||
|
||||
interface Props {
|
||||
platform?: GroupPlatform
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'sm'
|
||||
})
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
const sizes = {
|
||||
xs: 'w-3 h-3',
|
||||
sm: 'w-3.5 h-3.5',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5'
|
||||
}
|
||||
return sizes[props.size] + ' flex-shrink-0'
|
||||
})
|
||||
</script>
|
||||
@@ -29,7 +29,7 @@
|
||||
<transition name="dropdown">
|
||||
<div
|
||||
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">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
@@ -62,43 +62,43 @@
|
||||
<!-- Progress bars -->
|
||||
<div class="space-y-1.5">
|
||||
<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>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<span class="text-[10px] text-gray-500 w-8 flex-shrink-0">{{ t('subscriptionProgress.daily') }}</span>
|
||||
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="getProgressBarClass(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)"
|
||||
:style="{ width: getProgressWidth(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }"
|
||||
></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) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<span class="text-[10px] text-gray-500 w-8 flex-shrink-0">{{ t('subscriptionProgress.weekly') }}</span>
|
||||
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="getProgressBarClass(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }"
|
||||
></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) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<span class="text-[10px] text-gray-500 w-8 flex-shrink-0">{{ t('subscriptionProgress.monthly') }}</span>
|
||||
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="getProgressBarClass(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }"
|
||||
></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) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
155
frontend/src/composables/useOpenAIOAuth.ts
Normal file
155
frontend/src/composables/useOpenAIOAuth.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { ref } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
|
||||
export interface OpenAITokenInfo {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
id_token?: string
|
||||
token_type?: string
|
||||
expires_in?: number
|
||||
expires_at?: number
|
||||
scope?: string
|
||||
email?: string
|
||||
name?: string
|
||||
// OpenAI specific IDs (extracted from ID Token)
|
||||
chatgpt_account_id?: string
|
||||
chatgpt_user_id?: string
|
||||
organization_id?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function useOpenAIOAuth() {
|
||||
const appStore = useAppStore()
|
||||
|
||||
// State
|
||||
const authUrl = ref('')
|
||||
const sessionId = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Reset state
|
||||
const resetState = () => {
|
||||
authUrl.value = ''
|
||||
sessionId.value = ''
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
// Generate auth URL for OpenAI OAuth
|
||||
const generateAuthUrl = async (
|
||||
proxyId?: number | null,
|
||||
redirectUri?: string
|
||||
): Promise<boolean> => {
|
||||
loading.value = true
|
||||
authUrl.value = ''
|
||||
sessionId.value = ''
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (proxyId) {
|
||||
payload.proxy_id = proxyId
|
||||
}
|
||||
if (redirectUri) {
|
||||
payload.redirect_uri = redirectUri
|
||||
}
|
||||
|
||||
const response = await adminAPI.accounts.generateAuthUrl('/admin/openai/generate-auth-url', payload)
|
||||
authUrl.value = response.auth_url
|
||||
sessionId.value = response.session_id
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to generate OpenAI auth URL'
|
||||
appStore.showError(error.value)
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Exchange auth code for tokens
|
||||
const exchangeAuthCode = async (
|
||||
code: string,
|
||||
currentSessionId: string,
|
||||
proxyId?: number | null
|
||||
): Promise<OpenAITokenInfo | null> => {
|
||||
if (!code.trim() || !currentSessionId) {
|
||||
error.value = 'Missing auth code or session ID'
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload: { session_id: string; code: string; proxy_id?: number } = {
|
||||
session_id: currentSessionId,
|
||||
code: code.trim()
|
||||
}
|
||||
if (proxyId) {
|
||||
payload.proxy_id = proxyId
|
||||
}
|
||||
|
||||
const tokenInfo = await adminAPI.accounts.exchangeCode('/admin/openai/exchange-code', payload)
|
||||
return tokenInfo as OpenAITokenInfo
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to exchange OpenAI auth code'
|
||||
appStore.showError(error.value)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Build credentials for OpenAI OAuth account
|
||||
const buildCredentials = (tokenInfo: OpenAITokenInfo): Record<string, unknown> => {
|
||||
const creds: Record<string, unknown> = {
|
||||
access_token: tokenInfo.access_token,
|
||||
refresh_token: tokenInfo.refresh_token,
|
||||
token_type: tokenInfo.token_type,
|
||||
expires_in: tokenInfo.expires_in,
|
||||
expires_at: tokenInfo.expires_at,
|
||||
scope: tokenInfo.scope
|
||||
}
|
||||
|
||||
// Include OpenAI specific IDs (required for forwarding)
|
||||
if (tokenInfo.chatgpt_account_id) {
|
||||
creds.chatgpt_account_id = tokenInfo.chatgpt_account_id
|
||||
}
|
||||
if (tokenInfo.chatgpt_user_id) {
|
||||
creds.chatgpt_user_id = tokenInfo.chatgpt_user_id
|
||||
}
|
||||
if (tokenInfo.organization_id) {
|
||||
creds.organization_id = tokenInfo.organization_id
|
||||
}
|
||||
|
||||
return creds
|
||||
}
|
||||
|
||||
// Build extra info from token response
|
||||
const buildExtraInfo = (tokenInfo: OpenAITokenInfo): Record<string, string> | undefined => {
|
||||
const extra: Record<string, string> = {}
|
||||
if (tokenInfo.email) {
|
||||
extra.email = tokenInfo.email
|
||||
}
|
||||
if (tokenInfo.name) {
|
||||
extra.name = tokenInfo.name
|
||||
}
|
||||
return Object.keys(extra).length > 0 ? extra : undefined
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
authUrl,
|
||||
sessionId,
|
||||
loading,
|
||||
error,
|
||||
// Methods
|
||||
resetState,
|
||||
generateAuthUrl,
|
||||
exchangeAuthCode,
|
||||
buildCredentials,
|
||||
buildExtraInfo
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,11 @@ export default {
|
||||
addBalanceWithCode: 'Add balance with a code',
|
||||
},
|
||||
|
||||
// Groups (shared)
|
||||
groups: {
|
||||
subscription: 'Sub',
|
||||
},
|
||||
|
||||
// API Keys
|
||||
keys: {
|
||||
title: 'API Keys',
|
||||
@@ -330,6 +335,15 @@ export default {
|
||||
memberSince: 'Member Since',
|
||||
administrator: 'Administrator',
|
||||
user: 'User',
|
||||
username: 'Username',
|
||||
wechat: 'WeChat ID',
|
||||
enterUsername: 'Enter username',
|
||||
enterWechat: 'Enter WeChat ID',
|
||||
editProfile: 'Edit Profile',
|
||||
updateProfile: 'Update Profile',
|
||||
updating: 'Updating...',
|
||||
updateSuccess: 'Profile updated successfully',
|
||||
updateFailed: 'Failed to update profile',
|
||||
changePassword: 'Change Password',
|
||||
currentPassword: 'Current Password',
|
||||
newPassword: 'New Password',
|
||||
@@ -441,9 +455,30 @@ export default {
|
||||
admin: 'Admin',
|
||||
user: 'User',
|
||||
disabled: 'Disabled',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
username: 'Username',
|
||||
wechat: 'WeChat ID',
|
||||
notes: 'Notes',
|
||||
enterEmail: 'Enter email',
|
||||
enterPassword: 'Enter password',
|
||||
enterUsername: 'Enter username (optional)',
|
||||
enterWechat: 'Enter WeChat ID (optional)',
|
||||
enterNotes: 'Enter notes (admin only)',
|
||||
notesHint: 'This note is only visible to administrators',
|
||||
enterNewPassword: 'Enter new password (optional)',
|
||||
leaveEmptyToKeep: 'Leave empty to keep current password',
|
||||
generatePassword: 'Generate random password',
|
||||
copyPassword: 'Copy password',
|
||||
creating: 'Creating...',
|
||||
updating: 'Updating...',
|
||||
columns: {
|
||||
user: 'User',
|
||||
username: 'Username',
|
||||
wechat: 'WeChat ID',
|
||||
notes: 'Notes',
|
||||
role: 'Role',
|
||||
subscriptions: 'Subscriptions',
|
||||
balance: 'Balance',
|
||||
usage: 'Usage',
|
||||
concurrency: 'Concurrency',
|
||||
@@ -453,6 +488,9 @@ export default {
|
||||
},
|
||||
today: 'Today',
|
||||
total: 'Total',
|
||||
noSubscription: 'No subscription',
|
||||
daysRemaining: '{days}d',
|
||||
expired: 'Expired',
|
||||
disableUser: 'Disable User',
|
||||
enableUser: 'Enable User',
|
||||
viewApiKeys: 'View API Keys',
|
||||
@@ -462,16 +500,6 @@ export default {
|
||||
none: 'None',
|
||||
noUsersYet: 'No users yet',
|
||||
createFirstUser: 'Create your first user to get started.',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
enterEmail: 'Enter email',
|
||||
enterPassword: 'Enter password',
|
||||
enterNewPassword: 'Enter new password (optional)',
|
||||
leaveEmptyToKeep: 'Leave empty to keep current password',
|
||||
generatePassword: 'Generate random password',
|
||||
copyPassword: 'Copy password',
|
||||
creating: 'Creating...',
|
||||
updating: 'Updating...',
|
||||
userCreated: 'User created successfully',
|
||||
userUpdated: 'User updated successfully',
|
||||
userDeleted: 'User deleted successfully',
|
||||
@@ -492,6 +520,26 @@ export default {
|
||||
allowedGroupsUpdated: 'Allowed groups updated successfully',
|
||||
failedToLoadGroups: 'Failed to load 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
|
||||
@@ -515,6 +563,7 @@ export default {
|
||||
accounts: 'Accounts',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
billingType: 'Billing Type',
|
||||
},
|
||||
accountsCount: '{count} accounts',
|
||||
form: {
|
||||
@@ -527,12 +576,16 @@ export default {
|
||||
enterGroupName: 'Enter group name',
|
||||
optionalDescription: 'Optional description',
|
||||
platformHint: 'Select the platform this group is associated with',
|
||||
platformNotEditable: 'Platform cannot be changed after creation',
|
||||
rateMultiplierHint: 'Cost multiplier for this group (e.g., 1.5 = 150% of base cost)',
|
||||
exclusiveHint: 'Exclusive (requires explicit user access)',
|
||||
noGroupsYet: 'No groups yet',
|
||||
createFirstGroup: 'Create your first group to organize API keys.',
|
||||
creating: 'Creating...',
|
||||
updating: 'Updating...',
|
||||
limitDay: 'd',
|
||||
limitWeek: 'w',
|
||||
limitMonth: 'mo',
|
||||
groupCreated: 'Group created successfully',
|
||||
groupUpdated: 'Group updated successfully',
|
||||
groupDeleted: 'Group deleted successfully',
|
||||
@@ -571,6 +624,11 @@ export default {
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
noLimits: 'No limits configured',
|
||||
resetNow: 'Resetting soon',
|
||||
windowNotActive: 'Window not active',
|
||||
resetInMinutes: 'Resets in {minutes}m',
|
||||
resetInHoursMinutes: 'Resets in {hours}h {minutes}m',
|
||||
resetInDaysHours: 'Resets in {days}d {hours}h',
|
||||
daysRemaining: 'days remaining',
|
||||
noExpiration: 'No expiration',
|
||||
status: {
|
||||
@@ -647,6 +705,7 @@ export default {
|
||||
status: 'Status',
|
||||
schedulable: 'Schedule',
|
||||
todayStats: "Today's Stats",
|
||||
groups: 'Groups',
|
||||
usageWindows: 'Usage Windows',
|
||||
priority: 'Priority',
|
||||
lastUsed: 'Last Used',
|
||||
@@ -661,12 +720,16 @@ export default {
|
||||
tokenRefreshed: 'Token refreshed successfully',
|
||||
accountDeleted: 'Account deleted successfully',
|
||||
rateLimitCleared: 'Rate limit cleared successfully',
|
||||
resetStatus: 'Reset Status',
|
||||
statusReset: 'Account status reset successfully',
|
||||
failedToResetStatus: 'Failed to reset account status',
|
||||
failedToLoad: 'Failed to load accounts',
|
||||
failedToRefresh: 'Failed to refresh token',
|
||||
failedToDelete: 'Failed to delete account',
|
||||
failedToClearRateLimit: 'Failed to clear rate limit',
|
||||
deleteConfirm: "Are you sure you want to delete '{name}'? This action cannot be undone.",
|
||||
// Create/Edit Account Modal
|
||||
platform: 'Platform',
|
||||
accountName: 'Account Name',
|
||||
enterAccountName: 'Enter account name',
|
||||
accountType: 'Account Type',
|
||||
@@ -759,10 +822,26 @@ export default {
|
||||
cookieAuthFailed: 'Cookie authorization failed',
|
||||
keyAuthFailed: 'Key {index}: {error}',
|
||||
successCreated: 'Successfully created {count} account(s)',
|
||||
// OpenAI specific
|
||||
openai: {
|
||||
title: 'OpenAI Account Authorization',
|
||||
followSteps: 'Follow these steps to complete OpenAI account authorization:',
|
||||
step1GenerateUrl: 'Click the button below to generate the authorization URL',
|
||||
generateAuthUrl: 'Generate Auth URL',
|
||||
step2OpenUrl: 'Open the URL in your browser and complete authorization',
|
||||
openUrlDesc: 'Open the authorization URL in a new tab, log in to your OpenAI account and authorize.',
|
||||
importantNotice: '<strong>Important:</strong> The page may take a while to load after authorization. Please wait patiently. When the browser address bar changes to <code>http://localhost...</code>, the authorization is complete.',
|
||||
step3EnterCode: 'Enter Authorization URL or Code',
|
||||
authCodeDesc: 'After authorization is complete, when the page URL becomes <code>http://localhost:xxx/auth/callback?code=...</code>:',
|
||||
authCode: 'Authorization URL or Code',
|
||||
authCodePlaceholder: 'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
|
||||
authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect',
|
||||
},
|
||||
},
|
||||
// Re-Auth Modal
|
||||
reAuthorizeAccount: 'Re-Authorize Account',
|
||||
claudeCodeAccount: 'Claude Code Account',
|
||||
openaiAccount: 'OpenAI Account',
|
||||
inputMethod: 'Input Method',
|
||||
reAuthorizedSuccess: 'Account re-authorized successfully',
|
||||
// Test Modal
|
||||
@@ -785,6 +864,39 @@ export default {
|
||||
selectTestModel: 'Select Test Model',
|
||||
testModel: 'claude-sonnet-4-5-20250929',
|
||||
testPrompt: 'Prompt: "hi"',
|
||||
// Stats Modal
|
||||
viewStats: 'View Stats',
|
||||
usageStatistics: 'Usage Statistics',
|
||||
last30DaysUsage: 'Last 30 days usage statistics (based on actual usage days)',
|
||||
stats: {
|
||||
totalCost: '30-Day Total Cost',
|
||||
accumulatedCost: 'Accumulated cost',
|
||||
standardCost: 'Standard',
|
||||
totalRequests: '30-Day Total Requests',
|
||||
totalCalls: 'Total API calls',
|
||||
avgDailyCost: 'Daily Avg Cost',
|
||||
basedOnActualDays: 'Based on {days} actual usage days',
|
||||
avgDailyRequests: 'Daily Avg Requests',
|
||||
avgDailyUsage: 'Average daily usage',
|
||||
todayOverview: 'Today Overview',
|
||||
cost: 'Cost',
|
||||
requests: 'Requests',
|
||||
highestCostDay: 'Highest Cost Day',
|
||||
highestRequestDay: 'Highest Request Day',
|
||||
date: 'Date',
|
||||
accumulatedTokens: 'Accumulated Tokens',
|
||||
totalTokens: '30-Day Total',
|
||||
dailyAvgTokens: 'Daily Average',
|
||||
performance: 'Performance',
|
||||
avgResponseTime: 'Avg Response',
|
||||
daysActive: 'Days Active',
|
||||
recentActivity: 'Recent Activity',
|
||||
todayRequests: 'Today Requests',
|
||||
todayTokens: 'Today Tokens',
|
||||
todayCost: 'Today Cost',
|
||||
usageTrend: '30-Day Cost & Request Trend',
|
||||
noData: 'No usage data available for this account',
|
||||
},
|
||||
},
|
||||
|
||||
// Proxies
|
||||
@@ -1087,6 +1199,7 @@ export default {
|
||||
daysRemaining: '{days} days remaining',
|
||||
expiresOn: 'Expires on {date}',
|
||||
resetIn: 'Resets in {time}',
|
||||
windowNotActive: 'Awaiting first use',
|
||||
usageOf: '{used} of {limit}',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export default {
|
||||
groups: '分组管理',
|
||||
subscriptions: '订阅管理',
|
||||
accounts: '账号管理',
|
||||
proxies: '代理管理',
|
||||
proxies: 'IP管理',
|
||||
redeemCodes: '兑换码',
|
||||
settings: '系统设置',
|
||||
myAccount: '我的账户',
|
||||
@@ -182,6 +182,11 @@ export default {
|
||||
addBalanceWithCode: '使用兑换码充值',
|
||||
},
|
||||
|
||||
// Groups (shared)
|
||||
groups: {
|
||||
subscription: '订阅',
|
||||
},
|
||||
|
||||
// API Keys
|
||||
keys: {
|
||||
title: 'API 密钥',
|
||||
@@ -330,6 +335,15 @@ export default {
|
||||
memberSince: '注册时间',
|
||||
administrator: '管理员',
|
||||
user: '用户',
|
||||
username: '用户名',
|
||||
wechat: '微信号',
|
||||
enterUsername: '输入用户名',
|
||||
enterWechat: '输入微信号',
|
||||
editProfile: '编辑个人资料',
|
||||
updateProfile: '更新资料',
|
||||
updating: '更新中...',
|
||||
updateSuccess: '资料更新成功',
|
||||
updateFailed: '资料更新失败',
|
||||
changePassword: '修改密码',
|
||||
currentPassword: '当前密码',
|
||||
newPassword: '新密码',
|
||||
@@ -455,13 +469,40 @@ export default {
|
||||
deleteUser: '删除用户',
|
||||
deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
|
||||
searchPlaceholder: '搜索用户...',
|
||||
searchUsers: '搜索用户...',
|
||||
roleFilter: '角色筛选',
|
||||
allRoles: '全部角色',
|
||||
allStatus: '全部状态',
|
||||
statusFilter: '状态筛选',
|
||||
allStatuses: '全部状态',
|
||||
admin: '管理员',
|
||||
user: '用户',
|
||||
disabled: '禁用',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
username: '用户名',
|
||||
wechat: '微信号',
|
||||
notes: '备注',
|
||||
enterEmail: '请输入邮箱',
|
||||
enterPassword: '请输入密码',
|
||||
enterUsername: '请输入用户名(选填)',
|
||||
enterWechat: '请输入微信号(选填)',
|
||||
enterNotes: '请输入备注(仅管理员可见)',
|
||||
notesHint: '此备注仅对管理员可见',
|
||||
enterNewPassword: '请输入新密码(选填)',
|
||||
leaveEmptyToKeep: '留空则保持原密码不变',
|
||||
generatePassword: '生成随机密码',
|
||||
copyPassword: '复制密码',
|
||||
creating: '创建中...',
|
||||
updating: '更新中...',
|
||||
columns: {
|
||||
user: '用户',
|
||||
email: '邮箱',
|
||||
username: '用户名',
|
||||
wechat: '微信号',
|
||||
notes: '备注',
|
||||
role: '角色',
|
||||
subscriptions: '订阅分组',
|
||||
balance: '余额',
|
||||
usage: '用量',
|
||||
concurrency: '并发数',
|
||||
@@ -471,6 +512,30 @@ export default {
|
||||
},
|
||||
today: '今日',
|
||||
total: '累计',
|
||||
noSubscription: '暂无订阅',
|
||||
daysRemaining: '{days}天',
|
||||
expired: '已过期',
|
||||
disableUser: '禁用用户',
|
||||
enableUser: '启用用户',
|
||||
viewApiKeys: '查看 API 密钥',
|
||||
userApiKeys: '用户 API 密钥',
|
||||
noApiKeys: '此用户暂无 API 密钥',
|
||||
group: '分组',
|
||||
none: '无',
|
||||
noUsersYet: '暂无用户',
|
||||
createFirstUser: '创建您的第一个用户以开始使用系统',
|
||||
userCreated: '用户创建成功',
|
||||
userUpdated: '用户更新成功',
|
||||
userDeleted: '用户删除成功',
|
||||
userEnabled: '用户已启用',
|
||||
userDisabled: '用户已禁用',
|
||||
failedToLoad: '加载用户列表失败',
|
||||
failedToCreate: '创建用户失败',
|
||||
failedToUpdate: '更新用户失败',
|
||||
failedToDelete: '删除用户失败',
|
||||
failedToToggle: '更新用户状态失败',
|
||||
failedToLoadApiKeys: '加载用户 API 密钥失败',
|
||||
deleteConfirm: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
|
||||
roles: {
|
||||
admin: '管理员',
|
||||
user: '用户',
|
||||
@@ -482,6 +547,13 @@ export default {
|
||||
form: {
|
||||
emailLabel: '邮箱',
|
||||
emailPlaceholder: '请输入邮箱',
|
||||
usernameLabel: '用户名',
|
||||
usernamePlaceholder: '请输入用户名(选填)',
|
||||
wechatLabel: '微信号',
|
||||
wechatPlaceholder: '请输入微信号(选填)',
|
||||
notesLabel: '备注',
|
||||
notesPlaceholder: '请输入备注(仅管理员可见)',
|
||||
notesHint: '此备注仅对管理员可见',
|
||||
passwordLabel: '密码',
|
||||
passwordPlaceholder: '请输入密码(留空则不修改)',
|
||||
roleLabel: '角色',
|
||||
@@ -505,9 +577,7 @@ export default {
|
||||
userDeletedSuccess: '用户删除成功',
|
||||
balanceAdjustedSuccess: '余额调整成功',
|
||||
concurrencyAdjustedSuccess: '并发数调整成功',
|
||||
failedToLoad: '加载用户列表失败',
|
||||
failedToSave: '保存用户失败',
|
||||
failedToDelete: '删除用户失败',
|
||||
failedToAdjust: '调整失败',
|
||||
setAllowedGroups: '设置允许分组',
|
||||
allowedGroupsHint: '选择此用户可以使用的标准分组。订阅类型分组请在订阅管理中配置。',
|
||||
@@ -517,6 +587,25 @@ export default {
|
||||
allowedGroupsUpdated: '允许分组更新成功',
|
||||
failedToLoadGroups: '加载分组列表失败',
|
||||
failedToUpdateAllowedGroups: '更新允许分组失败',
|
||||
deposit: '充值',
|
||||
withdraw: '退款',
|
||||
depositAmount: '充值金额',
|
||||
withdrawAmount: '退款金额',
|
||||
depositNotesPlaceholder: '例如:新用户注册奖励、活动充值、补偿充值等',
|
||||
withdrawNotesPlaceholder: '例如:服务问题退款、错误充值退回、账户注销退款等',
|
||||
notesOptional: '备注为可选项,有助于未来查账',
|
||||
amountHint: '请输入正数金额',
|
||||
newBalance: '操作后余额',
|
||||
depositing: '充值中...',
|
||||
withdrawing: '退款中...',
|
||||
confirmDeposit: '确认充值',
|
||||
confirmWithdraw: '确认退款',
|
||||
depositSuccess: '充值成功',
|
||||
withdrawSuccess: '退款成功',
|
||||
failedToDeposit: '充值失败',
|
||||
failedToWithdraw: '退款失败',
|
||||
useDepositWithdrawButtons: '请使用充值/退款按钮调整余额',
|
||||
insufficientBalance: '余额不足,退款后余额不能为负数',
|
||||
},
|
||||
|
||||
// Groups Management
|
||||
@@ -530,13 +619,16 @@ export default {
|
||||
deleteConfirmSubscription: "确定要删除订阅分组 '{name}' 吗?此操作会让所有绑定此订阅的用户的 API Key 失效,并删除所有相关的订阅记录。此操作无法撤销。",
|
||||
columns: {
|
||||
name: '名称',
|
||||
platform: '平台',
|
||||
rateMultiplier: '费率倍数',
|
||||
exclusive: '独占',
|
||||
platforms: '平台',
|
||||
type: '类型',
|
||||
priority: '优先级',
|
||||
apiKeys: 'API 密钥数',
|
||||
accounts: '账号数',
|
||||
status: '状态',
|
||||
actions: '操作',
|
||||
billingType: '计费类型',
|
||||
},
|
||||
form: {
|
||||
name: '名称',
|
||||
@@ -588,10 +680,14 @@ export default {
|
||||
enterGroupName: '请输入分组名称',
|
||||
optionalDescription: '可选描述',
|
||||
platformHint: '选择此分组关联的平台',
|
||||
platformNotEditable: '创建后不可更改平台',
|
||||
noGroupsYet: '暂无分组',
|
||||
createFirstGroup: '创建您的第一个分组来组织 API 密钥。',
|
||||
creating: '创建中...',
|
||||
updating: '更新中...',
|
||||
limitDay: '日',
|
||||
limitWeek: '周',
|
||||
limitMonth: '月',
|
||||
groupCreated: '分组创建成功',
|
||||
groupUpdated: '分组更新成功',
|
||||
groupDeleted: '分组删除成功',
|
||||
@@ -626,6 +722,11 @@ export default {
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
noLimits: '未配置限额',
|
||||
resetNow: '即将重置',
|
||||
windowNotActive: '窗口未激活',
|
||||
resetInMinutes: '{minutes} 分钟后重置',
|
||||
resetInHoursMinutes: '{hours} 小时 {minutes} 分钟后重置',
|
||||
resetInDaysHours: '{days} 天 {hours} 小时后重置',
|
||||
daysRemaining: '天剩余',
|
||||
noExpiration: '无过期时间',
|
||||
status: {
|
||||
@@ -699,6 +800,9 @@ export default {
|
||||
weight: '权重',
|
||||
status: '状态',
|
||||
schedulable: '调度',
|
||||
todayStats: '今日统计',
|
||||
groups: '分组',
|
||||
usageWindows: '用量窗口',
|
||||
lastUsed: '最近使用',
|
||||
actions: '操作',
|
||||
},
|
||||
@@ -749,6 +853,9 @@ export default {
|
||||
accountCreatedSuccess: '账号添加成功',
|
||||
accountUpdatedSuccess: '账号更新成功',
|
||||
accountDeletedSuccess: '账号删除成功',
|
||||
resetStatus: '重置状态',
|
||||
statusReset: '账号状态已重置',
|
||||
failedToResetStatus: '重置账号状态失败',
|
||||
cookieRefreshedSuccess: 'Cookie 刷新成功',
|
||||
testSuccess: '账号测试通过',
|
||||
testFailed: '账号测试失败',
|
||||
@@ -757,6 +864,7 @@ export default {
|
||||
failedToDelete: '删除账号失败',
|
||||
failedToRefresh: '刷新 Cookie 失败',
|
||||
// Create/Edit Account Modal
|
||||
platform: '平台',
|
||||
accountName: '账号名称',
|
||||
enterAccountName: '请输入账号名称',
|
||||
accountType: '账号类型',
|
||||
@@ -849,10 +957,26 @@ export default {
|
||||
cookieAuthFailed: 'Cookie 授权失败',
|
||||
keyAuthFailed: '密钥 {index}: {error}',
|
||||
successCreated: '成功创建 {count} 个账号',
|
||||
// OpenAI specific
|
||||
openai: {
|
||||
title: 'OpenAI 账户授权',
|
||||
followSteps: '请按照以下步骤完成 OpenAI 账户的授权:',
|
||||
step1GenerateUrl: '点击下方按钮生成授权链接',
|
||||
generateAuthUrl: '生成授权链接',
|
||||
step2OpenUrl: '在浏览器中打开链接并完成授权',
|
||||
openUrlDesc: '请在新标签页中打开授权链接,登录您的 OpenAI 账户并授权。',
|
||||
importantNotice: '<strong>重要提示:</strong>授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 <code>http://localhost...</code> 开头时,表示授权已完成。',
|
||||
step3EnterCode: '输入授权链接或 Code',
|
||||
authCodeDesc: '授权完成后,当页面地址变为 <code>http://localhost:xxx/auth/callback?code=...</code> 时:',
|
||||
authCode: '授权链接或 Code',
|
||||
authCodePlaceholder: '方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值',
|
||||
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
|
||||
},
|
||||
},
|
||||
// Re-Auth Modal
|
||||
reAuthorizeAccount: '重新授权账号',
|
||||
claudeCodeAccount: 'Claude Code 账号',
|
||||
openaiAccount: 'OpenAI 账号',
|
||||
inputMethod: '输入方式',
|
||||
reAuthorizedSuccess: '账号重新授权成功',
|
||||
// Test Modal
|
||||
@@ -873,11 +997,44 @@ export default {
|
||||
selectTestModel: '选择测试模型',
|
||||
testModel: 'claude-sonnet-4-5-20250929',
|
||||
testPrompt: '提示词:"hi"',
|
||||
// Stats Modal
|
||||
viewStats: '查看统计',
|
||||
usageStatistics: '使用统计',
|
||||
last30DaysUsage: '近30天使用统计(日均基于实际使用天数)',
|
||||
stats: {
|
||||
totalCost: '30天总费用',
|
||||
accumulatedCost: '累计成本',
|
||||
standardCost: '标准计费',
|
||||
totalRequests: '30天总请求',
|
||||
totalCalls: '累计调用次数',
|
||||
avgDailyCost: '日均费用',
|
||||
basedOnActualDays: '基于 {days} 天实际使用',
|
||||
avgDailyRequests: '日均请求',
|
||||
avgDailyUsage: '平均每日调用',
|
||||
todayOverview: '今日概览',
|
||||
cost: '费用',
|
||||
requests: '请求',
|
||||
highestCostDay: '最高费用日',
|
||||
highestRequestDay: '最高请求日',
|
||||
date: '日期',
|
||||
accumulatedTokens: '累计 Token',
|
||||
totalTokens: '30天总计',
|
||||
dailyAvgTokens: '日均 Token',
|
||||
performance: '性能',
|
||||
avgResponseTime: '平均响应',
|
||||
daysActive: '活跃天数',
|
||||
recentActivity: '最近统计',
|
||||
todayRequests: '今日请求',
|
||||
todayTokens: '今日 Token',
|
||||
todayCost: '今日费用',
|
||||
usageTrend: '30天费用与请求趋势',
|
||||
noData: '该账号暂无使用数据',
|
||||
},
|
||||
},
|
||||
|
||||
// Proxies Management
|
||||
proxies: {
|
||||
title: '代理管理',
|
||||
title: 'IP管理',
|
||||
description: '管理代理服务器配置',
|
||||
createProxy: '添加代理',
|
||||
editProxy: '编辑代理',
|
||||
@@ -1266,6 +1423,7 @@ export default {
|
||||
daysRemaining: '剩余 {days} 天',
|
||||
expiresOn: '{date} 到期',
|
||||
resetIn: '{time} 后重置',
|
||||
windowNotActive: '等待首次使用',
|
||||
usageOf: '已用 {used} / {limit}',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
wechat: string;
|
||||
notes: string;
|
||||
email: string;
|
||||
role: 'admin' | 'user'; // User role for authorization
|
||||
balance: number; // User balance for API usage
|
||||
concurrency: number; // Allowed concurrent requests
|
||||
status: 'active' | 'disabled'; // Account status
|
||||
allowed_groups: number[] | null; // Allowed group IDs (null = all non-exclusive groups)
|
||||
subscriptions?: UserSubscription[]; // User's active subscriptions
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -280,7 +283,7 @@ export interface UpdateGroupRequest {
|
||||
|
||||
// ==================== Account & Proxy Types ====================
|
||||
|
||||
export type AccountPlatform = 'anthropic';
|
||||
export type AccountPlatform = 'anthropic' | 'openai';
|
||||
export type AccountType = 'oauth' | 'setup-token' | 'apikey';
|
||||
export type OAuthAddMethod = 'oauth' | 'setup-token';
|
||||
export type ProxyProtocol = 'http' | 'https' | 'socks5';
|
||||
@@ -313,6 +316,7 @@ export interface Account {
|
||||
platform: AccountPlatform;
|
||||
type: AccountType;
|
||||
credentials?: Record<string, unknown>;
|
||||
extra?: CodexUsageSnapshot & Record<string, unknown>; // Extra fields including Codex usage
|
||||
proxy_id: number | null;
|
||||
concurrency: number;
|
||||
priority: number;
|
||||
@@ -323,6 +327,7 @@ export interface Account {
|
||||
updated_at: string;
|
||||
proxy?: Proxy;
|
||||
group_ids?: number[]; // Groups this account belongs to
|
||||
groups?: Group[]; // Preloaded group objects
|
||||
|
||||
// Rate limit & scheduling fields
|
||||
schedulable: boolean;
|
||||
@@ -357,6 +362,18 @@ export interface AccountUsageInfo {
|
||||
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 {
|
||||
name: string;
|
||||
platform: AccountPlatform;
|
||||
@@ -561,6 +578,9 @@ export interface ApiKeyUsageTrendPoint {
|
||||
export interface UpdateUserRequest {
|
||||
email?: string;
|
||||
password?: string;
|
||||
username?: string;
|
||||
wechat?: string;
|
||||
notes?: string;
|
||||
role?: 'admin' | 'user';
|
||||
balance?: number;
|
||||
concurrency?: number;
|
||||
@@ -643,3 +663,51 @@ export interface UsageQueryParams {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
// ==================== Account Usage Statistics ====================
|
||||
|
||||
export interface AccountUsageHistory {
|
||||
date: string;
|
||||
label: string;
|
||||
requests: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
actual_cost: number;
|
||||
}
|
||||
|
||||
export interface AccountUsageSummary {
|
||||
days: number;
|
||||
actual_days_used: number;
|
||||
total_cost: number;
|
||||
total_standard_cost: number;
|
||||
total_requests: number;
|
||||
total_tokens: number;
|
||||
avg_daily_cost: number;
|
||||
avg_daily_requests: number;
|
||||
avg_daily_tokens: number;
|
||||
avg_duration_ms: number;
|
||||
today: {
|
||||
date: string;
|
||||
cost: number;
|
||||
requests: number;
|
||||
tokens: number;
|
||||
} | null;
|
||||
highest_cost_day: {
|
||||
date: string;
|
||||
label: string;
|
||||
cost: number;
|
||||
requests: number;
|
||||
} | null;
|
||||
highest_request_day: {
|
||||
date: string;
|
||||
label: string;
|
||||
requests: number;
|
||||
cost: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface AccountUsageStatsResponse {
|
||||
history: AccountUsageHistory[];
|
||||
summary: AccountUsageSummary;
|
||||
models: ModelStat[];
|
||||
}
|
||||
|
||||
@@ -223,13 +223,13 @@
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">Claude</span>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded">{{ t('home.providers.supported') }}</span>
|
||||
</div>
|
||||
<!-- GPT - Coming Soon -->
|
||||
<div class="flex items-center gap-2 px-5 py-3 rounded-xl bg-white/40 dark:bg-dark-800/40 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 opacity-60">
|
||||
<!-- GPT - Supported -->
|
||||
<div class="flex items-center gap-2 px-5 py-3 rounded-xl bg-white/60 dark:bg-dark-800/60 backdrop-blur-sm border border-primary-200 dark:border-primary-800 ring-1 ring-primary-500/20">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center">
|
||||
<span class="text-white text-xs font-bold">G</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">GPT</span>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-gray-100 dark:bg-dark-700 text-gray-500 dark:text-dark-400 rounded">{{ t('home.providers.soon') }}</span>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded">{{ t('home.providers.supported') }}</span>
|
||||
</div>
|
||||
<!-- Gemini - Coming Soon -->
|
||||
<div class="flex items-center gap-2 px-5 py-3 rounded-xl bg-white/40 dark:bg-dark-800/40 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 opacity-60">
|
||||
|
||||
@@ -74,15 +74,17 @@
|
||||
</template>
|
||||
|
||||
<template #cell-platform="{ value }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'w-2 h-2 rounded-full',
|
||||
value === 'anthropic' ? 'bg-orange-500' : 'bg-gray-400'
|
||||
]"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 capitalize">{{ value === 'anthropic' ? 'Anthropic' : value }}</span>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
value === 'anthropic'
|
||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
]"
|
||||
>
|
||||
<PlatformIcon :platform="value" size="xs" />
|
||||
{{ value === 'anthropic' ? 'Anthropic' : 'OpenAI' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-type="{ value }">
|
||||
@@ -123,6 +125,21 @@
|
||||
<AccountTodayStatsCell :account="row" />
|
||||
</template>
|
||||
|
||||
<template #cell-groups="{ row }">
|
||||
<div v-if="row.groups && row.groups.length > 0" class="flex flex-wrap gap-1.5">
|
||||
<GroupBadge
|
||||
v-for="group in row.groups"
|
||||
:key="group.id"
|
||||
:name="group.name"
|
||||
:platform="group.platform"
|
||||
:subscription-type="group.subscription_type"
|
||||
:rate-multiplier="group.rate_multiplier"
|
||||
:show-rate="false"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-usage="{ row }">
|
||||
<AccountUsageCell :account="row" />
|
||||
</template>
|
||||
@@ -139,6 +156,17 @@
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Reset Status button for error accounts -->
|
||||
<button
|
||||
v-if="row.status === 'error'"
|
||||
@click="handleResetStatus(row)"
|
||||
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
:title="t('admin.accounts.resetStatus')"
|
||||
>
|
||||
<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="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Clear Rate Limit button -->
|
||||
<button
|
||||
v-if="isRateLimited(row) || isOverloaded(row)"
|
||||
@@ -160,6 +188,16 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- View Stats button -->
|
||||
<button
|
||||
@click="handleViewStats(row)"
|
||||
class="p-2 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/20 text-gray-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
:title="t('admin.accounts.viewStats')"
|
||||
>
|
||||
<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="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
||||
@click="handleReAuth(row)"
|
||||
@@ -258,6 +296,13 @@
|
||||
@close="closeTestModal"
|
||||
/>
|
||||
|
||||
<!-- Account Stats Modal -->
|
||||
<AccountStatsModal
|
||||
:show="showStatsModal"
|
||||
:account="statsAccount"
|
||||
@close="closeStatsModal"
|
||||
/>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
@@ -285,11 +330,13 @@ import Pagination from '@/components/common/Pagination.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { CreateAccountModal, EditAccountModal, ReAuthAccountModal } from '@/components/account'
|
||||
import { CreateAccountModal, EditAccountModal, ReAuthAccountModal, AccountStatsModal } from '@/components/account'
|
||||
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
|
||||
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
|
||||
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
||||
import AccountTestModal from '@/components/account/AccountTestModal.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
import { formatRelativeTime } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -303,6 +350,7 @@ const columns = computed<Column[]>(() => [
|
||||
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
|
||||
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
|
||||
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false },
|
||||
{ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false },
|
||||
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
|
||||
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
|
||||
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
|
||||
@@ -312,7 +360,8 @@ const columns = computed<Column[]>(() => [
|
||||
// Filter options
|
||||
const platformOptions = computed(() => [
|
||||
{ value: '', label: t('admin.accounts.allPlatforms') },
|
||||
{ value: 'anthropic', label: t('admin.accounts.platforms.anthropic') }
|
||||
{ value: 'anthropic', label: t('admin.accounts.platforms.anthropic') },
|
||||
{ value: 'openai', label: t('admin.accounts.platforms.openai') }
|
||||
])
|
||||
|
||||
const typeOptions = computed(() => [
|
||||
@@ -353,10 +402,12 @@ const showEditModal = ref(false)
|
||||
const showReAuthModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showTestModal = ref(false)
|
||||
const showStatsModal = ref(false)
|
||||
const editingAccount = ref<Account | null>(null)
|
||||
const reAuthAccount = ref<Account | null>(null)
|
||||
const deletingAccount = ref<Account | null>(null)
|
||||
const testingAccount = ref<Account | null>(null)
|
||||
const statsAccount = ref<Account | null>(null)
|
||||
const togglingSchedulable = ref<number | null>(null)
|
||||
|
||||
// Rate limit / Overload helpers
|
||||
@@ -405,7 +456,8 @@ const loadProxies = async () => {
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
groups.value = await adminAPI.groups.getByPlatform('anthropic')
|
||||
// Load groups for all platforms to support both Anthropic and OpenAI accounts
|
||||
groups.value = await adminAPI.groups.getAll()
|
||||
} catch (error) {
|
||||
console.error('Error loading groups:', error)
|
||||
}
|
||||
@@ -494,6 +546,23 @@ const handleClearRateLimit = async (account: Account) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Reset account status (clear error and rate limit)
|
||||
const handleResetStatus = async (account: Account) => {
|
||||
try {
|
||||
// Clear error status
|
||||
await adminAPI.accounts.clearError(account.id)
|
||||
// Also clear rate limit if exists
|
||||
if (isRateLimited(account) || isOverloaded(account)) {
|
||||
await adminAPI.accounts.clearRateLimit(account.id)
|
||||
}
|
||||
appStore.showSuccess(t('admin.accounts.statusReset'))
|
||||
loadAccounts()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToResetStatus'))
|
||||
console.error('Error resetting account status:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle schedulable
|
||||
const handleToggleSchedulable = async (account: Account) => {
|
||||
togglingSchedulable.value = account.id
|
||||
@@ -527,6 +596,17 @@ const closeTestModal = () => {
|
||||
testingAccount.value = null
|
||||
}
|
||||
|
||||
// Stats modal
|
||||
const handleViewStats = (account: Account) => {
|
||||
statsAccount.value = account
|
||||
showStatsModal.value = true
|
||||
}
|
||||
|
||||
const closeStatsModal = () => {
|
||||
showStatsModal.value = false
|
||||
statsAccount.value = null
|
||||
}
|
||||
|
||||
// Initialize
|
||||
onMounted(() => {
|
||||
loadAccounts()
|
||||
|
||||
@@ -60,14 +60,46 @@
|
||||
</template>
|
||||
|
||||
<template #cell-platform="{ value }">
|
||||
<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">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
</svg>
|
||||
{{ value.charAt(0).toUpperCase() + value.slice(1) }}
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
value === 'anthropic'
|
||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
]"
|
||||
>
|
||||
<PlatformIcon :platform="value" size="xs" />
|
||||
{{ value === 'anthropic' ? 'Anthropic' : 'OpenAI' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-billing_type="{ row }">
|
||||
<div class="space-y-1">
|
||||
<!-- Type Badge -->
|
||||
<span
|
||||
:class="[
|
||||
'inline-block px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
row.subscription_type === 'subscription'
|
||||
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
]"
|
||||
>
|
||||
{{ row.subscription_type === 'subscription' ? t('admin.groups.subscription.subscription') : t('admin.groups.subscription.standard') }}
|
||||
</span>
|
||||
<!-- Subscription Limits - compact single line -->
|
||||
<div v-if="row.subscription_type === 'subscription'" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<template v-if="row.daily_limit_usd || row.weekly_limit_usd || row.monthly_limit_usd">
|
||||
<span v-if="row.daily_limit_usd">${{ row.daily_limit_usd }}/{{ t('admin.groups.limitDay') }}</span>
|
||||
<span v-if="row.daily_limit_usd && (row.weekly_limit_usd || row.monthly_limit_usd)" class="mx-1 text-gray-300 dark:text-gray-600">·</span>
|
||||
<span v-if="row.weekly_limit_usd">${{ row.weekly_limit_usd }}/{{ t('admin.groups.limitWeek') }}</span>
|
||||
<span v-if="row.weekly_limit_usd && row.monthly_limit_usd" class="mx-1 text-gray-300 dark:text-gray-600">·</span>
|
||||
<span v-if="row.monthly_limit_usd">${{ row.monthly_limit_usd }}/{{ t('admin.groups.limitMonth') }}</span>
|
||||
</template>
|
||||
<span v-else class="text-gray-400 dark:text-gray-500">{{ t('admin.groups.subscription.noLimit') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-rate_multiplier="{ value }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}x</span>
|
||||
</template>
|
||||
@@ -186,8 +218,8 @@
|
||||
<input
|
||||
v-model.number="createForm.rate_multiplier"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
step="0.001"
|
||||
min="0.001"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
@@ -323,15 +355,17 @@
|
||||
<Select
|
||||
v-model="editForm.platform"
|
||||
:options="platformOptions"
|
||||
:disabled="true"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p>
|
||||
</div>
|
||||
<div v-if="editForm.subscription_type !== 'subscription'">
|
||||
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.rate_multiplier"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
step="0.001"
|
||||
min="0.001"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
@@ -472,6 +506,7 @@ import Modal from '@/components/common/Modal.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -479,6 +514,7 @@ const appStore = useAppStore()
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'name', label: t('admin.groups.columns.name'), sortable: true },
|
||||
{ key: 'platform', label: t('admin.groups.columns.platform'), sortable: true },
|
||||
{ key: 'billing_type', label: t('admin.groups.columns.billingType'), sortable: true },
|
||||
{ key: 'rate_multiplier', label: t('admin.groups.columns.rateMultiplier'), sortable: true },
|
||||
{ key: 'is_exclusive', label: t('admin.groups.columns.type'), sortable: true },
|
||||
{ key: 'account_count', label: t('admin.groups.columns.accounts'), sortable: true },
|
||||
@@ -500,14 +536,14 @@ const exclusiveOptions = computed(() => [
|
||||
])
|
||||
|
||||
const platformOptions = computed(() => [
|
||||
{ value: 'anthropic', label: 'Anthropic' }
|
||||
// Future: { value: 'openai', label: 'OpenAI' },
|
||||
// Future: { value: 'gemini', label: 'Gemini' }
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'openai', label: 'OpenAI' }
|
||||
])
|
||||
|
||||
const platformFilterOptions = computed(() => [
|
||||
{ value: '', label: t('admin.groups.allPlatforms') },
|
||||
{ value: 'anthropic', label: 'Anthropic' }
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'openai', label: 'OpenAI' }
|
||||
])
|
||||
|
||||
const editStatusOptions = computed(() => [
|
||||
|
||||
@@ -60,52 +60,95 @@
|
||||
</template>
|
||||
|
||||
<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">
|
||||
{{ row.group?.name || `Group #${row.group_id}` }}
|
||||
</span>
|
||||
<GroupBadge
|
||||
v-if="row.group"
|
||||
: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 #cell-usage="{ row }">
|
||||
<div class="space-y-1 min-w-[200px]">
|
||||
<div v-if="row.group?.daily_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500 w-12">{{ t('admin.subscriptions.daily') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all"
|
||||
:class="getProgressClass(row.daily_usage_usd, row.group?.daily_limit_usd)"
|
||||
:style="{ width: getProgressWidth(row.daily_usage_usd, row.group?.daily_limit_usd) }"
|
||||
></div>
|
||||
<div class="space-y-2 min-w-[280px]">
|
||||
<!-- Daily Usage -->
|
||||
<div v-if="row.group?.daily_limit_usd" class="usage-row">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="usage-label">{{ t('admin.subscriptions.daily') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="getProgressClass(row.daily_usage_usd, row.group?.daily_limit_usd)"
|
||||
:style="{ width: getProgressWidth(row.daily_usage_usd, row.group?.daily_limit_usd) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="usage-amount">
|
||||
${{ row.daily_usage_usd?.toFixed(2) || '0.00' }}
|
||||
<span class="text-gray-400">/</span>
|
||||
${{ row.group?.daily_limit_usd?.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 w-20 text-right">
|
||||
${{ row.daily_usage_usd?.toFixed(2) || '0.00' }} / ${{ row.group?.daily_limit_usd?.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="row.group?.weekly_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500 w-12">{{ t('admin.subscriptions.weekly') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all"
|
||||
:class="getProgressClass(row.weekly_usage_usd, row.group?.weekly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(row.weekly_usage_usd, row.group?.weekly_limit_usd) }"
|
||||
></div>
|
||||
<div class="reset-info" v-if="row.daily_window_start">
|
||||
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ formatResetTime(row.daily_window_start, 'daily') }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 w-20 text-right">
|
||||
${{ row.weekly_usage_usd?.toFixed(2) || '0.00' }} / ${{ row.group?.weekly_limit_usd?.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="row.group?.monthly_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500 w-12">{{ t('admin.subscriptions.monthly') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all"
|
||||
:class="getProgressClass(row.monthly_usage_usd, row.group?.monthly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(row.monthly_usage_usd, row.group?.monthly_limit_usd) }"
|
||||
></div>
|
||||
|
||||
<!-- Weekly Usage -->
|
||||
<div v-if="row.group?.weekly_limit_usd" class="usage-row">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="usage-label">{{ t('admin.subscriptions.weekly') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="getProgressClass(row.weekly_usage_usd, row.group?.weekly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(row.weekly_usage_usd, row.group?.weekly_limit_usd) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="usage-amount">
|
||||
${{ row.weekly_usage_usd?.toFixed(2) || '0.00' }}
|
||||
<span class="text-gray-400">/</span>
|
||||
${{ row.group?.weekly_limit_usd?.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="reset-info" v-if="row.weekly_window_start">
|
||||
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ formatResetTime(row.weekly_window_start, 'weekly') }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 w-20 text-right">
|
||||
${{ row.monthly_usage_usd?.toFixed(2) || '0.00' }} / ${{ row.group?.monthly_limit_usd?.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Usage -->
|
||||
<div v-if="row.group?.monthly_limit_usd" class="usage-row">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="usage-label">{{ t('admin.subscriptions.monthly') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="getProgressClass(row.monthly_usage_usd, row.group?.monthly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(row.monthly_usage_usd, row.group?.monthly_limit_usd) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="usage-amount">
|
||||
${{ row.monthly_usage_usd?.toFixed(2) || '0.00' }}
|
||||
<span class="text-gray-400">/</span>
|
||||
${{ row.group?.monthly_limit_usd?.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="reset-info" v-if="row.monthly_window_start">
|
||||
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ formatResetTime(row.monthly_window_start, 'monthly') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Limits -->
|
||||
<div v-if="!row.group?.daily_limit_usd && !row.group?.weekly_limit_usd && !row.group?.monthly_limit_usd" class="text-xs text-gray-500">
|
||||
{{ t('admin.subscriptions.noLimits') }}
|
||||
</div>
|
||||
@@ -324,6 +367,7 @@ import Modal from '@/components/common/Modal.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -553,9 +597,65 @@ const getProgressClass = (used: number, limit: number | null): string => {
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
// Format reset time based on window start and period type
|
||||
const formatResetTime = (windowStart: string, period: 'daily' | 'weekly' | 'monthly'): string => {
|
||||
if (!windowStart) return t('admin.subscriptions.windowNotActive')
|
||||
|
||||
const start = new Date(windowStart)
|
||||
const now = new Date()
|
||||
|
||||
// Calculate reset time based on period
|
||||
let resetTime: Date
|
||||
switch (period) {
|
||||
case 'daily':
|
||||
resetTime = new Date(start.getTime() + 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'weekly':
|
||||
resetTime = new Date(start.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'monthly':
|
||||
resetTime = new Date(start.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
}
|
||||
|
||||
const diffMs = resetTime.getTime() - now.getTime()
|
||||
if (diffMs <= 0) return t('admin.subscriptions.windowNotActive')
|
||||
|
||||
const diffSeconds = Math.floor(diffMs / 1000)
|
||||
const days = Math.floor(diffSeconds / 86400)
|
||||
const hours = Math.floor((diffSeconds % 86400) / 3600)
|
||||
const minutes = Math.floor((diffSeconds % 3600) / 60)
|
||||
|
||||
if (days > 0) {
|
||||
return t('admin.subscriptions.resetInDaysHours', { days, hours })
|
||||
} else if (hours > 0) {
|
||||
return t('admin.subscriptions.resetInHoursMinutes', { hours, minutes })
|
||||
} else {
|
||||
return t('admin.subscriptions.resetInMinutes', { minutes })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSubscriptions()
|
||||
loadGroups()
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.usage-row {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.usage-label {
|
||||
@apply text-xs font-medium text-gray-500 dark:text-gray-400 w-10 flex-shrink-0;
|
||||
}
|
||||
|
||||
.usage-amount {
|
||||
@apply text-xs text-gray-600 dark:text-gray-300 tabular-nums whitespace-nowrap;
|
||||
}
|
||||
|
||||
.reset-info {
|
||||
@apply flex items-center gap-1 text-[10px] text-blue-600 dark:text-blue-400 pl-12;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -73,6 +73,27 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-username="{ value }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-wechat="{ value }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-notes="{ value }">
|
||||
<div class="max-w-xs">
|
||||
<span
|
||||
v-if="value"
|
||||
:title="value.length > 30 ? value : undefined"
|
||||
class="text-sm text-gray-600 dark:text-gray-400 block truncate"
|
||||
>
|
||||
{{ value.length > 30 ? value.substring(0, 25) + '...' : value }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400">-</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-role="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
@@ -84,6 +105,27 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-subscriptions="{ row }">
|
||||
<div v-if="row.subscriptions && row.subscriptions.length > 0" class="flex flex-wrap gap-1.5">
|
||||
<GroupBadge
|
||||
v-for="sub in row.subscriptions"
|
||||
:key="sub.id"
|
||||
:name="sub.group?.name || ''"
|
||||
:platform="sub.group?.platform"
|
||||
:subscription-type="sub.group?.subscription_type"
|
||||
:rate-multiplier="sub.group?.rate_multiplier"
|
||||
:days-remaining="sub.expires_at ? getDaysRemaining(sub.expires_at) : null"
|
||||
:title="sub.expires_at ? formatExpiresAt(sub.expires_at) : ''"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs text-gray-400 dark:text-dark-500 bg-gray-50 dark:bg-dark-700/50">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
<span>{{ t('admin.users.noSubscription') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-balance="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
|
||||
</template>
|
||||
@@ -165,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" />
|
||||
</svg>
|
||||
</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 -->
|
||||
<button
|
||||
@click="handleEdit(row)"
|
||||
@@ -272,6 +334,34 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.username') }}</label>
|
||||
<input
|
||||
v-model="createForm.username"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.users.enterUsername')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.wechat') }}</label>
|
||||
<input
|
||||
v-model="createForm.wechat"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.users.enterWechat')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.notes') }}</label>
|
||||
<textarea
|
||||
v-model="createForm.notes"
|
||||
rows="3"
|
||||
class="input"
|
||||
:placeholder="t('admin.users.enterNotes')"
|
||||
></textarea>
|
||||
<p class="input-hint">{{ t('admin.users.notesHint') }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
|
||||
@@ -378,24 +468,41 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.balance"
|
||||
type="number"
|
||||
step="any"
|
||||
class="input"
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.username') }}</label>
|
||||
<input
|
||||
v-model="editForm.username"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.users.enterUsername')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.wechat') }}</label>
|
||||
<input
|
||||
v-model="editForm.wechat"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.users.enterWechat')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.notes') }}</label>
|
||||
<textarea
|
||||
v-model="editForm.notes"
|
||||
rows="3"
|
||||
class="input"
|
||||
:placeholder="t('admin.users.enterNotes')"
|
||||
></textarea>
|
||||
<p class="input-hint">{{ t('admin.users.notesHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.concurrency"
|
||||
type="number"
|
||||
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 class="flex justify-end gap-3 pt-4">
|
||||
@@ -631,6 +738,114 @@
|
||||
</template>
|
||||
</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 -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
@@ -662,12 +877,17 @@ import Modal from '@/components/common/Modal.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'email', label: t('admin.users.columns.user'), sortable: true },
|
||||
{ key: 'username', label: t('admin.users.columns.username'), sortable: true },
|
||||
{ key: 'wechat', label: t('admin.users.columns.wechat'), sortable: false },
|
||||
{ key: 'notes', label: t('admin.users.columns.notes'), sortable: false },
|
||||
{ key: 'role', label: t('admin.users.columns.role'), sortable: true },
|
||||
{ key: 'subscriptions', label: t('admin.users.columns.subscriptions'), sortable: false },
|
||||
{ key: 'balance', label: t('admin.users.columns.balance'), sortable: true },
|
||||
{ key: 'usage', label: t('admin.users.columns.usage'), sortable: false },
|
||||
{ key: 'concurrency', label: t('admin.users.columns.concurrency'), sortable: true },
|
||||
@@ -725,9 +945,22 @@ const selectedGroupIds = ref<number[]>([])
|
||||
const loadingGroups = 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({
|
||||
email: '',
|
||||
password: '',
|
||||
username: '',
|
||||
wechat: '',
|
||||
notes: '',
|
||||
balance: 0,
|
||||
concurrency: 1
|
||||
})
|
||||
@@ -735,7 +968,9 @@ const createForm = reactive({
|
||||
const editForm = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
balance: 0,
|
||||
username: '',
|
||||
wechat: '',
|
||||
notes: '',
|
||||
concurrency: 1
|
||||
})
|
||||
const editPasswordCopied = ref(false)
|
||||
@@ -749,6 +984,20 @@ const formatDate = (dateString: string): string => {
|
||||
})
|
||||
}
|
||||
|
||||
// 计算剩余天数
|
||||
const getDaysRemaining = (expiresAt: string): number => {
|
||||
const now = new Date()
|
||||
const expires = new Date(expiresAt)
|
||||
const diffMs = expires.getTime() - now.getTime()
|
||||
return Math.ceil(diffMs / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
// 格式化过期时间(用于 tooltip)
|
||||
const formatExpiresAt = (expiresAt: string): string => {
|
||||
const date = new Date(expiresAt)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const generateRandomPasswordStr = () => {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
|
||||
let password = ''
|
||||
@@ -844,6 +1093,9 @@ const closeCreateModal = () => {
|
||||
showCreateModal.value = false
|
||||
createForm.email = ''
|
||||
createForm.password = ''
|
||||
createForm.username = ''
|
||||
createForm.wechat = ''
|
||||
createForm.notes = ''
|
||||
createForm.balance = 0
|
||||
createForm.concurrency = 1
|
||||
passwordCopied.value = false
|
||||
@@ -868,7 +1120,9 @@ const handleEdit = (user: User) => {
|
||||
editingUser.value = user
|
||||
editForm.email = user.email
|
||||
editForm.password = ''
|
||||
editForm.balance = user.balance
|
||||
editForm.username = user.username || ''
|
||||
editForm.wechat = user.wechat || ''
|
||||
editForm.notes = user.notes || ''
|
||||
editForm.concurrency = user.concurrency
|
||||
editPasswordCopied.value = false
|
||||
showEditModal.value = true
|
||||
@@ -886,10 +1140,11 @@ const handleUpdateUser = async () => {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
// Build update data - only include password if not empty
|
||||
const updateData: Record<string, any> = {
|
||||
email: editForm.email,
|
||||
balance: editForm.balance,
|
||||
username: editForm.username,
|
||||
wechat: editForm.wechat,
|
||||
notes: editForm.notes,
|
||||
concurrency: editForm.concurrency
|
||||
}
|
||||
if (editForm.password.trim()) {
|
||||
@@ -1009,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(() => {
|
||||
loadUsers()
|
||||
})
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
<GroupBadge
|
||||
v-if="row.group"
|
||||
:name="row.group.name"
|
||||
:platform="row.group.platform"
|
||||
:subscription-type="row.group.subscription_type"
|
||||
:rate-multiplier="row.group.rate_multiplier"
|
||||
/>
|
||||
@@ -231,6 +232,7 @@
|
||||
<GroupBadge
|
||||
v-if="option"
|
||||
:name="(option as unknown as GroupOption).label"
|
||||
:platform="(option as unknown as GroupOption).platform"
|
||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||
/>
|
||||
@@ -239,6 +241,7 @@
|
||||
<template #option="{ option }">
|
||||
<GroupBadge
|
||||
:name="(option as unknown as GroupOption).label"
|
||||
:platform="(option as unknown as GroupOption).platform"
|
||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||
/>
|
||||
@@ -358,6 +361,7 @@
|
||||
>
|
||||
<GroupBadge
|
||||
:name="option.label"
|
||||
:platform="option.platform"
|
||||
:subscription-type="option.subscriptionType"
|
||||
:rate-multiplier="option.rate"
|
||||
/>
|
||||
@@ -394,7 +398,7 @@ import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import UseKeyModal from '@/components/keys/UseKeyModal.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import type { ApiKey, Group, PublicSettings, SubscriptionType } from '@/types'
|
||||
import type { ApiKey, Group, PublicSettings, SubscriptionType, GroupPlatform } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import type { BatchApiKeyUsageStats } from '@/api/usage'
|
||||
|
||||
@@ -403,6 +407,7 @@ interface GroupOption {
|
||||
label: string
|
||||
rate: number
|
||||
subscriptionType: SubscriptionType
|
||||
platform: GroupPlatform
|
||||
}
|
||||
|
||||
const appStore = useAppStore()
|
||||
@@ -491,7 +496,8 @@ const groupOptions = computed(() =>
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
rate: group.rate_multiplier,
|
||||
subscriptionType: group.subscription_type
|
||||
subscriptionType: group.subscription_type,
|
||||
platform: group.platform
|
||||
}))
|
||||
)
|
||||
|
||||
|
||||
@@ -55,11 +55,25 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-4 h-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
<span class="truncate">{{ user?.email }}</span>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-4 h-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
<span class="truncate">{{ user?.email }}</span>
|
||||
</div>
|
||||
<div v-if="user?.username" class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-4 h-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
<span class="truncate">{{ user.username }}</span>
|
||||
</div>
|
||||
<div v-if="user?.wechat" class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-4 h-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
||||
</svg>
|
||||
<span class="truncate">{{ user.wechat }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,6 +95,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Profile Section -->
|
||||
<div class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">{{ t('profile.editProfile') }}</h2>
|
||||
</div>
|
||||
<div class="px-6 py-6">
|
||||
<form @submit.prevent="handleUpdateProfile" class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="input-label">
|
||||
{{ t('profile.username') }}
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="profileForm.username"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('profile.enterUsername')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="wechat" class="input-label">
|
||||
{{ t('profile.wechat') }}
|
||||
</label>
|
||||
<input
|
||||
id="wechat"
|
||||
v-model="profileForm.wechat"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('profile.enterWechat')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="updatingProfile"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ updatingProfile ? t('profile.updating') : t('profile.updateProfile') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Section -->
|
||||
<div class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
|
||||
@@ -191,13 +251,25 @@ const passwordForm = ref({
|
||||
confirm_password: ''
|
||||
})
|
||||
|
||||
const profileForm = ref({
|
||||
username: '',
|
||||
wechat: ''
|
||||
})
|
||||
|
||||
const changingPassword = ref(false)
|
||||
const updatingProfile = ref(false)
|
||||
const contactInfo = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await authAPI.getPublicSettings()
|
||||
contactInfo.value = settings.contact_info || ''
|
||||
|
||||
// Initialize profile form with current user data
|
||||
if (user.value) {
|
||||
profileForm.value.username = user.value.username || ''
|
||||
profileForm.value.wechat = user.value.wechat || ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load contact info:', error)
|
||||
}
|
||||
@@ -250,4 +322,23 @@ const handleChangePassword = async () => {
|
||||
changingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
updatingProfile.value = true
|
||||
try {
|
||||
const updatedUser = await userAPI.updateProfile({
|
||||
username: profileForm.value.username,
|
||||
wechat: profileForm.value.wechat
|
||||
})
|
||||
|
||||
// Update auth store with new user data
|
||||
authStore.user = updatedUser
|
||||
|
||||
appStore.showSuccess(t('profile.updateSuccess'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('profile.updateFailed'))
|
||||
} finally {
|
||||
updatingProfile.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -229,14 +229,14 @@ function getExpirationClass(expiresAt: string): string {
|
||||
}
|
||||
|
||||
function formatResetTime(windowStart: string | null, windowHours: number): string {
|
||||
if (!windowStart) return '--';
|
||||
if (!windowStart) return t('userSubscriptions.windowNotActive');
|
||||
|
||||
const start = new Date(windowStart);
|
||||
const end = new Date(start.getTime() + windowHours * 60 * 60 * 1000);
|
||||
const now = new Date();
|
||||
const diff = end.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) return 'Now';
|
||||
if (diff <= 0) return t('userSubscriptions.windowNotActive');
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
@@ -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/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/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