Merge branch 'main' into feature/antigravity_auth
This commit is contained in:
10
README_CN.md
10
README_CN.md
@@ -283,6 +283,16 @@ npm run dev
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 简易模式
|
||||||
|
|
||||||
|
简易模式适合个人开发者或内部团队快速使用,不依赖完整 SaaS 功能。
|
||||||
|
|
||||||
|
- 启用方式:设置环境变量 `RUN_MODE=simple`
|
||||||
|
- 功能差异:隐藏 SaaS 相关功能,跳过计费流程
|
||||||
|
- 安全注意事项:生产环境需同时设置 `SIMPLE_MODE_CONFIRM=true` 才允许启动
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -107,6 +107,14 @@ func runSetupServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runMainServer() {
|
func runMainServer() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.RunMode == config.RunModeSimple {
|
||||||
|
log.Println("⚠️ WARNING: Running in SIMPLE mode - billing and quota checks are DISABLED")
|
||||||
|
}
|
||||||
|
|
||||||
buildInfo := handler.BuildInfo{
|
buildInfo := handler.BuildInfo{
|
||||||
Version: Version,
|
Version: Version,
|
||||||
BuildType: BuildType,
|
BuildType: BuildType,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
emailQueueService := service.ProvideEmailQueueService(emailService)
|
emailQueueService := service.ProvideEmailQueueService(emailService)
|
||||||
authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService)
|
authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService)
|
||||||
userService := service.NewUserService(userRepository)
|
userService := service.NewUserService(userRepository)
|
||||||
authHandler := handler.NewAuthHandler(authService, userService)
|
authHandler := handler.NewAuthHandler(configConfig, authService, userService)
|
||||||
userHandler := handler.NewUserHandler(userService)
|
userHandler := handler.NewUserHandler(userService)
|
||||||
apiKeyRepository := repository.NewApiKeyRepository(db)
|
apiKeyRepository := repository.NewApiKeyRepository(db)
|
||||||
groupRepository := repository.NewGroupRepository(db)
|
groupRepository := repository.NewGroupRepository(db)
|
||||||
@@ -62,7 +62,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
||||||
redeemCodeRepository := repository.NewRedeemCodeRepository(db)
|
redeemCodeRepository := repository.NewRedeemCodeRepository(db)
|
||||||
billingCache := repository.NewBillingCache(client)
|
billingCache := repository.NewBillingCache(client)
|
||||||
billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository)
|
billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository, configConfig)
|
||||||
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService)
|
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService)
|
||||||
redeemCache := repository.NewRedeemCache(client)
|
redeemCache := repository.NewRedeemCache(client)
|
||||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService)
|
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService)
|
||||||
@@ -132,7 +132,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler)
|
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler)
|
||||||
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
|
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
|
||||||
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
|
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
|
||||||
apiKeyAuthMiddleware := middleware.NewApiKeyAuthMiddleware(apiKeyService, subscriptionService)
|
apiKeyAuthMiddleware := middleware.NewApiKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
|
||||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService)
|
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService)
|
||||||
httpServer := server.ProvideHTTPServer(configConfig, engine)
|
httpServer := server.ProvideHTTPServer(configConfig, engine)
|
||||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig)
|
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig)
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
RunModeStandard = "standard"
|
||||||
|
RunModeSimple = "simple"
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `mapstructure:"server"`
|
Server ServerConfig `mapstructure:"server"`
|
||||||
Database DatabaseConfig `mapstructure:"database"`
|
Database DatabaseConfig `mapstructure:"database"`
|
||||||
@@ -17,6 +22,7 @@ type Config struct {
|
|||||||
Pricing PricingConfig `mapstructure:"pricing"`
|
Pricing PricingConfig `mapstructure:"pricing"`
|
||||||
Gateway GatewayConfig `mapstructure:"gateway"`
|
Gateway GatewayConfig `mapstructure:"gateway"`
|
||||||
TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
|
TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
|
||||||
|
RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
|
||||||
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
|
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
|
||||||
Gemini GeminiConfig `mapstructure:"gemini"`
|
Gemini GeminiConfig `mapstructure:"gemini"`
|
||||||
}
|
}
|
||||||
@@ -135,6 +141,16 @@ type RateLimitConfig struct {
|
|||||||
OverloadCooldownMinutes int `mapstructure:"overload_cooldown_minutes"` // 529过载冷却时间(分钟)
|
OverloadCooldownMinutes int `mapstructure:"overload_cooldown_minutes"` // 529过载冷却时间(分钟)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NormalizeRunMode(value string) string {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
switch normalized {
|
||||||
|
case RunModeStandard, RunModeSimple:
|
||||||
|
return normalized
|
||||||
|
default:
|
||||||
|
return RunModeStandard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
viper.SetConfigName("config")
|
viper.SetConfigName("config")
|
||||||
viper.SetConfigType("yaml")
|
viper.SetConfigType("yaml")
|
||||||
@@ -161,6 +177,8 @@ func Load() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("unmarshal config error: %w", err)
|
return nil, fmt.Errorf("unmarshal config error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg.RunMode = NormalizeRunMode(cfg.RunMode)
|
||||||
|
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, fmt.Errorf("validate config error: %w", err)
|
return nil, fmt.Errorf("validate config error: %w", err)
|
||||||
}
|
}
|
||||||
@@ -169,6 +187,8 @@ func Load() (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setDefaults() {
|
func setDefaults() {
|
||||||
|
viper.SetDefault("run_mode", RunModeStandard)
|
||||||
|
|
||||||
// Server
|
// Server
|
||||||
viper.SetDefault("server.host", "0.0.0.0")
|
viper.SetDefault("server.host", "0.0.0.0")
|
||||||
viper.SetDefault("server.port", 8080)
|
viper.SetDefault("server.port", 8080)
|
||||||
|
|||||||
23
backend/internal/config/config_test.go
Normal file
23
backend/internal/config/config_test.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeRunMode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"simple", "simple"},
|
||||||
|
{"SIMPLE", "simple"},
|
||||||
|
{"standard", "standard"},
|
||||||
|
{"invalid", "standard"},
|
||||||
|
{"", "standard"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := NormalizeRunMode(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("NormalizeRunMode(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
@@ -11,13 +12,15 @@ import (
|
|||||||
|
|
||||||
// AuthHandler handles authentication-related requests
|
// AuthHandler handles authentication-related requests
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
|
cfg *config.Config
|
||||||
authService *service.AuthService
|
authService *service.AuthService
|
||||||
userService *service.UserService
|
userService *service.UserService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthHandler creates a new AuthHandler
|
// NewAuthHandler creates a new AuthHandler
|
||||||
func NewAuthHandler(authService *service.AuthService, userService *service.UserService) *AuthHandler {
|
func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService) *AuthHandler {
|
||||||
return &AuthHandler{
|
return &AuthHandler{
|
||||||
|
cfg: cfg,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
}
|
}
|
||||||
@@ -157,5 +160,15 @@ func (h *AuthHandler) GetCurrentUser(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, dto.UserFromService(user))
|
type UserResponse struct {
|
||||||
|
*dto.User
|
||||||
|
RunMode string `json:"run_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
runMode := config.RunModeStandard
|
||||||
|
if h.cfg != nil {
|
||||||
|
runMode = h.cfg.RunMode
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, UserResponse{User: dto.UserFromService(user), RunMode: runMode})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 创建默认分组(简易模式支持)
|
||||||
|
if err := ensureDefaultGroups(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// 修复无效的过期时间(年份超过 2099 会导致 JSON 序列化失败)
|
// 修复无效的过期时间(年份超过 2099 会导致 JSON 序列化失败)
|
||||||
return fixInvalidExpiresAt(db)
|
return fixInvalidExpiresAt(db)
|
||||||
}
|
}
|
||||||
@@ -47,3 +52,55 @@ func fixInvalidExpiresAt(db *gorm.DB) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureDefaultGroups 确保默认分组存在(简易模式支持)
|
||||||
|
// 为每个平台创建一个默认分组,配置最大权限以确保简易模式下不受限制
|
||||||
|
func ensureDefaultGroups(db *gorm.DB) error {
|
||||||
|
defaultGroups := []struct {
|
||||||
|
name string
|
||||||
|
platform string
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "anthropic-default",
|
||||||
|
platform: "anthropic",
|
||||||
|
description: "Default group for Anthropic accounts (Simple Mode)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openai-default",
|
||||||
|
platform: "openai",
|
||||||
|
description: "Default group for OpenAI accounts (Simple Mode)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gemini-default",
|
||||||
|
platform: "gemini",
|
||||||
|
description: "Default group for Gemini accounts (Simple Mode)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dg := range defaultGroups {
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&groupModel{}).Where("name = ?", dg.name).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
group := &groupModel{
|
||||||
|
Name: dg.name,
|
||||||
|
Description: dg.description,
|
||||||
|
Platform: dg.platform,
|
||||||
|
RateMultiplier: 1.0,
|
||||||
|
IsExclusive: false,
|
||||||
|
Status: "active",
|
||||||
|
SubscriptionType: "standard",
|
||||||
|
}
|
||||||
|
if err := db.Create(group).Error; err != nil {
|
||||||
|
log.Printf("[AutoMigrate] Failed to create default group %s: %v", dg.name, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("[AutoMigrate] Created default group: %s (platform: %s)", dg.name, dg.platform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,8 +82,9 @@ func (s *GroupRepoSuite) TestList() {
|
|||||||
|
|
||||||
groups, page, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10})
|
groups, page, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10})
|
||||||
s.Require().NoError(err, "List")
|
s.Require().NoError(err, "List")
|
||||||
s.Require().Len(groups, 2)
|
// 3 default groups + 2 test groups = 5 total
|
||||||
s.Require().Equal(int64(2), page.Total)
|
s.Require().Len(groups, 5)
|
||||||
|
s.Require().Equal(int64(5), page.Total)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GroupRepoSuite) TestListWithFilters_Platform() {
|
func (s *GroupRepoSuite) TestListWithFilters_Platform() {
|
||||||
@@ -92,8 +93,12 @@ func (s *GroupRepoSuite) TestListWithFilters_Platform() {
|
|||||||
|
|
||||||
groups, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.PlatformOpenAI, "", nil)
|
groups, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.PlatformOpenAI, "", nil)
|
||||||
s.Require().NoError(err)
|
s.Require().NoError(err)
|
||||||
s.Require().Len(groups, 1)
|
// 1 default openai group + 1 test openai group = 2 total
|
||||||
s.Require().Equal(service.PlatformOpenAI, groups[0].Platform)
|
s.Require().Len(groups, 2)
|
||||||
|
// Verify all groups are OpenAI platform
|
||||||
|
for _, g := range groups {
|
||||||
|
s.Require().Equal(service.PlatformOpenAI, g.Platform)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GroupRepoSuite) TestListWithFilters_Status() {
|
func (s *GroupRepoSuite) TestListWithFilters_Status() {
|
||||||
@@ -151,8 +156,17 @@ func (s *GroupRepoSuite) TestListActive() {
|
|||||||
|
|
||||||
groups, err := s.repo.ListActive(s.ctx)
|
groups, err := s.repo.ListActive(s.ctx)
|
||||||
s.Require().NoError(err, "ListActive")
|
s.Require().NoError(err, "ListActive")
|
||||||
s.Require().Len(groups, 1)
|
// 3 default groups (all active) + 1 test active group = 4 total
|
||||||
s.Require().Equal("active1", groups[0].Name)
|
s.Require().Len(groups, 4)
|
||||||
|
// Verify our test group is in the results
|
||||||
|
var found bool
|
||||||
|
for _, g := range groups {
|
||||||
|
if g.Name == "active1" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Require().True(found, "active1 group should be in results")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GroupRepoSuite) TestListActiveByPlatform() {
|
func (s *GroupRepoSuite) TestListActiveByPlatform() {
|
||||||
@@ -162,8 +176,17 @@ func (s *GroupRepoSuite) TestListActiveByPlatform() {
|
|||||||
|
|
||||||
groups, err := s.repo.ListActiveByPlatform(s.ctx, service.PlatformAnthropic)
|
groups, err := s.repo.ListActiveByPlatform(s.ctx, service.PlatformAnthropic)
|
||||||
s.Require().NoError(err, "ListActiveByPlatform")
|
s.Require().NoError(err, "ListActiveByPlatform")
|
||||||
s.Require().Len(groups, 1)
|
// 1 default anthropic group + 1 test active anthropic group = 2 total
|
||||||
s.Require().Equal("g1", groups[0].Name)
|
s.Require().Len(groups, 2)
|
||||||
|
// Verify our test group is in the results
|
||||||
|
var found bool
|
||||||
|
for _, g := range groups {
|
||||||
|
if g.Name == "g1" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Require().True(found, "g1 group should be in results")
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ExistsByName ---
|
// --- ExistsByName ---
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"status": "active",
|
"status": "active",
|
||||||
"allowed_groups": null,
|
"allowed_groups": null,
|
||||||
"created_at": "2025-01-02T03:04:05Z",
|
"created_at": "2025-01-02T03:04:05Z",
|
||||||
"updated_at": "2025-01-02T03:04:05Z"
|
"updated_at": "2025-01-02T03:04:05Z",
|
||||||
|
"run_mode": "standard"
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
},
|
},
|
||||||
@@ -369,6 +370,7 @@ func newContractDeps(t *testing.T) *contractDeps {
|
|||||||
Default: config.DefaultConfig{
|
Default: config.DefaultConfig{
|
||||||
ApiKeyPrefix: "sk-",
|
ApiKeyPrefix: "sk-",
|
||||||
},
|
},
|
||||||
|
RunMode: config.RunModeStandard,
|
||||||
}
|
}
|
||||||
|
|
||||||
userService := service.NewUserService(userRepo)
|
userService := service.NewUserService(userRepo)
|
||||||
@@ -380,7 +382,7 @@ func newContractDeps(t *testing.T) *contractDeps {
|
|||||||
settingRepo := newStubSettingRepo()
|
settingRepo := newStubSettingRepo()
|
||||||
settingService := service.NewSettingService(settingRepo, cfg)
|
settingService := service.NewSettingService(settingRepo, cfg)
|
||||||
|
|
||||||
authHandler := handler.NewAuthHandler(nil, userService)
|
authHandler := handler.NewAuthHandler(cfg, nil, userService)
|
||||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||||
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
||||||
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil)
|
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func ProvideRouter(
|
|||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(middleware2.Recovery())
|
r.Use(middleware2.Recovery())
|
||||||
|
|
||||||
return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService)
|
return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvideHTTPServer 提供 HTTP 服务器
|
// ProvideHTTPServer 提供 HTTP 服务器
|
||||||
|
|||||||
@@ -5,18 +5,19 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewApiKeyAuthMiddleware 创建 API Key 认证中间件
|
// NewApiKeyAuthMiddleware 创建 API Key 认证中间件
|
||||||
func NewApiKeyAuthMiddleware(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService) ApiKeyAuthMiddleware {
|
func NewApiKeyAuthMiddleware(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) ApiKeyAuthMiddleware {
|
||||||
return ApiKeyAuthMiddleware(apiKeyAuthWithSubscription(apiKeyService, subscriptionService))
|
return ApiKeyAuthMiddleware(apiKeyAuthWithSubscription(apiKeyService, subscriptionService, cfg))
|
||||||
}
|
}
|
||||||
|
|
||||||
// apiKeyAuthWithSubscription API Key认证中间件(支持订阅验证)
|
// apiKeyAuthWithSubscription API Key认证中间件(支持订阅验证)
|
||||||
func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService) gin.HandlerFunc {
|
func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// 尝试从Authorization header中提取API key (Bearer scheme)
|
// 尝试从Authorization header中提取API key (Bearer scheme)
|
||||||
authHeader := c.GetHeader("Authorization")
|
authHeader := c.GetHeader("Authorization")
|
||||||
@@ -85,6 +86,18 @@ func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscripti
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.RunMode == config.RunModeSimple {
|
||||||
|
// 简易模式:跳过余额和订阅检查,但仍需设置必要的上下文
|
||||||
|
c.Set(string(ContextKeyApiKey), apiKey)
|
||||||
|
c.Set(string(ContextKeyUser), AuthSubject{
|
||||||
|
UserID: apiKey.User.ID,
|
||||||
|
Concurrency: apiKey.User.Concurrency,
|
||||||
|
})
|
||||||
|
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 判断计费方式:订阅模式 vs 余额模式
|
// 判断计费方式:订阅模式 vs 余额模式
|
||||||
isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
|
isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
@@ -11,15 +12,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ApiKeyAuthGoogle is a Google-style error wrapper for API key auth.
|
// ApiKeyAuthGoogle is a Google-style error wrapper for API key auth.
|
||||||
func ApiKeyAuthGoogle(apiKeyService *service.ApiKeyService) gin.HandlerFunc {
|
func ApiKeyAuthGoogle(apiKeyService *service.ApiKeyService, cfg *config.Config) gin.HandlerFunc {
|
||||||
return ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil)
|
return ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApiKeyAuthWithSubscriptionGoogle behaves like ApiKeyAuthWithSubscription but returns Google-style errors:
|
// ApiKeyAuthWithSubscriptionGoogle behaves like ApiKeyAuthWithSubscription but returns Google-style errors:
|
||||||
// {"error":{"code":401,"message":"...","status":"UNAUTHENTICATED"}}
|
// {"error":{"code":401,"message":"...","status":"UNAUTHENTICATED"}}
|
||||||
//
|
//
|
||||||
// It is intended for Gemini native endpoints (/v1beta) to match Gemini SDK expectations.
|
// It is intended for Gemini native endpoints (/v1beta) to match Gemini SDK expectations.
|
||||||
func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService) gin.HandlerFunc {
|
func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
apiKeyString := extractAPIKeyFromRequest(c)
|
apiKeyString := extractAPIKeyFromRequest(c)
|
||||||
if apiKeyString == "" {
|
if apiKeyString == "" {
|
||||||
@@ -50,6 +51,18 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 简易模式:跳过余额和订阅检查
|
||||||
|
if cfg.RunMode == config.RunModeSimple {
|
||||||
|
c.Set(string(ContextKeyApiKey), apiKey)
|
||||||
|
c.Set(string(ContextKeyUser), AuthSubject{
|
||||||
|
UserID: apiKey.User.ID,
|
||||||
|
Concurrency: apiKey.User.Concurrency,
|
||||||
|
})
|
||||||
|
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
|
isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
|
||||||
if isSubscriptionType && subscriptionService != nil {
|
if isSubscriptionType && subscriptionService != nil {
|
||||||
subscription, err := subscriptionService.GetActiveSubscription(
|
subscription, err := subscriptionService.GetActiveSubscription(
|
||||||
|
|||||||
286
backend/internal/server/middleware/api_key_auth_test.go
Normal file
286
backend/internal/server/middleware/api_key_auth_test.go
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
//go:build unit
|
||||||
|
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSimpleModeBypassesQuotaCheck(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
limit := 1.0
|
||||||
|
group := &service.Group{
|
||||||
|
ID: 42,
|
||||||
|
Name: "sub",
|
||||||
|
Status: service.StatusActive,
|
||||||
|
SubscriptionType: service.SubscriptionTypeSubscription,
|
||||||
|
DailyLimitUSD: &limit,
|
||||||
|
}
|
||||||
|
user := &service.User{
|
||||||
|
ID: 7,
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
Balance: 10,
|
||||||
|
Concurrency: 3,
|
||||||
|
}
|
||||||
|
apiKey := &service.ApiKey{
|
||||||
|
ID: 100,
|
||||||
|
UserID: user.ID,
|
||||||
|
Key: "test-key",
|
||||||
|
Status: service.StatusActive,
|
||||||
|
User: user,
|
||||||
|
Group: group,
|
||||||
|
}
|
||||||
|
apiKey.GroupID = &group.ID
|
||||||
|
|
||||||
|
apiKeyRepo := &stubApiKeyRepo{
|
||||||
|
getByKey: func(ctx context.Context, key string) (*service.ApiKey, error) {
|
||||||
|
if key != apiKey.Key {
|
||||||
|
return nil, service.ErrApiKeyNotFound
|
||||||
|
}
|
||||||
|
clone := *apiKey
|
||||||
|
return &clone, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("simple_mode_bypasses_quota_check", func(t *testing.T) {
|
||||||
|
cfg := &config.Config{RunMode: config.RunModeSimple}
|
||||||
|
apiKeyService := service.NewApiKeyService(apiKeyRepo, nil, nil, nil, nil, cfg)
|
||||||
|
subscriptionService := service.NewSubscriptionService(nil, &stubUserSubscriptionRepo{}, nil)
|
||||||
|
router := newAuthTestRouter(apiKeyService, subscriptionService, cfg)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/t", nil)
|
||||||
|
req.Header.Set("x-api-key", apiKey.Key)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("standard_mode_enforces_quota_check", func(t *testing.T) {
|
||||||
|
cfg := &config.Config{RunMode: config.RunModeStandard}
|
||||||
|
apiKeyService := service.NewApiKeyService(apiKeyRepo, nil, nil, nil, nil, cfg)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
sub := &service.UserSubscription{
|
||||||
|
ID: 55,
|
||||||
|
UserID: user.ID,
|
||||||
|
GroupID: group.ID,
|
||||||
|
Status: service.SubscriptionStatusActive,
|
||||||
|
ExpiresAt: now.Add(24 * time.Hour),
|
||||||
|
DailyWindowStart: &now,
|
||||||
|
DailyUsageUSD: 10,
|
||||||
|
}
|
||||||
|
subscriptionRepo := &stubUserSubscriptionRepo{
|
||||||
|
getActive: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
|
||||||
|
if userID != sub.UserID || groupID != sub.GroupID {
|
||||||
|
return nil, service.ErrSubscriptionNotFound
|
||||||
|
}
|
||||||
|
clone := *sub
|
||||||
|
return &clone, nil
|
||||||
|
},
|
||||||
|
updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil },
|
||||||
|
activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil },
|
||||||
|
resetDaily: func(ctx context.Context, id int64, start time.Time) error { return nil },
|
||||||
|
resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil },
|
||||||
|
resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil },
|
||||||
|
}
|
||||||
|
subscriptionService := service.NewSubscriptionService(nil, subscriptionRepo, nil)
|
||||||
|
router := newAuthTestRouter(apiKeyService, subscriptionService, cfg)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/t", nil)
|
||||||
|
req.Header.Set("x-api-key", apiKey.Key)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||||
|
require.Contains(t, w.Body.String(), "USAGE_LIMIT_EXCEEDED")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthTestRouter(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) *gin.Engine {
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(gin.HandlerFunc(NewApiKeyAuthMiddleware(apiKeyService, subscriptionService, cfg)))
|
||||||
|
router.GET("/t", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
})
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubApiKeyRepo struct {
|
||||||
|
getByKey func(ctx context.Context, key string) (*service.ApiKey, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubApiKeyRepo) Create(ctx context.Context, key *service.ApiKey) error {
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubApiKeyRepo) GetByID(ctx context.Context, id int64) (*service.ApiKey, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubApiKeyRepo) GetByKey(ctx context.Context, key string) (*service.ApiKey, error) {
|
||||||
|
if r.getByKey != nil {
|
||||||
|
return r.getByKey(ctx, key)
|
||||||
|
}
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubApiKeyRepo) Update(ctx context.Context, key *service.ApiKey) error {
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubApiKeyRepo) Delete(ctx context.Context, id int64) error {
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) {
|
||||||
|
return nil, nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubApiKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) {
|
||||||
|
return 0, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubApiKeyRepo) ExistsByKey(ctx context.Context, key string) (bool, error) {
|
||||||
|
return false, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubApiKeyRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) {
|
||||||
|
return nil, nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubApiKeyRepo) SearchApiKeys(ctx context.Context, userID int64, keyword string, limit int) ([]service.ApiKey, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubApiKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) {
|
||||||
|
return 0, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) {
|
||||||
|
return 0, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubUserSubscriptionRepo struct {
|
||||||
|
getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
|
||||||
|
updateStatus func(ctx context.Context, subscriptionID int64, status string) error
|
||||||
|
activateWindow func(ctx context.Context, id int64, start time.Time) error
|
||||||
|
resetDaily func(ctx context.Context, id int64, start time.Time) error
|
||||||
|
resetWeekly func(ctx context.Context, id int64, start time.Time) error
|
||||||
|
resetMonthly func(ctx context.Context, id int64, start time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) Create(ctx context.Context, sub *service.UserSubscription) error {
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) GetByID(ctx context.Context, id int64) (*service.UserSubscription, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) GetByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) GetActiveByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
|
||||||
|
if r.getActive != nil {
|
||||||
|
return r.getActive(ctx, userID, groupID)
|
||||||
|
}
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) Update(ctx context.Context, sub *service.UserSubscription) error {
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) Delete(ctx context.Context, id int64) error {
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) ListByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) ListActiveByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.UserSubscription, *pagination.PaginationResult, error) {
|
||||||
|
return nil, nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status string) ([]service.UserSubscription, *pagination.PaginationResult, error) {
|
||||||
|
return nil, nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) {
|
||||||
|
return false, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) ExtendExpiry(ctx context.Context, subscriptionID int64, newExpiresAt time.Time) error {
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) UpdateStatus(ctx context.Context, subscriptionID int64, status string) error {
|
||||||
|
if r.updateStatus != nil {
|
||||||
|
return r.updateStatus(ctx, subscriptionID, status)
|
||||||
|
}
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error {
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
|
||||||
|
if r.activateWindow != nil {
|
||||||
|
return r.activateWindow(ctx, id, start)
|
||||||
|
}
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) ResetDailyUsage(ctx context.Context, id int64, newWindowStart time.Time) error {
|
||||||
|
if r.resetDaily != nil {
|
||||||
|
return r.resetDaily(ctx, id, newWindowStart)
|
||||||
|
}
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) ResetWeeklyUsage(ctx context.Context, id int64, newWindowStart time.Time) error {
|
||||||
|
if r.resetWeekly != nil {
|
||||||
|
return r.resetWeekly(ctx, id, newWindowStart)
|
||||||
|
}
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) ResetMonthlyUsage(ctx context.Context, id int64, newWindowStart time.Time) error {
|
||||||
|
if r.resetMonthly != nil {
|
||||||
|
return r.resetMonthly(ctx, id, newWindowStart)
|
||||||
|
}
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) IncrementUsage(ctx context.Context, id int64, costUSD float64) error {
|
||||||
|
return errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUserSubscriptionRepo) BatchUpdateExpiredStatus(ctx context.Context) (int64, error) {
|
||||||
|
return 0, errors.New("not implemented")
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/server/routes"
|
"github.com/Wei-Shaw/sub2api/internal/server/routes"
|
||||||
@@ -19,6 +20,7 @@ func SetupRouter(
|
|||||||
apiKeyAuth middleware2.ApiKeyAuthMiddleware,
|
apiKeyAuth middleware2.ApiKeyAuthMiddleware,
|
||||||
apiKeyService *service.ApiKeyService,
|
apiKeyService *service.ApiKeyService,
|
||||||
subscriptionService *service.SubscriptionService,
|
subscriptionService *service.SubscriptionService,
|
||||||
|
cfg *config.Config,
|
||||||
) *gin.Engine {
|
) *gin.Engine {
|
||||||
// 应用中间件
|
// 应用中间件
|
||||||
r.Use(middleware2.Logger())
|
r.Use(middleware2.Logger())
|
||||||
@@ -30,7 +32,7 @@ func SetupRouter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 注册路由
|
// 注册路由
|
||||||
registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService)
|
registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, cfg)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,7 @@ func registerRoutes(
|
|||||||
apiKeyAuth middleware2.ApiKeyAuthMiddleware,
|
apiKeyAuth middleware2.ApiKeyAuthMiddleware,
|
||||||
apiKeyService *service.ApiKeyService,
|
apiKeyService *service.ApiKeyService,
|
||||||
subscriptionService *service.SubscriptionService,
|
subscriptionService *service.SubscriptionService,
|
||||||
|
cfg *config.Config,
|
||||||
) {
|
) {
|
||||||
// 通用路由(健康检查、状态等)
|
// 通用路由(健康检查、状态等)
|
||||||
routes.RegisterCommonRoutes(r)
|
routes.RegisterCommonRoutes(r)
|
||||||
@@ -55,5 +58,5 @@ func registerRoutes(
|
|||||||
routes.RegisterAuthRoutes(v1, h, jwtAuth)
|
routes.RegisterAuthRoutes(v1, h, jwtAuth)
|
||||||
routes.RegisterUserRoutes(v1, h, jwtAuth)
|
routes.RegisterUserRoutes(v1, h, jwtAuth)
|
||||||
routes.RegisterAdminRoutes(v1, h, adminAuth)
|
routes.RegisterAdminRoutes(v1, h, adminAuth)
|
||||||
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService)
|
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, cfg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package routes
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
@@ -15,6 +16,7 @@ func RegisterGatewayRoutes(
|
|||||||
apiKeyAuth middleware.ApiKeyAuthMiddleware,
|
apiKeyAuth middleware.ApiKeyAuthMiddleware,
|
||||||
apiKeyService *service.ApiKeyService,
|
apiKeyService *service.ApiKeyService,
|
||||||
subscriptionService *service.SubscriptionService,
|
subscriptionService *service.SubscriptionService,
|
||||||
|
cfg *config.Config,
|
||||||
) {
|
) {
|
||||||
// API网关(Claude API兼容)
|
// API网关(Claude API兼容)
|
||||||
gateway := r.Group("/v1")
|
gateway := r.Group("/v1")
|
||||||
@@ -30,7 +32,7 @@ func RegisterGatewayRoutes(
|
|||||||
|
|
||||||
// Gemini 原生 API 兼容层(Gemini SDK/CLI 直连)
|
// Gemini 原生 API 兼容层(Gemini SDK/CLI 直连)
|
||||||
gemini := r.Group("/v1beta")
|
gemini := r.Group("/v1beta")
|
||||||
gemini.Use(middleware.ApiKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService))
|
gemini.Use(middleware.ApiKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg))
|
||||||
{
|
{
|
||||||
gemini.GET("/models", h.Gateway.GeminiV1BetaListModels)
|
gemini.GET("/models", h.Gateway.GeminiV1BetaListModels)
|
||||||
gemini.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel)
|
gemini.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel)
|
||||||
@@ -54,7 +56,7 @@ func RegisterGatewayRoutes(
|
|||||||
|
|
||||||
antigravityV1Beta := r.Group("/antigravity/v1beta")
|
antigravityV1Beta := r.Group("/antigravity/v1beta")
|
||||||
antigravityV1Beta.Use(middleware.ForcePlatform(service.PlatformAntigravity))
|
antigravityV1Beta.Use(middleware.ForcePlatform(service.PlatformAntigravity))
|
||||||
antigravityV1Beta.Use(middleware.ApiKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService))
|
antigravityV1Beta.Use(middleware.ApiKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg))
|
||||||
{
|
{
|
||||||
antigravityV1Beta.GET("/models", h.Gateway.GeminiV1BetaListModels)
|
antigravityV1Beta.GET("/models", h.Gateway.GeminiV1BetaListModels)
|
||||||
antigravityV1Beta.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel)
|
antigravityV1Beta.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel)
|
||||||
|
|||||||
@@ -54,15 +54,23 @@ type UsageLogRepository interface {
|
|||||||
GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// usageCache 用于缓存usage数据
|
// apiUsageCache 缓存从 Anthropic API 获取的使用率数据(utilization, resets_at)
|
||||||
type usageCache struct {
|
type apiUsageCache struct {
|
||||||
data *UsageInfo
|
response *ClaudeUsageResponse
|
||||||
|
timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// windowStatsCache 缓存从本地数据库查询的窗口统计(requests, tokens, cost)
|
||||||
|
type windowStatsCache struct {
|
||||||
|
stats *WindowStats
|
||||||
timestamp time.Time
|
timestamp time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
usageCacheMap = sync.Map{}
|
apiCacheMap = sync.Map{} // 缓存 API 响应
|
||||||
cacheTTL = 10 * time.Minute
|
windowStatsCacheMap = sync.Map{} // 缓存窗口统计
|
||||||
|
apiCacheTTL = 10 * time.Minute
|
||||||
|
windowStatsCacheTTL = 1 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// WindowStats 窗口期统计
|
// WindowStats 窗口期统计
|
||||||
@@ -126,7 +134,7 @@ func NewAccountUsageService(accountRepo AccountRepository, usageLogRepo UsageLog
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetUsage 获取账号使用量
|
// GetUsage 获取账号使用量
|
||||||
// OAuth账号: 调用Anthropic API获取真实数据(需要profile scope),缓存10分钟
|
// OAuth账号: 调用Anthropic API获取真实数据(需要profile scope),API响应缓存10分钟,窗口统计缓存1分钟
|
||||||
// Setup Token账号: 根据session_window推算5h窗口,7d数据不可用(没有profile scope)
|
// Setup Token账号: 根据session_window推算5h窗口,7d数据不可用(没有profile scope)
|
||||||
// API Key账号: 不支持usage查询
|
// API Key账号: 不支持usage查询
|
||||||
func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*UsageInfo, error) {
|
func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*UsageInfo, error) {
|
||||||
@@ -137,30 +145,34 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
|
|||||||
|
|
||||||
// 只有oauth类型账号可以通过API获取usage(有profile scope)
|
// 只有oauth类型账号可以通过API获取usage(有profile scope)
|
||||||
if account.CanGetUsage() {
|
if account.CanGetUsage() {
|
||||||
// 检查缓存
|
var apiResp *ClaudeUsageResponse
|
||||||
if cached, ok := usageCacheMap.Load(accountID); ok {
|
|
||||||
cache, ok := cached.(*usageCache)
|
// 1. 检查 API 缓存(10 分钟)
|
||||||
if !ok {
|
if cached, ok := apiCacheMap.Load(accountID); ok {
|
||||||
usageCacheMap.Delete(accountID)
|
if cache, ok := cached.(*apiUsageCache); ok && time.Since(cache.timestamp) < apiCacheTTL {
|
||||||
} else if time.Since(cache.timestamp) < cacheTTL {
|
apiResp = cache.response
|
||||||
return cache.data, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从API获取数据
|
// 2. 如果没有缓存,从 API 获取
|
||||||
usage, err := s.fetchOAuthUsage(ctx, account)
|
if apiResp == nil {
|
||||||
if err != nil {
|
apiResp, err = s.fetchOAuthUsageRaw(ctx, account)
|
||||||
return nil, err
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 缓存 API 响应
|
||||||
|
apiCacheMap.Store(accountID, &apiUsageCache{
|
||||||
|
response: apiResp,
|
||||||
|
timestamp: time.Now(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加5h窗口统计数据
|
// 3. 构建 UsageInfo(每次都重新计算 RemainingSeconds)
|
||||||
s.addWindowStats(ctx, account, usage)
|
now := time.Now()
|
||||||
|
usage := s.buildUsageInfo(apiResp, &now)
|
||||||
|
|
||||||
// 缓存结果
|
// 4. 添加窗口统计(有独立缓存,1 分钟)
|
||||||
usageCacheMap.Store(accountID, &usageCache{
|
s.addWindowStats(ctx, account, usage)
|
||||||
data: usage,
|
|
||||||
timestamp: time.Now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
return usage, nil
|
return usage, nil
|
||||||
}
|
}
|
||||||
@@ -177,31 +189,54 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
|
|||||||
return nil, fmt.Errorf("account type %s does not support usage query", account.Type)
|
return nil, fmt.Errorf("account type %s does not support usage query", account.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
// addWindowStats 为usage数据添加窗口期统计
|
// addWindowStats 为 usage 数据添加窗口期统计
|
||||||
|
// 使用独立缓存(1 分钟),与 API 缓存分离
|
||||||
func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Account, usage *UsageInfo) {
|
func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Account, usage *UsageInfo) {
|
||||||
if usage.FiveHour == nil {
|
// 修复:即使 FiveHour 为 nil,也要尝试获取统计数据
|
||||||
|
// 因为 SevenDay/SevenDaySonnet 可能需要
|
||||||
|
if usage.FiveHour == nil && usage.SevenDay == nil && usage.SevenDaySonnet == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用session_window_start作为统计起始时间
|
// 检查窗口统计缓存(1 分钟)
|
||||||
var startTime time.Time
|
var windowStats *WindowStats
|
||||||
if account.SessionWindowStart != nil {
|
if cached, ok := windowStatsCacheMap.Load(account.ID); ok {
|
||||||
startTime = *account.SessionWindowStart
|
if cache, ok := cached.(*windowStatsCache); ok && time.Since(cache.timestamp) < windowStatsCacheTTL {
|
||||||
} else {
|
windowStats = cache.stats
|
||||||
// 如果没有窗口信息,使用5小时前作为默认
|
}
|
||||||
startTime = time.Now().Add(-5 * time.Hour)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, startTime)
|
// 如果没有缓存,从数据库查询
|
||||||
if err != nil {
|
if windowStats == nil {
|
||||||
log.Printf("Failed to get window stats for account %d: %v", account.ID, err)
|
var startTime time.Time
|
||||||
return
|
if account.SessionWindowStart != nil {
|
||||||
|
startTime = *account.SessionWindowStart
|
||||||
|
} else {
|
||||||
|
startTime = time.Now().Add(-5 * time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, startTime)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to get window stats for account %d: %v", account.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
windowStats = &WindowStats{
|
||||||
|
Requests: stats.Requests,
|
||||||
|
Tokens: stats.Tokens,
|
||||||
|
Cost: stats.Cost,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存窗口统计(1 分钟)
|
||||||
|
windowStatsCacheMap.Store(account.ID, &windowStatsCache{
|
||||||
|
stats: windowStats,
|
||||||
|
timestamp: time.Now(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
usage.FiveHour.WindowStats = &WindowStats{
|
// 为 FiveHour 添加 WindowStats(5h 窗口统计)
|
||||||
Requests: stats.Requests,
|
if usage.FiveHour != nil {
|
||||||
Tokens: stats.Tokens,
|
usage.FiveHour.WindowStats = windowStats
|
||||||
Cost: stats.Cost,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,8 +262,8 @@ func (s *AccountUsageService) GetAccountUsageStats(ctx context.Context, accountI
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchOAuthUsage 从Anthropic API获取OAuth账号的使用量
|
// fetchOAuthUsageRaw 从 Anthropic API 获取原始响应(不构建 UsageInfo)
|
||||||
func (s *AccountUsageService) fetchOAuthUsage(ctx context.Context, account *Account) (*UsageInfo, error) {
|
func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *Account) (*ClaudeUsageResponse, error) {
|
||||||
accessToken := account.GetCredential("access_token")
|
accessToken := account.GetCredential("access_token")
|
||||||
if accessToken == "" {
|
if accessToken == "" {
|
||||||
return nil, fmt.Errorf("no access token available")
|
return nil, fmt.Errorf("no access token available")
|
||||||
@@ -239,13 +274,7 @@ func (s *AccountUsageService) fetchOAuthUsage(ctx context.Context, account *Acco
|
|||||||
proxyURL = account.Proxy.URL()
|
proxyURL = account.Proxy.URL()
|
||||||
}
|
}
|
||||||
|
|
||||||
usageResp, err := s.usageFetcher.FetchUsage(ctx, accessToken, proxyURL)
|
return s.usageFetcher.FetchUsage(ctx, accessToken, proxyURL)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
return s.buildUsageInfo(usageResp, &now), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseTime 尝试多种格式解析时间
|
// parseTime 尝试多种格式解析时间
|
||||||
@@ -270,20 +299,16 @@ func (s *AccountUsageService) buildUsageInfo(resp *ClaudeUsageResponse, updatedA
|
|||||||
UpdatedAt: updatedAt,
|
UpdatedAt: updatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5小时窗口
|
// 5小时窗口 - 始终创建对象(即使 ResetsAt 为空)
|
||||||
|
info.FiveHour = &UsageProgress{
|
||||||
|
Utilization: resp.FiveHour.Utilization,
|
||||||
|
}
|
||||||
if resp.FiveHour.ResetsAt != "" {
|
if resp.FiveHour.ResetsAt != "" {
|
||||||
if fiveHourReset, err := parseTime(resp.FiveHour.ResetsAt); err == nil {
|
if fiveHourReset, err := parseTime(resp.FiveHour.ResetsAt); err == nil {
|
||||||
info.FiveHour = &UsageProgress{
|
info.FiveHour.ResetsAt = &fiveHourReset
|
||||||
Utilization: resp.FiveHour.Utilization,
|
info.FiveHour.RemainingSeconds = int(time.Until(fiveHourReset).Seconds())
|
||||||
ResetsAt: &fiveHourReset,
|
|
||||||
RemainingSeconds: int(time.Until(fiveHourReset).Seconds()),
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Failed to parse FiveHour.ResetsAt: %s, error: %v", resp.FiveHour.ResetsAt, err)
|
log.Printf("Failed to parse FiveHour.ResetsAt: %s, error: %v", resp.FiveHour.ResetsAt, err)
|
||||||
// 即使解析失败也返回utilization
|
|
||||||
info.FiveHour = &UsageProgress{
|
|
||||||
Utilization: resp.FiveHour.Utilization,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -609,12 +609,30 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
|
|||||||
if err := s.accountRepo.Create(ctx, account); err != nil {
|
if err := s.accountRepo.Create(ctx, account); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 绑定分组
|
// 绑定分组
|
||||||
if len(input.GroupIDs) > 0 {
|
groupIDs := input.GroupIDs
|
||||||
if err := s.accountRepo.BindGroups(ctx, account.ID, input.GroupIDs); err != nil {
|
// 如果没有指定分组,自动绑定对应平台的默认分组
|
||||||
|
if len(groupIDs) == 0 {
|
||||||
|
defaultGroupName := input.Platform + "-default"
|
||||||
|
groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform)
|
||||||
|
if err == nil {
|
||||||
|
for _, g := range groups {
|
||||||
|
if g.Name == defaultGroupName {
|
||||||
|
groupIDs = []int64{g.ID}
|
||||||
|
log.Printf("[CreateAccount] Auto-binding account %d to default group %s (ID: %d)", account.ID, defaultGroupName, g.ID)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(groupIDs) > 0 {
|
||||||
|
if err := s.accountRepo.BindGroups(ctx, account.ID, groupIDs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return account, nil
|
return account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/infrastructure/errors"
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/infrastructure/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,14 +33,16 @@ type BillingCacheService struct {
|
|||||||
cache BillingCache
|
cache BillingCache
|
||||||
userRepo UserRepository
|
userRepo UserRepository
|
||||||
subRepo UserSubscriptionRepository
|
subRepo UserSubscriptionRepository
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBillingCacheService 创建计费缓存服务
|
// NewBillingCacheService 创建计费缓存服务
|
||||||
func NewBillingCacheService(cache BillingCache, userRepo UserRepository, subRepo UserSubscriptionRepository) *BillingCacheService {
|
func NewBillingCacheService(cache BillingCache, userRepo UserRepository, subRepo UserSubscriptionRepository, cfg *config.Config) *BillingCacheService {
|
||||||
return &BillingCacheService{
|
return &BillingCacheService{
|
||||||
cache: cache,
|
cache: cache,
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
subRepo: subRepo,
|
subRepo: subRepo,
|
||||||
|
cfg: cfg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +227,11 @@ func (s *BillingCacheService) InvalidateSubscription(ctx context.Context, userID
|
|||||||
// 余额模式:检查缓存余额 > 0
|
// 余额模式:检查缓存余额 > 0
|
||||||
// 订阅模式:检查缓存用量未超过限额(Group限额从参数传入)
|
// 订阅模式:检查缓存用量未超过限额(Group限额从参数传入)
|
||||||
func (s *BillingCacheService) CheckBillingEligibility(ctx context.Context, user *User, apiKey *ApiKey, group *Group, subscription *UserSubscription) error {
|
func (s *BillingCacheService) CheckBillingEligibility(ctx context.Context, user *User, apiKey *ApiKey, group *Group, subscription *UserSubscription) error {
|
||||||
|
// 简易模式:跳过所有计费检查
|
||||||
|
if s.cfg.RunMode == config.RunModeSimple {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 判断计费模式
|
// 判断计费模式
|
||||||
isSubscriptionMode := group != nil && group.IsSubscriptionType() && subscription != nil
|
isSubscriptionMode := group != nil && group.IsSubscriptionType() && subscription != nil
|
||||||
|
|
||||||
|
|||||||
@@ -357,7 +357,10 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
|
|||||||
// 2. 获取可调度账号列表(单平台)
|
// 2. 获取可调度账号列表(单平台)
|
||||||
var accounts []Account
|
var accounts []Account
|
||||||
var err error
|
var err error
|
||||||
if groupID != nil {
|
if s.cfg.RunMode == config.RunModeSimple {
|
||||||
|
// 简易模式:忽略 groupID,查询所有可用账号
|
||||||
|
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
||||||
|
} else if groupID != nil {
|
||||||
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, platform)
|
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, platform)
|
||||||
} else {
|
} else {
|
||||||
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
||||||
@@ -1226,6 +1229,12 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
|||||||
log.Printf("Create usage log failed: %v", err)
|
log.Printf("Create usage log failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
|
||||||
|
log.Printf("[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
|
||||||
|
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 根据计费类型执行扣费
|
// 根据计费类型执行扣费
|
||||||
if isSubscriptionBilling {
|
if isSubscriptionBilling {
|
||||||
// 订阅模式:更新订阅用量(使用 TotalCost 原始费用,不考虑倍率)
|
// 订阅模式:更新订阅用量(使用 TotalCost 原始费用,不考虑倍率)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -155,7 +156,10 @@ func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.C
|
|||||||
// 2. Get schedulable OpenAI accounts
|
// 2. Get schedulable OpenAI accounts
|
||||||
var accounts []Account
|
var accounts []Account
|
||||||
var err error
|
var err error
|
||||||
if groupID != nil {
|
// 简易模式:忽略分组限制,查询所有可用账号
|
||||||
|
if s.cfg.RunMode == config.RunModeSimple {
|
||||||
|
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformOpenAI)
|
||||||
|
} else if groupID != nil {
|
||||||
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformOpenAI)
|
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformOpenAI)
|
||||||
} else {
|
} else {
|
||||||
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformOpenAI)
|
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformOpenAI)
|
||||||
@@ -754,6 +758,12 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
|||||||
|
|
||||||
_ = s.usageLogRepo.Create(ctx, usageLog)
|
_ = s.usageLogRepo.Create(ctx, usageLog)
|
||||||
|
|
||||||
|
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
|
||||||
|
log.Printf("[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
|
||||||
|
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Deduct based on billing type
|
// Deduct based on billing type
|
||||||
if isSubscriptionBilling {
|
if isSubscriptionBilling {
|
||||||
if cost.TotalCost > 0 {
|
if cost.TotalCost > 0 {
|
||||||
|
|||||||
@@ -164,6 +164,14 @@ func (s *UserService) UpdateBalance(ctx context.Context, userID int64, amount fl
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateConcurrency 更新用户并发数(管理员功能)
|
||||||
|
func (s *UserService) UpdateConcurrency(ctx context.Context, userID int64, concurrency int) error {
|
||||||
|
if err := s.userRepo.UpdateConcurrency(ctx, userID, concurrency); err != nil {
|
||||||
|
return fmt.Errorf("update concurrency: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateStatus 更新用户状态(管理员功能)
|
// UpdateStatus 更新用户状态(管理员功能)
|
||||||
func (s *UserService) UpdateStatus(ctx context.Context, userID int64, status string) error {
|
func (s *UserService) UpdateStatus(ctx context.Context, userID int64, status string) error {
|
||||||
user, err := s.userRepo.GetByID(ctx, userID)
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ SERVER_PORT=8080
|
|||||||
# Server mode: release or debug
|
# Server mode: release or debug
|
||||||
SERVER_MODE=release
|
SERVER_MODE=release
|
||||||
|
|
||||||
|
# 运行模式: standard (默认) 或 simple (内部自用)
|
||||||
|
# standard: 完整 SaaS 功能,包含计费/余额校验;simple: 隐藏 SaaS 功能并跳过计费/余额校验
|
||||||
|
RUN_MODE=standard
|
||||||
|
|
||||||
# Timezone
|
# Timezone
|
||||||
TZ=Asia/Shanghai
|
TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ server:
|
|||||||
# Mode: "debug" for development, "release" for production
|
# Mode: "debug" for development, "release" for production
|
||||||
mode: "release"
|
mode: "release"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Run Mode Configuration
|
||||||
|
# =============================================================================
|
||||||
|
# Run mode: "standard" (default) or "simple" (for internal use)
|
||||||
|
# - standard: Full SaaS features with billing/balance checks
|
||||||
|
# - simple: Hides SaaS features and skips billing/balance checks
|
||||||
|
run_mode: "standard"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Database Configuration (PostgreSQL)
|
# Database Configuration (PostgreSQL)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ services:
|
|||||||
- SERVER_HOST=0.0.0.0
|
- SERVER_HOST=0.0.0.0
|
||||||
- SERVER_PORT=8080
|
- SERVER_PORT=8080
|
||||||
- SERVER_MODE=${SERVER_MODE:-release}
|
- SERVER_MODE=${SERVER_MODE:-release}
|
||||||
|
- RUN_MODE=${RUN_MODE:-standard}
|
||||||
|
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
# Database Configuration (PostgreSQL)
|
# Database Configuration (PostgreSQL)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
LoginRequest,
|
LoginRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
User,
|
CurrentUserResponse,
|
||||||
SendVerifyCodeRequest,
|
SendVerifyCodeRequest,
|
||||||
SendVerifyCodeResponse,
|
SendVerifyCodeResponse,
|
||||||
PublicSettings
|
PublicSettings
|
||||||
@@ -70,9 +70,8 @@ export async function register(userData: RegisterRequest): Promise<AuthResponse>
|
|||||||
* Get current authenticated user
|
* Get current authenticated user
|
||||||
* @returns User profile data
|
* @returns User profile data
|
||||||
*/
|
*/
|
||||||
export async function getCurrentUser(): Promise<User> {
|
export async function getCurrentUser() {
|
||||||
const { data } = await apiClient.get<User>('/auth/me')
|
return apiClient.get<CurrentUserResponse>('/auth/me')
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -585,7 +585,7 @@
|
|||||||
: 'https://api.anthropic.com'
|
: 'https://api.anthropic.com'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
|
<p class="input-hint">{{ baseUrlHint }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
|
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
|
||||||
@@ -602,13 +602,7 @@
|
|||||||
: 'sk-ant-...'
|
: 'sk-ant-...'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<p class="input-hint">
|
<p class="input-hint">{{ apiKeyHint }}</p>
|
||||||
{{
|
|
||||||
form.platform === 'gemini'
|
|
||||||
? t('admin.accounts.gemini.apiKeyHint')
|
|
||||||
: t('admin.accounts.apiKeyHint')
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Model Restriction Section (不适用于 Gemini) -->
|
<!-- Model Restriction Section (不适用于 Gemini) -->
|
||||||
@@ -1055,8 +1049,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Group Selection -->
|
<!-- Group Selection - 仅标准模式显示 -->
|
||||||
<GroupSelector
|
<GroupSelector
|
||||||
|
v-if="!authStore.isSimpleMode"
|
||||||
v-model="form.group_ids"
|
v-model="form.group_ids"
|
||||||
:groups="groups"
|
:groups="groups"
|
||||||
:platform="form.platform"
|
:platform="form.platform"
|
||||||
@@ -1172,6 +1167,7 @@
|
|||||||
import { ref, reactive, computed, watch } from 'vue'
|
import { ref, reactive, computed, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import {
|
import {
|
||||||
useAccountOAuth,
|
useAccountOAuth,
|
||||||
@@ -1199,6 +1195,7 @@ interface OAuthFlowExposed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const oauthStepTitle = computed(() => {
|
const oauthStepTitle = computed(() => {
|
||||||
if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title')
|
if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title')
|
||||||
@@ -1207,6 +1204,19 @@ const oauthStepTitle = computed(() => {
|
|||||||
return t('admin.accounts.oauth.title')
|
return t('admin.accounts.oauth.title')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Platform-specific hints for API Key type
|
||||||
|
const baseUrlHint = computed(() => {
|
||||||
|
if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
|
||||||
|
if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
|
||||||
|
return t('admin.accounts.baseUrlHint')
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiKeyHint = computed(() => {
|
||||||
|
if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint')
|
||||||
|
if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint')
|
||||||
|
return t('admin.accounts.apiKeyHint')
|
||||||
|
})
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
show: boolean
|
show: boolean
|
||||||
proxies: Proxy[]
|
proxies: Proxy[]
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
: 'https://api.anthropic.com'
|
: 'https://api.anthropic.com'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
|
<p class="input-hint">{{ baseUrlHint }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
|
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
|
||||||
@@ -497,8 +497,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Group Selection -->
|
<!-- Group Selection - 仅标准模式显示 -->
|
||||||
<GroupSelector
|
<GroupSelector
|
||||||
|
v-if="!authStore.isSimpleMode"
|
||||||
v-model="form.group_ids"
|
v-model="form.group_ids"
|
||||||
:groups="groups"
|
:groups="groups"
|
||||||
:platform="account?.platform"
|
:platform="account?.platform"
|
||||||
@@ -549,6 +550,7 @@
|
|||||||
import { ref, reactive, computed, watch } from 'vue'
|
import { ref, reactive, computed, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Account, Proxy, Group } from '@/types'
|
import type { Account, Proxy, Group } from '@/types'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
@@ -571,6 +573,15 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
// Platform-specific hint for Base URL
|
||||||
|
const baseUrlHint = computed(() => {
|
||||||
|
if (!props.account) return t('admin.accounts.baseUrlHint')
|
||||||
|
if (props.account.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
|
||||||
|
if (props.account.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
|
||||||
|
return t('admin.accounts.baseUrlHint')
|
||||||
|
})
|
||||||
|
|
||||||
// Model mapping type
|
// Model mapping type
|
||||||
interface ModelMapping {
|
interface ModelMapping {
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.select-dropdown {
|
.select-dropdown {
|
||||||
@apply absolute z-[100] mt-2 w-full;
|
@apply absolute left-0 z-[100] mt-2 min-w-full w-max max-w-[300px];
|
||||||
@apply bg-white dark:bg-dark-800;
|
@apply bg-white dark:bg-dark-800;
|
||||||
@apply rounded-xl;
|
@apply rounded-xl;
|
||||||
@apply border border-gray-200 dark:border-dark-700;
|
@apply border border-gray-200 dark:border-dark-700;
|
||||||
@@ -339,7 +339,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.select-option-label {
|
.select-option-label {
|
||||||
@apply truncate;
|
@apply flex-1 min-w-0 truncate text-left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-empty {
|
.select-empty {
|
||||||
|
|||||||
@@ -45,8 +45,8 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Personal Section for Admin -->
|
<!-- Personal Section for Admin (hidden in simple mode) -->
|
||||||
<div class="sidebar-section">
|
<div v-if="!authStore.isSimpleMode" class="sidebar-section">
|
||||||
<div v-if="!sidebarCollapsed" class="sidebar-section-title">
|
<div v-if="!sidebarCollapsed" class="sidebar-section-title">
|
||||||
{{ t('nav.myAccount') }}
|
{{ t('nav.myAccount') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -402,36 +402,54 @@ const ChevronDoubleRightIcon = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// User navigation items (for regular users)
|
// User navigation items (for regular users)
|
||||||
const userNavItems = computed(() => [
|
const userNavItems = computed(() => {
|
||||||
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
const items = [
|
||||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
||||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon },
|
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon },
|
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon },
|
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||||
])
|
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||||
|
]
|
||||||
|
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
|
||||||
|
})
|
||||||
|
|
||||||
// Personal navigation items (for admin's "My Account" section, without Dashboard)
|
// Personal navigation items (for admin's "My Account" section, without Dashboard)
|
||||||
const personalNavItems = computed(() => [
|
const personalNavItems = computed(() => {
|
||||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
const items = [
|
||||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon },
|
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon },
|
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon },
|
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||||
])
|
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||||
|
]
|
||||||
|
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
|
||||||
|
})
|
||||||
|
|
||||||
// Admin navigation items
|
// Admin navigation items
|
||||||
const adminNavItems = computed(() => [
|
const adminNavItems = computed(() => {
|
||||||
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
const baseItems = [
|
||||||
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon },
|
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
||||||
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon },
|
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
|
||||||
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon },
|
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
|
||||||
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
|
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
|
||||||
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon },
|
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
||||||
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
|
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
|
||||||
{ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }
|
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
|
||||||
])
|
]
|
||||||
|
|
||||||
|
// 简单模式下,在系统设置前插入 API密钥
|
||||||
|
if (authStore.isSimpleMode) {
|
||||||
|
const filtered = baseItems.filter(item => !item.hideInSimpleMode)
|
||||||
|
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon })
|
||||||
|
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
baseItems.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
||||||
|
return baseItems
|
||||||
|
})
|
||||||
|
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
appStore.toggleSidebar()
|
appStore.toggleSidebar()
|
||||||
|
|||||||
@@ -676,14 +676,21 @@ export default {
|
|||||||
description: 'Description',
|
description: 'Description',
|
||||||
platform: 'Platform',
|
platform: 'Platform',
|
||||||
rateMultiplier: 'Rate Multiplier',
|
rateMultiplier: 'Rate Multiplier',
|
||||||
status: 'Status'
|
status: 'Status',
|
||||||
|
exclusive: 'Exclusive Group'
|
||||||
},
|
},
|
||||||
enterGroupName: 'Enter group name',
|
enterGroupName: 'Enter group name',
|
||||||
optionalDescription: 'Optional description',
|
optionalDescription: 'Optional description',
|
||||||
platformHint: 'Select the platform this group is associated with',
|
platformHint: 'Select the platform this group is associated with',
|
||||||
platformNotEditable: 'Platform cannot be changed after creation',
|
platformNotEditable: 'Platform cannot be changed after creation',
|
||||||
rateMultiplierHint: 'Cost multiplier for this group (e.g., 1.5 = 150% of base cost)',
|
rateMultiplierHint: 'Cost multiplier for this group (e.g., 1.5 = 150% of base cost)',
|
||||||
exclusiveHint: 'Exclusive (requires explicit user access)',
|
exclusiveHint: 'Exclusive group, manually assign to specific users',
|
||||||
|
exclusiveTooltip: {
|
||||||
|
title: 'What is an exclusive group?',
|
||||||
|
description: 'When enabled, users cannot see this group when creating API Keys. Only after an admin manually assigns a user to this group can they use it.',
|
||||||
|
example: 'Use case:',
|
||||||
|
exampleContent: 'Public group rate is 0.8. Create an exclusive group with 0.7 rate, manually assign VIP users to give them better pricing.'
|
||||||
|
},
|
||||||
noGroupsYet: 'No groups yet',
|
noGroupsYet: 'No groups yet',
|
||||||
createFirstGroup: 'Create your first group to organize API keys.',
|
createFirstGroup: 'Create your first group to organize API keys.',
|
||||||
creating: 'Creating...',
|
creating: 'Creating...',
|
||||||
@@ -910,6 +917,11 @@ export default {
|
|||||||
apiKeyRequired: 'API Key *',
|
apiKeyRequired: 'API Key *',
|
||||||
apiKeyPlaceholder: 'sk-ant-api03-...',
|
apiKeyPlaceholder: 'sk-ant-api03-...',
|
||||||
apiKeyHint: 'Your Claude Console API Key',
|
apiKeyHint: 'Your Claude Console API Key',
|
||||||
|
// OpenAI specific hints
|
||||||
|
openai: {
|
||||||
|
baseUrlHint: 'Leave default for official OpenAI API',
|
||||||
|
apiKeyHint: 'Your OpenAI API Key'
|
||||||
|
},
|
||||||
modelRestriction: 'Model Restriction (Optional)',
|
modelRestriction: 'Model Restriction (Optional)',
|
||||||
modelWhitelist: 'Model Whitelist',
|
modelWhitelist: 'Model Whitelist',
|
||||||
modelMapping: 'Model Mapping',
|
modelMapping: 'Model Mapping',
|
||||||
@@ -1096,6 +1108,7 @@ export default {
|
|||||||
modelPassthrough: 'Gemini Model Passthrough',
|
modelPassthrough: 'Gemini Model Passthrough',
|
||||||
modelPassthroughDesc:
|
modelPassthroughDesc:
|
||||||
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
|
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
|
||||||
|
baseUrlHint: 'Leave default for official Gemini API',
|
||||||
apiKeyHint: 'Your Gemini API Key (starts with AIza)'
|
apiKeyHint: 'Your Gemini API Key (starts with AIza)'
|
||||||
},
|
},
|
||||||
// Re-Auth Modal
|
// Re-Auth Modal
|
||||||
@@ -1215,9 +1228,9 @@ export default {
|
|||||||
batchAdd: 'Quick Add',
|
batchAdd: 'Quick Add',
|
||||||
batchInput: 'Proxy List',
|
batchInput: 'Proxy List',
|
||||||
batchInputPlaceholder:
|
batchInputPlaceholder:
|
||||||
"Enter one proxy per line in the following formats:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443",
|
"Enter one proxy per line in the following formats:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443",
|
||||||
batchInputHint:
|
batchInputHint:
|
||||||
"Supports http, https, socks5 protocols. Format: protocol://[user:pass@]host:port",
|
"Supports http, https, socks5 protocols. Format: protocol://[user:pass{'@'}]host:port",
|
||||||
parsedCount: '{count} valid',
|
parsedCount: '{count} valid',
|
||||||
invalidCount: '{count} invalid',
|
invalidCount: '{count} invalid',
|
||||||
duplicateCount: '{count} duplicate',
|
duplicateCount: '{count} duplicate',
|
||||||
|
|||||||
@@ -733,14 +733,15 @@ export default {
|
|||||||
platform: '平台',
|
platform: '平台',
|
||||||
rateMultiplier: '费率倍数',
|
rateMultiplier: '费率倍数',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
|
exclusive: '专属分组',
|
||||||
nameLabel: '分组名称',
|
nameLabel: '分组名称',
|
||||||
namePlaceholder: '请输入分组名称',
|
namePlaceholder: '请输入分组名称',
|
||||||
descriptionLabel: '描述',
|
descriptionLabel: '描述',
|
||||||
descriptionPlaceholder: '请输入描述(可选)',
|
descriptionPlaceholder: '请输入描述(可选)',
|
||||||
rateMultiplierLabel: '费率倍数',
|
rateMultiplierLabel: '费率倍数',
|
||||||
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
|
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
|
||||||
exclusiveLabel: '独占模式',
|
exclusiveLabel: '专属分组',
|
||||||
exclusiveHint: '启用后,此分组的用户将独占使用分配的账号',
|
exclusiveHint: '专属分组,可以手动指定给用户',
|
||||||
platformLabel: '平台限制',
|
platformLabel: '平台限制',
|
||||||
platformPlaceholder: '选择平台(留空则不限制)',
|
platformPlaceholder: '选择平台(留空则不限制)',
|
||||||
accountsLabel: '指定账号',
|
accountsLabel: '指定账号',
|
||||||
@@ -753,8 +754,14 @@ export default {
|
|||||||
yes: '是',
|
yes: '是',
|
||||||
no: '否'
|
no: '否'
|
||||||
},
|
},
|
||||||
exclusive: '独占',
|
exclusive: '专属',
|
||||||
exclusiveHint: '启用后,此分组的用户将独占使用分配的账号',
|
exclusiveHint: '专属分组,可以手动指定给特定用户',
|
||||||
|
exclusiveTooltip: {
|
||||||
|
title: '什么是专属分组?',
|
||||||
|
description: '开启后,用户在创建 API Key 时将无法看到此分组。只有管理员手动将用户分配到此分组后,用户才能使用。',
|
||||||
|
example: '使用场景:',
|
||||||
|
exampleContent: '公开分组费率 0.8,您可以创建一个费率 0.7 的专属分组,手动分配给 VIP 用户,让他们享受更优惠的价格。'
|
||||||
|
},
|
||||||
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
|
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
|
||||||
platforms: {
|
platforms: {
|
||||||
all: '全部平台',
|
all: '全部平台',
|
||||||
@@ -773,8 +780,8 @@ export default {
|
|||||||
allPlatforms: '全部平台',
|
allPlatforms: '全部平台',
|
||||||
allStatus: '全部状态',
|
allStatus: '全部状态',
|
||||||
allGroups: '全部分组',
|
allGroups: '全部分组',
|
||||||
exclusiveFilter: '独占',
|
exclusiveFilter: '专属',
|
||||||
nonExclusive: '非独占',
|
nonExclusive: '公开',
|
||||||
public: '公开',
|
public: '公开',
|
||||||
rateAndAccounts: '{rate}x 费率 · {count} 个账号',
|
rateAndAccounts: '{rate}x 费率 · {count} 个账号',
|
||||||
accountsCount: '{count} 个账号',
|
accountsCount: '{count} 个账号',
|
||||||
@@ -1058,6 +1065,11 @@ export default {
|
|||||||
apiKeyRequired: 'API Key *',
|
apiKeyRequired: 'API Key *',
|
||||||
apiKeyPlaceholder: 'sk-ant-api03-...',
|
apiKeyPlaceholder: 'sk-ant-api03-...',
|
||||||
apiKeyHint: '您的 Claude Console API Key',
|
apiKeyHint: '您的 Claude Console API Key',
|
||||||
|
// OpenAI specific hints
|
||||||
|
openai: {
|
||||||
|
baseUrlHint: '留空使用官方 OpenAI API',
|
||||||
|
apiKeyHint: '您的 OpenAI API Key'
|
||||||
|
},
|
||||||
modelRestriction: '模型限制(可选)',
|
modelRestriction: '模型限制(可选)',
|
||||||
modelWhitelist: '模型白名单',
|
modelWhitelist: '模型白名单',
|
||||||
modelMapping: '模型映射',
|
modelMapping: '模型映射',
|
||||||
@@ -1226,7 +1238,8 @@ export default {
|
|||||||
gemini: {
|
gemini: {
|
||||||
modelPassthrough: 'Gemini 直接转发模型',
|
modelPassthrough: 'Gemini 直接转发模型',
|
||||||
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。',
|
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。',
|
||||||
apiKeyHint: 'Your Gemini API Key(以 AIza 开头)'
|
baseUrlHint: '留空使用官方 Gemini API',
|
||||||
|
apiKeyHint: '您的 Gemini API Key(以 AIza 开头)'
|
||||||
},
|
},
|
||||||
// Re-Auth Modal
|
// Re-Auth Modal
|
||||||
reAuthorizeAccount: '重新授权账号',
|
reAuthorizeAccount: '重新授权账号',
|
||||||
@@ -1364,8 +1377,8 @@ export default {
|
|||||||
batchAdd: '快捷添加',
|
batchAdd: '快捷添加',
|
||||||
batchInput: '代理列表',
|
batchInput: '代理列表',
|
||||||
batchInputPlaceholder:
|
batchInputPlaceholder:
|
||||||
"每行输入一个代理,支持以下格式:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443",
|
"每行输入一个代理,支持以下格式:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443",
|
||||||
batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码@]主机:端口",
|
batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码{'@'}]主机:端口",
|
||||||
parsedCount: '有效 {count} 个',
|
parsedCount: '有效 {count} 个',
|
||||||
invalidCount: '无效 {count} 个',
|
invalidCount: '无效 {count} 个',
|
||||||
duplicateCount: '重复 {count} 个',
|
duplicateCount: '重复 {count} 个',
|
||||||
|
|||||||
@@ -341,6 +341,23 @@ router.beforeEach((to, _from, next) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 简易模式下限制访问某些页面
|
||||||
|
if (authStore.isSimpleMode) {
|
||||||
|
const restrictedPaths = [
|
||||||
|
'/admin/groups',
|
||||||
|
'/admin/subscriptions',
|
||||||
|
'/admin/redeem',
|
||||||
|
'/subscriptions',
|
||||||
|
'/redeem'
|
||||||
|
]
|
||||||
|
|
||||||
|
if (restrictedPaths.some((path) => to.path.startsWith(path))) {
|
||||||
|
// 简易模式下访问受限页面,重定向到仪表板
|
||||||
|
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// All checks passed, allow navigation
|
// All checks passed, allow navigation
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, readonly } from 'vue'
|
||||||
import { authAPI } from '@/api'
|
import { authAPI } from '@/api'
|
||||||
import type { User, LoginRequest, RegisterRequest } from '@/types'
|
import type { User, LoginRequest, RegisterRequest } from '@/types'
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
const user = ref<User | null>(null)
|
const user = ref<User | null>(null)
|
||||||
const token = ref<string | null>(null)
|
const token = ref<string | null>(null)
|
||||||
|
const runMode = ref<'standard' | 'simple'>('standard')
|
||||||
let refreshIntervalId: ReturnType<typeof setInterval> | null = null
|
let refreshIntervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
// ==================== Computed ====================
|
// ==================== Computed ====================
|
||||||
@@ -29,6 +30,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
return user.value?.role === 'admin'
|
return user.value?.role === 'admin'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isSimpleMode = computed(() => runMode.value === 'simple')
|
||||||
|
|
||||||
// ==================== Actions ====================
|
// ==================== Actions ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,16 +101,22 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
// Store token and user
|
// Store token and user
|
||||||
token.value = response.access_token
|
token.value = response.access_token
|
||||||
user.value = response.user
|
|
||||||
|
// Extract run_mode if present
|
||||||
|
if (response.user.run_mode) {
|
||||||
|
runMode.value = response.user.run_mode
|
||||||
|
}
|
||||||
|
const { run_mode, ...userData } = response.user
|
||||||
|
user.value = userData
|
||||||
|
|
||||||
// Persist to localStorage
|
// Persist to localStorage
|
||||||
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
|
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
|
||||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(response.user))
|
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData))
|
||||||
|
|
||||||
// Start auto-refresh interval
|
// Start auto-refresh interval
|
||||||
startAutoRefresh()
|
startAutoRefresh()
|
||||||
|
|
||||||
return response.user
|
return userData
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Clear any partial state on error
|
// Clear any partial state on error
|
||||||
clearAuth()
|
clearAuth()
|
||||||
@@ -127,16 +136,22 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
// Store token and user
|
// Store token and user
|
||||||
token.value = response.access_token
|
token.value = response.access_token
|
||||||
user.value = response.user
|
|
||||||
|
// Extract run_mode if present
|
||||||
|
if (response.user.run_mode) {
|
||||||
|
runMode.value = response.user.run_mode
|
||||||
|
}
|
||||||
|
const { run_mode, ...userDataWithoutRunMode } = response.user
|
||||||
|
user.value = userDataWithoutRunMode
|
||||||
|
|
||||||
// Persist to localStorage
|
// Persist to localStorage
|
||||||
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
|
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
|
||||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(response.user))
|
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userDataWithoutRunMode))
|
||||||
|
|
||||||
// Start auto-refresh interval
|
// Start auto-refresh interval
|
||||||
startAutoRefresh()
|
startAutoRefresh()
|
||||||
|
|
||||||
return response.user
|
return userDataWithoutRunMode
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Clear any partial state on error
|
// Clear any partial state on error
|
||||||
clearAuth()
|
clearAuth()
|
||||||
@@ -168,13 +183,17 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedUser = await authAPI.getCurrentUser()
|
const response = await authAPI.getCurrentUser()
|
||||||
user.value = updatedUser
|
if (response.data.run_mode) {
|
||||||
|
runMode.value = response.data.run_mode
|
||||||
|
}
|
||||||
|
const { run_mode, ...userData } = response.data
|
||||||
|
user.value = userData
|
||||||
|
|
||||||
// Update localStorage
|
// Update localStorage
|
||||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(updatedUser))
|
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData))
|
||||||
|
|
||||||
return updatedUser
|
return userData
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If refresh fails with 401, clear auth state
|
// If refresh fails with 401, clear auth state
|
||||||
if ((error as { status?: number }).status === 401) {
|
if ((error as { status?: number }).status === 401) {
|
||||||
@@ -204,10 +223,12 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
// State
|
// State
|
||||||
user,
|
user,
|
||||||
token,
|
token,
|
||||||
|
runMode: readonly(runMode),
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
isSimpleMode,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
login,
|
login,
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ export interface PublicSettings {
|
|||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
access_token: string
|
access_token: string
|
||||||
token_type: string
|
token_type: string
|
||||||
user: User
|
user: User & { run_mode?: 'standard' | 'simple' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentUserResponse extends User {
|
||||||
|
run_mode?: 'standard' | 'simple'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Subscription Types ====================
|
// ==================== Subscription Types ====================
|
||||||
|
|||||||
@@ -494,6 +494,7 @@
|
|||||||
import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Account, Proxy, Group } from '@/types'
|
import type { Account, Proxy, Group } from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
@@ -522,22 +523,34 @@ import { formatRelativeTime } from '@/utils/format'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
// Table columns
|
// Table columns
|
||||||
const columns = computed<Column[]>(() => [
|
const columns = computed<Column[]>(() => {
|
||||||
{ key: 'select', label: '', sortable: false },
|
const cols: Column[] = [
|
||||||
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
|
{ key: 'select', label: '', sortable: false },
|
||||||
{ key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false },
|
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
|
||||||
{ key: 'concurrency', label: t('admin.accounts.columns.concurrencyStatus'), sortable: false },
|
{ key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false },
|
||||||
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
|
{ key: 'concurrency', label: t('admin.accounts.columns.concurrencyStatus'), sortable: false },
|
||||||
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
|
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
|
||||||
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false },
|
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
|
||||||
{ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false },
|
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), 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 },
|
// 简易模式下不显示分组列
|
||||||
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
|
if (!authStore.isSimpleMode) {
|
||||||
])
|
cols.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
cols.push(
|
||||||
|
{ 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 },
|
||||||
|
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
return cols
|
||||||
|
})
|
||||||
|
|
||||||
// Filter options
|
// Filter options
|
||||||
const platformOptions = computed(() => [
|
const platformOptions = computed(() => [
|
||||||
|
|||||||
@@ -407,10 +407,20 @@ const trendData = ref<TrendDataPoint[]>([])
|
|||||||
const modelStats = ref<ModelStat[]>([])
|
const modelStats = ref<ModelStat[]>([])
|
||||||
const userTrend = ref<UserUsageTrendPoint[]>([])
|
const userTrend = ref<UserUsageTrendPoint[]>([])
|
||||||
|
|
||||||
|
// Helper function to format date in local timezone
|
||||||
|
const formatLocalDate = (date: Date): string => {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize date range immediately
|
||||||
|
const now = new Date()
|
||||||
|
const weekAgo = new Date(now)
|
||||||
|
weekAgo.setDate(weekAgo.getDate() - 6)
|
||||||
|
|
||||||
// Date range
|
// Date range
|
||||||
const granularity = ref<'day' | 'hour'>('day')
|
const granularity = ref<'day' | 'hour'>('day')
|
||||||
const startDate = ref('')
|
const startDate = ref(formatLocalDate(weekAgo))
|
||||||
const endDate = ref('')
|
const endDate = ref(formatLocalDate(now))
|
||||||
|
|
||||||
// Granularity options for Select component
|
// Granularity options for Select component
|
||||||
const granularityOptions = computed(() => [
|
const granularityOptions = computed(() => [
|
||||||
@@ -597,18 +607,6 @@ const onDateRangeChange = (range: {
|
|||||||
loadChartData()
|
loadChartData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize default date range
|
|
||||||
const initializeDateRange = () => {
|
|
||||||
const now = new Date()
|
|
||||||
const today = now.toISOString().split('T')[0]
|
|
||||||
const weekAgo = new Date(now)
|
|
||||||
weekAgo.setDate(weekAgo.getDate() - 6)
|
|
||||||
|
|
||||||
startDate.value = weekAgo.toISOString().split('T')[0]
|
|
||||||
endDate.value = today
|
|
||||||
granularity.value = 'day'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load data
|
// Load data
|
||||||
const loadDashboardStats = async () => {
|
const loadDashboardStats = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -649,7 +647,6 @@ const loadChartData = async () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadDashboardStats()
|
loadDashboardStats()
|
||||||
initializeDateRange()
|
|
||||||
loadChartData()
|
loadChartData()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -282,34 +282,66 @@
|
|||||||
/>
|
/>
|
||||||
<p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p>
|
<p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="createForm.subscription_type !== 'subscription'" class="flex items-center gap-3">
|
<div v-if="createForm.subscription_type !== 'subscription'">
|
||||||
<button
|
<div class="mb-1.5 flex items-center gap-1">
|
||||||
type="button"
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
@click="createForm.is_exclusive = !createForm.is_exclusive"
|
{{ t('admin.groups.form.exclusive') }}
|
||||||
:class="[
|
</label>
|
||||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
<!-- Help Tooltip -->
|
||||||
createForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
<div class="group relative inline-flex">
|
||||||
]"
|
<svg
|
||||||
>
|
class="h-3.5 w-3.5 cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
|
||||||
<span
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<!-- Tooltip Popover -->
|
||||||
|
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||||
|
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
|
||||||
|
<p class="mb-2 text-xs font-medium">{{ t('admin.groups.exclusiveTooltip.title') }}</p>
|
||||||
|
<p class="mb-2 text-xs leading-relaxed text-gray-300">
|
||||||
|
{{ t('admin.groups.exclusiveTooltip.description') }}
|
||||||
|
</p>
|
||||||
|
<div class="rounded bg-gray-800 p-2 dark:bg-gray-700">
|
||||||
|
<p class="text-xs leading-relaxed text-gray-300">
|
||||||
|
<span class="text-primary-400">💡 {{ t('admin.groups.exclusiveTooltip.example') }}</span>
|
||||||
|
{{ t('admin.groups.exclusiveTooltip.exampleContent') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Arrow -->
|
||||||
|
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="createForm.is_exclusive = !createForm.is_exclusive"
|
||||||
:class="[
|
:class="[
|
||||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||||
createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
|
createForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||||
]"
|
]"
|
||||||
/>
|
>
|
||||||
</button>
|
<span
|
||||||
<label class="text-sm text-gray-700 dark:text-gray-300">
|
:class="[
|
||||||
{{ t('admin.groups.exclusiveHint') }}
|
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||||
</label>
|
createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ createForm.is_exclusive ? t('admin.groups.exclusive') : t('admin.groups.public') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Subscription Configuration -->
|
<!-- Subscription Configuration -->
|
||||||
<div class="mt-4 border-t pt-4">
|
<div class="mt-4 border-t pt-4">
|
||||||
<h4 class="mb-4 text-sm font-medium text-gray-900 dark:text-white">
|
<div>
|
||||||
{{ t('admin.groups.subscription.title') }}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
|
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
|
||||||
<Select v-model="createForm.subscription_type" :options="subscriptionTypeOptions" />
|
<Select v-model="createForm.subscription_type" :options="subscriptionTypeOptions" />
|
||||||
<p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p>
|
<p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p>
|
||||||
@@ -432,25 +464,61 @@
|
|||||||
class="input"
|
class="input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="editForm.subscription_type !== 'subscription'" class="flex items-center gap-3">
|
<div v-if="editForm.subscription_type !== 'subscription'">
|
||||||
<button
|
<div class="mb-1.5 flex items-center gap-1">
|
||||||
type="button"
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
@click="editForm.is_exclusive = !editForm.is_exclusive"
|
{{ t('admin.groups.form.exclusive') }}
|
||||||
:class="[
|
</label>
|
||||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
<!-- Help Tooltip -->
|
||||||
editForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
<div class="group relative inline-flex">
|
||||||
]"
|
<svg
|
||||||
>
|
class="h-3.5 w-3.5 cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
|
||||||
<span
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<!-- Tooltip Popover -->
|
||||||
|
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||||
|
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
|
||||||
|
<p class="mb-2 text-xs font-medium">{{ t('admin.groups.exclusiveTooltip.title') }}</p>
|
||||||
|
<p class="mb-2 text-xs leading-relaxed text-gray-300">
|
||||||
|
{{ t('admin.groups.exclusiveTooltip.description') }}
|
||||||
|
</p>
|
||||||
|
<div class="rounded bg-gray-800 p-2 dark:bg-gray-700">
|
||||||
|
<p class="text-xs leading-relaxed text-gray-300">
|
||||||
|
<span class="text-primary-400">💡 {{ t('admin.groups.exclusiveTooltip.example') }}</span>
|
||||||
|
{{ t('admin.groups.exclusiveTooltip.exampleContent') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Arrow -->
|
||||||
|
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="editForm.is_exclusive = !editForm.is_exclusive"
|
||||||
:class="[
|
:class="[
|
||||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||||
editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
|
editForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||||
]"
|
]"
|
||||||
/>
|
>
|
||||||
</button>
|
<span
|
||||||
<label class="text-sm text-gray-700 dark:text-gray-300">
|
:class="[
|
||||||
{{ t('admin.groups.exclusiveHint') }}
|
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||||
</label>
|
editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ editForm.is_exclusive ? t('admin.groups.exclusive') : t('admin.groups.public') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.groups.form.status') }}</label>
|
<label class="input-label">{{ t('admin.groups.form.status') }}</label>
|
||||||
@@ -459,11 +527,7 @@
|
|||||||
|
|
||||||
<!-- Subscription Configuration -->
|
<!-- Subscription Configuration -->
|
||||||
<div class="mt-4 border-t pt-4">
|
<div class="mt-4 border-t pt-4">
|
||||||
<h4 class="mb-4 text-sm font-medium text-gray-900 dark:text-white">
|
<div>
|
||||||
{{ t('admin.groups.subscription.title') }}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
|
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
|
||||||
<Select
|
<Select
|
||||||
v-model="editForm.subscription_type"
|
v-model="editForm.subscription_type"
|
||||||
|
|||||||
@@ -736,9 +736,19 @@ const groupOptions = computed(() => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Helper function to format date in local timezone
|
||||||
|
const formatLocalDate = (date: Date): string => {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize date range immediately
|
||||||
|
const now = new Date()
|
||||||
|
const weekAgo = new Date(now)
|
||||||
|
weekAgo.setDate(weekAgo.getDate() - 6)
|
||||||
|
|
||||||
// Date range state
|
// Date range state
|
||||||
const startDate = ref('')
|
const startDate = ref(formatLocalDate(weekAgo))
|
||||||
const endDate = ref('')
|
const endDate = ref(formatLocalDate(now))
|
||||||
|
|
||||||
const filters = ref<AdminUsageQueryParams>({
|
const filters = ref<AdminUsageQueryParams>({
|
||||||
user_id: undefined,
|
user_id: undefined,
|
||||||
@@ -752,18 +762,9 @@ const filters = ref<AdminUsageQueryParams>({
|
|||||||
end_date: undefined
|
end_date: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize default date range (last 7 days)
|
// Initialize filters with date range
|
||||||
const initializeDateRange = () => {
|
filters.value.start_date = startDate.value
|
||||||
const now = new Date()
|
filters.value.end_date = endDate.value
|
||||||
const today = now.toISOString().split('T')[0]
|
|
||||||
const weekAgo = new Date(now)
|
|
||||||
weekAgo.setDate(weekAgo.getDate() - 6)
|
|
||||||
|
|
||||||
startDate.value = weekAgo.toISOString().split('T')[0]
|
|
||||||
endDate.value = today
|
|
||||||
filters.value.start_date = startDate.value
|
|
||||||
filters.value.end_date = endDate.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// User search with debounce
|
// User search with debounce
|
||||||
const debounceSearchUsers = () => {
|
const debounceSearchUsers = () => {
|
||||||
@@ -988,9 +989,12 @@ const loadModelOptions = async () => {
|
|||||||
const endDate = new Date()
|
const endDate = new Date()
|
||||||
const startDateRange = new Date(endDate)
|
const startDateRange = new Date(endDate)
|
||||||
startDateRange.setDate(startDateRange.getDate() - 29)
|
startDateRange.setDate(startDateRange.getDate() - 29)
|
||||||
|
// Use local timezone instead of UTC
|
||||||
|
const endDateStr = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, '0')}-${String(endDate.getDate()).padStart(2, '0')}`
|
||||||
|
const startDateStr = `${startDateRange.getFullYear()}-${String(startDateRange.getMonth() + 1).padStart(2, '0')}-${String(startDateRange.getDate()).padStart(2, '0')}`
|
||||||
const response = await adminAPI.dashboard.getModelStats({
|
const response = await adminAPI.dashboard.getModelStats({
|
||||||
start_date: startDateRange.toISOString().split('T')[0],
|
start_date: startDateStr,
|
||||||
end_date: endDate.toISOString().split('T')[0]
|
end_date: endDateStr
|
||||||
})
|
})
|
||||||
const uniqueModels = new Set<string>()
|
const uniqueModels = new Set<string>()
|
||||||
response.models?.forEach((stat) => {
|
response.models?.forEach((stat) => {
|
||||||
@@ -1022,7 +1026,13 @@ const resetFilters = () => {
|
|||||||
}
|
}
|
||||||
granularity.value = 'day'
|
granularity.value = 'day'
|
||||||
// Reset date range to default (last 7 days)
|
// Reset date range to default (last 7 days)
|
||||||
initializeDateRange()
|
const now = new Date()
|
||||||
|
const weekAgo = new Date(now)
|
||||||
|
weekAgo.setDate(weekAgo.getDate() - 6)
|
||||||
|
startDate.value = formatLocalDate(weekAgo)
|
||||||
|
endDate.value = formatLocalDate(now)
|
||||||
|
filters.value.start_date = startDate.value
|
||||||
|
filters.value.end_date = endDate.value
|
||||||
pagination.value.page = 1
|
pagination.value.page = 1
|
||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
loadUsageLogs()
|
loadUsageLogs()
|
||||||
@@ -1114,7 +1124,6 @@ const hideTooltip = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initializeDateRange()
|
|
||||||
loadFilterOptions()
|
loadFilterOptions()
|
||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
loadUsageLogs()
|
loadUsageLogs()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<!-- Row 1: Core Stats -->
|
<!-- Row 1: Core Stats -->
|
||||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<!-- Balance -->
|
<!-- Balance -->
|
||||||
<div class="card p-4">
|
<div v-if="!authStore.isSimpleMode" class="card p-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
<div class="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||||
<svg
|
<svg
|
||||||
@@ -727,10 +727,20 @@ const trendChartRef = ref<ChartComponentRef | null>(null)
|
|||||||
// Recent usage
|
// Recent usage
|
||||||
const recentUsage = ref<UsageLog[]>([])
|
const recentUsage = ref<UsageLog[]>([])
|
||||||
|
|
||||||
|
// Helper function to format date in local timezone
|
||||||
|
const formatLocalDate = (date: Date): string => {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize date range immediately (not in onMounted)
|
||||||
|
const now = new Date()
|
||||||
|
const weekAgo = new Date(now)
|
||||||
|
weekAgo.setDate(weekAgo.getDate() - 6)
|
||||||
|
|
||||||
// Date range
|
// Date range
|
||||||
const granularity = ref<'day' | 'hour'>('day')
|
const granularity = ref<'day' | 'hour'>('day')
|
||||||
const startDate = ref('')
|
const startDate = ref(formatLocalDate(weekAgo))
|
||||||
const endDate = ref('')
|
const endDate = ref(formatLocalDate(now))
|
||||||
|
|
||||||
// Granularity options for Select component
|
// Granularity options for Select component
|
||||||
const granularityOptions = computed(() => [
|
const granularityOptions = computed(() => [
|
||||||
@@ -963,18 +973,6 @@ const onDateRangeChange = (range: {
|
|||||||
loadChartData()
|
loadChartData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize default date range
|
|
||||||
const initializeDateRange = () => {
|
|
||||||
const now = new Date()
|
|
||||||
const today = now.toISOString().split('T')[0]
|
|
||||||
const weekAgo = new Date(now)
|
|
||||||
weekAgo.setDate(weekAgo.getDate() - 6)
|
|
||||||
|
|
||||||
startDate.value = weekAgo.toISOString().split('T')[0]
|
|
||||||
endDate.value = today
|
|
||||||
granularity.value = 'day'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load data
|
// Load data
|
||||||
const loadDashboardStats = async () => {
|
const loadDashboardStats = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -1015,8 +1013,11 @@ const loadChartData = async () => {
|
|||||||
const loadRecentUsage = async () => {
|
const loadRecentUsage = async () => {
|
||||||
loadingUsage.value = true
|
loadingUsage.value = true
|
||||||
try {
|
try {
|
||||||
const endDate = new Date().toISOString().split('T')[0]
|
// Use local timezone instead of UTC
|
||||||
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
const now = new Date()
|
||||||
|
const endDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||||
|
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||||
|
const startDate = `${weekAgo.getFullYear()}-${String(weekAgo.getMonth() + 1).padStart(2, '0')}-${String(weekAgo.getDate()).padStart(2, '0')}`
|
||||||
const usageResponse = await usageAPI.getByDateRange(startDate, endDate)
|
const usageResponse = await usageAPI.getByDateRange(startDate, endDate)
|
||||||
recentUsage.value = usageResponse.items.slice(0, 5)
|
recentUsage.value = usageResponse.items.slice(0, 5)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1035,9 +1036,6 @@ onMounted(async () => {
|
|||||||
console.error('Failed to refresh subscription status:', error)
|
console.error('Failed to refresh subscription status:', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize date range (synchronous)
|
|
||||||
initializeDateRange()
|
|
||||||
|
|
||||||
// Load chart data and recent usage in parallel (non-critical)
|
// Load chart data and recent usage in parallel (non-critical)
|
||||||
Promise.all([loadChartData(), loadRecentUsage()]).catch((error) => {
|
Promise.all([loadChartData(), loadRecentUsage()]).catch((error) => {
|
||||||
console.error('Error loading secondary data:', error)
|
console.error('Error loading secondary data:', error)
|
||||||
|
|||||||
@@ -488,9 +488,19 @@ const apiKeyOptions = computed(() => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Helper function to format date in local timezone
|
||||||
|
const formatLocalDate = (date: Date): string => {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize date range immediately
|
||||||
|
const now = new Date()
|
||||||
|
const weekAgo = new Date(now)
|
||||||
|
weekAgo.setDate(weekAgo.getDate() - 6)
|
||||||
|
|
||||||
// Date range state
|
// Date range state
|
||||||
const startDate = ref('')
|
const startDate = ref(formatLocalDate(weekAgo))
|
||||||
const endDate = ref('')
|
const endDate = ref(formatLocalDate(now))
|
||||||
|
|
||||||
const filters = ref<UsageQueryParams>({
|
const filters = ref<UsageQueryParams>({
|
||||||
api_key_id: undefined,
|
api_key_id: undefined,
|
||||||
@@ -498,18 +508,9 @@ const filters = ref<UsageQueryParams>({
|
|||||||
end_date: undefined
|
end_date: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize default date range (last 7 days)
|
// Initialize filters with date range
|
||||||
const initializeDateRange = () => {
|
filters.value.start_date = startDate.value
|
||||||
const now = new Date()
|
filters.value.end_date = endDate.value
|
||||||
const today = now.toISOString().split('T')[0]
|
|
||||||
const weekAgo = new Date(now)
|
|
||||||
weekAgo.setDate(weekAgo.getDate() - 6)
|
|
||||||
|
|
||||||
startDate.value = weekAgo.toISOString().split('T')[0]
|
|
||||||
endDate.value = today
|
|
||||||
filters.value.start_date = startDate.value
|
|
||||||
filters.value.end_date = endDate.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle date range change from DateRangePicker
|
// Handle date range change from DateRangePicker
|
||||||
const onDateRangeChange = (range: {
|
const onDateRangeChange = (range: {
|
||||||
@@ -629,7 +630,13 @@ const resetFilters = () => {
|
|||||||
end_date: undefined
|
end_date: undefined
|
||||||
}
|
}
|
||||||
// Reset date range to default (last 7 days)
|
// Reset date range to default (last 7 days)
|
||||||
initializeDateRange()
|
const now = new Date()
|
||||||
|
const weekAgo = new Date(now)
|
||||||
|
weekAgo.setDate(weekAgo.getDate() - 6)
|
||||||
|
startDate.value = formatLocalDate(weekAgo)
|
||||||
|
endDate.value = formatLocalDate(now)
|
||||||
|
filters.value.start_date = startDate.value
|
||||||
|
filters.value.end_date = endDate.value
|
||||||
pagination.page = 1
|
pagination.page = 1
|
||||||
loadUsageLogs()
|
loadUsageLogs()
|
||||||
loadUsageStats()
|
loadUsageStats()
|
||||||
@@ -772,7 +779,6 @@ const hideTooltip = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initializeDateRange()
|
|
||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
loadUsageLogs()
|
loadUsageLogs()
|
||||||
loadUsageStats()
|
loadUsageStats()
|
||||||
|
|||||||
Reference in New Issue
Block a user