merge: 合并官方 upstream/main 的 6 个功能更新
合并内容: 1. feat(gateway): Claude Code 系统提示词智能注入 2. fix: 修复创建账号 schedulable 默认值为 false 的 bug 3. fix(frontend): 修复跨时区日期范围筛选问题 4. feat(proxy): SOCKS5H 代理支持(统一代理配置) 5. fix(oauth): 修复 Claude Cookie 添加账号时会话混淆 6. fix(test): 修复 OAuth 账号测试刷新 token 的 bug 新增文件: - backend/internal/pkg/proxyutil/* (SOCKS5H 支持) - backend/internal/service/gateway_prompt_test.go (测试) 来自 upstream: Wei-Shaw/sub2api commits d9b1587..a527559
This commit is contained in:
@@ -1,248 +1,248 @@
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
||||
//go:build !wireinject
|
||||
// +build !wireinject
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
|
||||
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
_ "github.com/Wei-Shaw/sub2api/ent/runtime"
|
||||
)
|
||||
|
||||
// Injectors from wire.go:
|
||||
|
||||
func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
configConfig, err := config.ProvideConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := repository.ProvideEnt(configConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := repository.ProvideSQLDB(client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userRepository := repository.NewUserRepository(client, db)
|
||||
settingRepository := repository.NewSettingRepository(client)
|
||||
settingService := service.NewSettingService(settingRepository, configConfig)
|
||||
redisClient := repository.ProvideRedis(configConfig)
|
||||
emailCache := repository.NewEmailCache(redisClient)
|
||||
emailService := service.NewEmailService(settingRepository, emailCache)
|
||||
turnstileVerifier := repository.NewTurnstileVerifier()
|
||||
turnstileService := service.NewTurnstileService(settingService, turnstileVerifier)
|
||||
emailQueueService := service.ProvideEmailQueueService(emailService)
|
||||
authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService)
|
||||
userService := service.NewUserService(userRepository)
|
||||
authHandler := handler.NewAuthHandler(configConfig, authService, userService)
|
||||
userHandler := handler.NewUserHandler(userService)
|
||||
apiKeyRepository := repository.NewApiKeyRepository(client)
|
||||
groupRepository := repository.NewGroupRepository(client, db)
|
||||
userSubscriptionRepository := repository.NewUserSubscriptionRepository(client)
|
||||
apiKeyCache := repository.NewApiKeyCache(redisClient)
|
||||
apiKeyService := service.NewApiKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, apiKeyCache, configConfig)
|
||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||
usageLogRepository := repository.NewUsageLogRepository(client, db)
|
||||
usageService := service.NewUsageService(usageLogRepository, userRepository)
|
||||
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
||||
redeemCodeRepository := repository.NewRedeemCodeRepository(client)
|
||||
billingCache := repository.NewBillingCache(redisClient)
|
||||
billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository, configConfig)
|
||||
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService)
|
||||
redeemCache := repository.NewRedeemCache(redisClient)
|
||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client)
|
||||
redeemHandler := handler.NewRedeemHandler(redeemService)
|
||||
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
|
||||
dashboardService := service.NewDashboardService(usageLogRepository)
|
||||
dashboardHandler := admin.NewDashboardHandler(dashboardService)
|
||||
accountRepository := repository.NewAccountRepository(client, db)
|
||||
proxyRepository := repository.NewProxyRepository(client, db)
|
||||
proxyExitInfoProber := repository.NewProxyExitInfoProber()
|
||||
adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, billingCacheService, proxyExitInfoProber)
|
||||
adminUserHandler := admin.NewUserHandler(adminService)
|
||||
groupHandler := admin.NewGroupHandler(adminService)
|
||||
claudeOAuthClient := repository.NewClaudeOAuthClient()
|
||||
oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient)
|
||||
openAIOAuthClient := repository.NewOpenAIOAuthClient()
|
||||
openAIOAuthService := service.NewOpenAIOAuthService(proxyRepository, openAIOAuthClient)
|
||||
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
|
||||
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
|
||||
geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, configConfig)
|
||||
geminiQuotaService := service.NewGeminiQuotaService(configConfig, settingRepository)
|
||||
rateLimitService := service.NewRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService)
|
||||
claudeUsageFetcher := repository.NewClaudeUsageFetcher()
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
|
||||
usageCache := service.NewUsageCache()
|
||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache)
|
||||
geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
|
||||
geminiTokenProvider := service.NewGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService)
|
||||
gatewayCache := repository.NewGatewayCache(redisClient)
|
||||
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
|
||||
antigravityTokenProvider := service.NewAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService)
|
||||
httpUpstream := repository.NewHTTPUpstream(configConfig)
|
||||
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, antigravityTokenProvider, rateLimitService, httpUpstream)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, geminiTokenProvider, antigravityGatewayService, httpUpstream)
|
||||
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
|
||||
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService)
|
||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
|
||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
||||
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
||||
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
|
||||
proxyHandler := admin.NewProxyHandler(adminService)
|
||||
adminRedeemHandler := admin.NewRedeemHandler(adminService)
|
||||
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService)
|
||||
updateCache := repository.NewUpdateCache(redisClient)
|
||||
gitHubReleaseClient := repository.NewGitHubReleaseClient()
|
||||
serviceBuildInfo := provideServiceBuildInfo(buildInfo)
|
||||
updateService := service.ProvideUpdateService(updateCache, gitHubReleaseClient, serviceBuildInfo)
|
||||
systemHandler := handler.ProvideSystemHandler(updateService)
|
||||
adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService)
|
||||
adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService)
|
||||
userAttributeDefinitionRepository := repository.NewUserAttributeDefinitionRepository(client)
|
||||
userAttributeValueRepository := repository.NewUserAttributeValueRepository(client)
|
||||
userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository)
|
||||
userAttributeHandler := admin.NewUserAttributeHandler(userAttributeService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler)
|
||||
pricingRemoteClient := repository.NewPricingRemoteClient()
|
||||
pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
billingService := service.NewBillingService(configConfig, pricingService)
|
||||
identityCache := repository.NewIdentityCache(redisClient)
|
||||
identityService := service.NewIdentityService(identityCache)
|
||||
timingWheelService := service.ProvideTimingWheelService()
|
||||
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService)
|
||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService)
|
||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService)
|
||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)
|
||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler)
|
||||
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
|
||||
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
|
||||
apiKeyAuthMiddleware := middleware.NewApiKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService)
|
||||
httpServer := server.ProvideHTTPServer(configConfig, engine)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig)
|
||||
v := provideCleanup(client, redisClient, tokenRefreshService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
Cleanup: v,
|
||||
}
|
||||
return application, nil
|
||||
}
|
||||
|
||||
// wire.go:
|
||||
|
||||
type Application struct {
|
||||
Server *http.Server
|
||||
Cleanup func()
|
||||
}
|
||||
|
||||
func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo {
|
||||
return service.BuildInfo{
|
||||
Version: buildInfo.Version,
|
||||
BuildType: buildInfo.BuildType,
|
||||
}
|
||||
}
|
||||
|
||||
func provideCleanup(
|
||||
entClient *ent.Client,
|
||||
rdb *redis.Client,
|
||||
tokenRefresh *service.TokenRefreshService,
|
||||
pricing *service.PricingService,
|
||||
emailQueue *service.EmailQueueService,
|
||||
billingCache *service.BillingCacheService,
|
||||
oauth *service.OAuthService,
|
||||
openaiOAuth *service.OpenAIOAuthService,
|
||||
geminiOAuth *service.GeminiOAuthService,
|
||||
antigravityOAuth *service.AntigravityOAuthService,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cleanupSteps := []struct {
|
||||
name string
|
||||
fn func() error
|
||||
}{
|
||||
{"TokenRefreshService", func() error {
|
||||
tokenRefresh.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"PricingService", func() error {
|
||||
pricing.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"EmailQueueService", func() error {
|
||||
emailQueue.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"BillingCacheService", func() error {
|
||||
billingCache.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"OAuthService", func() error {
|
||||
oauth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"OpenAIOAuthService", func() error {
|
||||
openaiOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"GeminiOAuthService", func() error {
|
||||
geminiOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"AntigravityOAuthService", func() error {
|
||||
antigravityOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"Redis", func() error {
|
||||
return rdb.Close()
|
||||
}},
|
||||
{"Ent", func() error {
|
||||
return entClient.Close()
|
||||
}},
|
||||
}
|
||||
|
||||
for _, step := range cleanupSteps {
|
||||
if err := step.fn(); err != nil {
|
||||
log.Printf("[Cleanup] %s failed: %v", step.name, err)
|
||||
|
||||
} else {
|
||||
log.Printf("[Cleanup] %s succeeded", step.name)
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("[Cleanup] Warning: cleanup timed out after 10 seconds")
|
||||
default:
|
||||
log.Printf("[Cleanup] All cleanup steps completed")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
||||
//go:build !wireinject
|
||||
// +build !wireinject
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
|
||||
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
_ "github.com/Wei-Shaw/sub2api/ent/runtime"
|
||||
)
|
||||
|
||||
// Injectors from wire.go:
|
||||
|
||||
func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
configConfig, err := config.ProvideConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := repository.ProvideEnt(configConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := repository.ProvideSQLDB(client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userRepository := repository.NewUserRepository(client, db)
|
||||
settingRepository := repository.NewSettingRepository(client)
|
||||
settingService := service.NewSettingService(settingRepository, configConfig)
|
||||
redisClient := repository.ProvideRedis(configConfig)
|
||||
emailCache := repository.NewEmailCache(redisClient)
|
||||
emailService := service.NewEmailService(settingRepository, emailCache)
|
||||
turnstileVerifier := repository.NewTurnstileVerifier()
|
||||
turnstileService := service.NewTurnstileService(settingService, turnstileVerifier)
|
||||
emailQueueService := service.ProvideEmailQueueService(emailService)
|
||||
authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService)
|
||||
userService := service.NewUserService(userRepository)
|
||||
authHandler := handler.NewAuthHandler(configConfig, authService, userService)
|
||||
userHandler := handler.NewUserHandler(userService)
|
||||
apiKeyRepository := repository.NewApiKeyRepository(client)
|
||||
groupRepository := repository.NewGroupRepository(client, db)
|
||||
userSubscriptionRepository := repository.NewUserSubscriptionRepository(client)
|
||||
apiKeyCache := repository.NewApiKeyCache(redisClient)
|
||||
apiKeyService := service.NewApiKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, apiKeyCache, configConfig)
|
||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||
usageLogRepository := repository.NewUsageLogRepository(client, db)
|
||||
usageService := service.NewUsageService(usageLogRepository, userRepository)
|
||||
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
||||
redeemCodeRepository := repository.NewRedeemCodeRepository(client)
|
||||
billingCache := repository.NewBillingCache(redisClient)
|
||||
billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository, configConfig)
|
||||
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService)
|
||||
redeemCache := repository.NewRedeemCache(redisClient)
|
||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client)
|
||||
redeemHandler := handler.NewRedeemHandler(redeemService)
|
||||
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
|
||||
dashboardService := service.NewDashboardService(usageLogRepository)
|
||||
dashboardHandler := admin.NewDashboardHandler(dashboardService)
|
||||
accountRepository := repository.NewAccountRepository(client, db)
|
||||
proxyRepository := repository.NewProxyRepository(client, db)
|
||||
proxyExitInfoProber := repository.NewProxyExitInfoProber()
|
||||
adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, billingCacheService, proxyExitInfoProber)
|
||||
adminUserHandler := admin.NewUserHandler(adminService)
|
||||
groupHandler := admin.NewGroupHandler(adminService)
|
||||
claudeOAuthClient := repository.NewClaudeOAuthClient()
|
||||
oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient)
|
||||
openAIOAuthClient := repository.NewOpenAIOAuthClient()
|
||||
openAIOAuthService := service.NewOpenAIOAuthService(proxyRepository, openAIOAuthClient)
|
||||
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
|
||||
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
|
||||
geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, configConfig)
|
||||
geminiQuotaService := service.NewGeminiQuotaService(configConfig, settingRepository)
|
||||
rateLimitService := service.NewRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService)
|
||||
claudeUsageFetcher := repository.NewClaudeUsageFetcher()
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
|
||||
usageCache := service.NewUsageCache()
|
||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache)
|
||||
geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
|
||||
geminiTokenProvider := service.NewGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService)
|
||||
gatewayCache := repository.NewGatewayCache(redisClient)
|
||||
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
|
||||
antigravityTokenProvider := service.NewAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService)
|
||||
httpUpstream := repository.NewHTTPUpstream(configConfig)
|
||||
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, antigravityTokenProvider, rateLimitService, httpUpstream)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream)
|
||||
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
|
||||
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService)
|
||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
|
||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
||||
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
||||
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
|
||||
proxyHandler := admin.NewProxyHandler(adminService)
|
||||
adminRedeemHandler := admin.NewRedeemHandler(adminService)
|
||||
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService)
|
||||
updateCache := repository.NewUpdateCache(redisClient)
|
||||
gitHubReleaseClient := repository.NewGitHubReleaseClient()
|
||||
serviceBuildInfo := provideServiceBuildInfo(buildInfo)
|
||||
updateService := service.ProvideUpdateService(updateCache, gitHubReleaseClient, serviceBuildInfo)
|
||||
systemHandler := handler.ProvideSystemHandler(updateService)
|
||||
adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService)
|
||||
adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService)
|
||||
userAttributeDefinitionRepository := repository.NewUserAttributeDefinitionRepository(client)
|
||||
userAttributeValueRepository := repository.NewUserAttributeValueRepository(client)
|
||||
userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository)
|
||||
userAttributeHandler := admin.NewUserAttributeHandler(userAttributeService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler)
|
||||
pricingRemoteClient := repository.NewPricingRemoteClient()
|
||||
pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
billingService := service.NewBillingService(configConfig, pricingService)
|
||||
identityCache := repository.NewIdentityCache(redisClient)
|
||||
identityService := service.NewIdentityService(identityCache)
|
||||
timingWheelService := service.ProvideTimingWheelService()
|
||||
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService)
|
||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService)
|
||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService)
|
||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)
|
||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler)
|
||||
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
|
||||
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
|
||||
apiKeyAuthMiddleware := middleware.NewApiKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService)
|
||||
httpServer := server.ProvideHTTPServer(configConfig, engine)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig)
|
||||
v := provideCleanup(client, redisClient, tokenRefreshService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
Cleanup: v,
|
||||
}
|
||||
return application, nil
|
||||
}
|
||||
|
||||
// wire.go:
|
||||
|
||||
type Application struct {
|
||||
Server *http.Server
|
||||
Cleanup func()
|
||||
}
|
||||
|
||||
func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo {
|
||||
return service.BuildInfo{
|
||||
Version: buildInfo.Version,
|
||||
BuildType: buildInfo.BuildType,
|
||||
}
|
||||
}
|
||||
|
||||
func provideCleanup(
|
||||
entClient *ent.Client,
|
||||
rdb *redis.Client,
|
||||
tokenRefresh *service.TokenRefreshService,
|
||||
pricing *service.PricingService,
|
||||
emailQueue *service.EmailQueueService,
|
||||
billingCache *service.BillingCacheService,
|
||||
oauth *service.OAuthService,
|
||||
openaiOAuth *service.OpenAIOAuthService,
|
||||
geminiOAuth *service.GeminiOAuthService,
|
||||
antigravityOAuth *service.AntigravityOAuthService,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cleanupSteps := []struct {
|
||||
name string
|
||||
fn func() error
|
||||
}{
|
||||
{"TokenRefreshService", func() error {
|
||||
tokenRefresh.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"PricingService", func() error {
|
||||
pricing.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"EmailQueueService", func() error {
|
||||
emailQueue.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"BillingCacheService", func() error {
|
||||
billingCache.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"OAuthService", func() error {
|
||||
oauth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"OpenAIOAuthService", func() error {
|
||||
openaiOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"GeminiOAuthService", func() error {
|
||||
geminiOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"AntigravityOAuthService", func() error {
|
||||
antigravityOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"Redis", func() error {
|
||||
return rdb.Close()
|
||||
}},
|
||||
{"Ent", func() error {
|
||||
return entClient.Close()
|
||||
}},
|
||||
}
|
||||
|
||||
for _, step := range cleanupSteps {
|
||||
if err := step.fn(); err != nil {
|
||||
log.Printf("[Cleanup] %s failed: %v", step.name, err)
|
||||
|
||||
} else {
|
||||
log.Printf("[Cleanup] %s succeeded", step.name)
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("[Cleanup] Warning: cleanup timed out after 10 seconds")
|
||||
default:
|
||||
log.Printf("[Cleanup] All cleanup steps completed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,323 +1,323 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ProxyHandler handles admin proxy management
|
||||
type ProxyHandler struct {
|
||||
adminService service.AdminService
|
||||
}
|
||||
|
||||
// NewProxyHandler creates a new admin proxy handler
|
||||
func NewProxyHandler(adminService service.AdminService) *ProxyHandler {
|
||||
return &ProxyHandler{
|
||||
adminService: adminService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateProxyRequest represents create proxy request
|
||||
type CreateProxyRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Protocol string `json:"protocol" binding:"required,oneof=http https socks5"`
|
||||
Host string `json:"host" binding:"required"`
|
||||
Port int `json:"port" binding:"required,min=1,max=65535"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// UpdateProxyRequest represents update proxy request
|
||||
type UpdateProxyRequest struct {
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
}
|
||||
|
||||
// List handles listing all proxies with pagination
|
||||
// GET /api/v1/admin/proxies
|
||||
func (h *ProxyHandler) List(c *gin.Context) {
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
protocol := c.Query("protocol")
|
||||
status := c.Query("status")
|
||||
search := c.Query("search")
|
||||
|
||||
proxies, total, err := h.adminService.ListProxies(c.Request.Context(), page, pageSize, protocol, status, search)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.Proxy, 0, len(proxies))
|
||||
for i := range proxies {
|
||||
out = append(out, *dto.ProxyFromService(&proxies[i]))
|
||||
}
|
||||
response.Paginated(c, out, total, page, pageSize)
|
||||
}
|
||||
|
||||
// GetAll handles getting all active proxies without pagination
|
||||
// GET /api/v1/admin/proxies/all
|
||||
// Optional query param: with_count=true to include account count per proxy
|
||||
func (h *ProxyHandler) GetAll(c *gin.Context) {
|
||||
withCount := c.Query("with_count") == "true"
|
||||
|
||||
if withCount {
|
||||
proxies, err := h.adminService.GetAllProxiesWithAccountCount(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
out := make([]dto.ProxyWithAccountCount, 0, len(proxies))
|
||||
for i := range proxies {
|
||||
out = append(out, *dto.ProxyWithAccountCountFromService(&proxies[i]))
|
||||
}
|
||||
response.Success(c, out)
|
||||
return
|
||||
}
|
||||
|
||||
proxies, err := h.adminService.GetAllProxies(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.Proxy, 0, len(proxies))
|
||||
for i := range proxies {
|
||||
out = append(out, *dto.ProxyFromService(&proxies[i]))
|
||||
}
|
||||
response.Success(c, out)
|
||||
}
|
||||
|
||||
// GetByID handles getting a proxy by ID
|
||||
// GET /api/v1/admin/proxies/:id
|
||||
func (h *ProxyHandler) GetByID(c *gin.Context) {
|
||||
proxyID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid proxy ID")
|
||||
return
|
||||
}
|
||||
|
||||
proxy, err := h.adminService.GetProxy(c.Request.Context(), proxyID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.ProxyFromService(proxy))
|
||||
}
|
||||
|
||||
// Create handles creating a new proxy
|
||||
// POST /api/v1/admin/proxies
|
||||
func (h *ProxyHandler) Create(c *gin.Context) {
|
||||
var req CreateProxyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
proxy, err := h.adminService.CreateProxy(c.Request.Context(), &service.CreateProxyInput{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Protocol: strings.TrimSpace(req.Protocol),
|
||||
Host: strings.TrimSpace(req.Host),
|
||||
Port: req.Port,
|
||||
Username: strings.TrimSpace(req.Username),
|
||||
Password: strings.TrimSpace(req.Password),
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.ProxyFromService(proxy))
|
||||
}
|
||||
|
||||
// Update handles updating a proxy
|
||||
// PUT /api/v1/admin/proxies/:id
|
||||
func (h *ProxyHandler) Update(c *gin.Context) {
|
||||
proxyID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid proxy ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateProxyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
proxy, err := h.adminService.UpdateProxy(c.Request.Context(), proxyID, &service.UpdateProxyInput{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Protocol: strings.TrimSpace(req.Protocol),
|
||||
Host: strings.TrimSpace(req.Host),
|
||||
Port: req.Port,
|
||||
Username: strings.TrimSpace(req.Username),
|
||||
Password: strings.TrimSpace(req.Password),
|
||||
Status: strings.TrimSpace(req.Status),
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.ProxyFromService(proxy))
|
||||
}
|
||||
|
||||
// Delete handles deleting a proxy
|
||||
// DELETE /api/v1/admin/proxies/:id
|
||||
func (h *ProxyHandler) Delete(c *gin.Context) {
|
||||
proxyID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid proxy ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.adminService.DeleteProxy(c.Request.Context(), proxyID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "Proxy deleted successfully"})
|
||||
}
|
||||
|
||||
// Test handles testing proxy connectivity
|
||||
// POST /api/v1/admin/proxies/:id/test
|
||||
func (h *ProxyHandler) Test(c *gin.Context) {
|
||||
proxyID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid proxy ID")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.adminService.TestProxy(c.Request.Context(), proxyID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// GetStats handles getting proxy statistics
|
||||
// GET /api/v1/admin/proxies/:id/stats
|
||||
func (h *ProxyHandler) GetStats(c *gin.Context) {
|
||||
proxyID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid proxy ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Return mock data for now
|
||||
_ = proxyID
|
||||
response.Success(c, gin.H{
|
||||
"total_accounts": 0,
|
||||
"active_accounts": 0,
|
||||
"total_requests": 0,
|
||||
"success_rate": 100.0,
|
||||
"average_latency": 0,
|
||||
})
|
||||
}
|
||||
|
||||
// GetProxyAccounts handles getting accounts using a proxy
|
||||
// GET /api/v1/admin/proxies/:id/accounts
|
||||
func (h *ProxyHandler) GetProxyAccounts(c *gin.Context) {
|
||||
proxyID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid proxy ID")
|
||||
return
|
||||
}
|
||||
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
|
||||
accounts, total, err := h.adminService.GetProxyAccounts(c.Request.Context(), proxyID, page, pageSize)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.Account, 0, len(accounts))
|
||||
for i := range accounts {
|
||||
out = append(out, *dto.AccountFromService(&accounts[i]))
|
||||
}
|
||||
response.Paginated(c, out, total, page, pageSize)
|
||||
}
|
||||
|
||||
// BatchCreateProxyItem represents a single proxy in batch create request
|
||||
type BatchCreateProxyItem struct {
|
||||
Protocol string `json:"protocol" binding:"required,oneof=http https socks5"`
|
||||
Host string `json:"host" binding:"required"`
|
||||
Port int `json:"port" binding:"required,min=1,max=65535"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// BatchCreateRequest represents batch create proxies request
|
||||
type BatchCreateRequest struct {
|
||||
Proxies []BatchCreateProxyItem `json:"proxies" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// BatchCreate handles batch creating proxies
|
||||
// POST /api/v1/admin/proxies/batch
|
||||
func (h *ProxyHandler) BatchCreate(c *gin.Context) {
|
||||
var req BatchCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
created := 0
|
||||
skipped := 0
|
||||
|
||||
for _, item := range req.Proxies {
|
||||
// Trim all string fields
|
||||
host := strings.TrimSpace(item.Host)
|
||||
protocol := strings.TrimSpace(item.Protocol)
|
||||
username := strings.TrimSpace(item.Username)
|
||||
password := strings.TrimSpace(item.Password)
|
||||
|
||||
// Check for duplicates (same host, port, username, password)
|
||||
exists, err := h.adminService.CheckProxyExists(c.Request.Context(), host, item.Port, username, password)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if exists {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Create proxy with default name
|
||||
_, err = h.adminService.CreateProxy(c.Request.Context(), &service.CreateProxyInput{
|
||||
Name: "default",
|
||||
Protocol: protocol,
|
||||
Host: host,
|
||||
Port: item.Port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
if err != nil {
|
||||
// If creation fails due to duplicate, count as skipped
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
created++
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"created": created,
|
||||
"skipped": skipped,
|
||||
})
|
||||
}
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ProxyHandler handles admin proxy management
|
||||
type ProxyHandler struct {
|
||||
adminService service.AdminService
|
||||
}
|
||||
|
||||
// NewProxyHandler creates a new admin proxy handler
|
||||
func NewProxyHandler(adminService service.AdminService) *ProxyHandler {
|
||||
return &ProxyHandler{
|
||||
adminService: adminService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateProxyRequest represents create proxy request
|
||||
type CreateProxyRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Protocol string `json:"protocol" binding:"required,oneof=http https socks5 socks5h"`
|
||||
Host string `json:"host" binding:"required"`
|
||||
Port int `json:"port" binding:"required,min=1,max=65535"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// UpdateProxyRequest represents update proxy request
|
||||
type UpdateProxyRequest struct {
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
}
|
||||
|
||||
// List handles listing all proxies with pagination
|
||||
// GET /api/v1/admin/proxies
|
||||
func (h *ProxyHandler) List(c *gin.Context) {
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
protocol := c.Query("protocol")
|
||||
status := c.Query("status")
|
||||
search := c.Query("search")
|
||||
|
||||
proxies, total, err := h.adminService.ListProxies(c.Request.Context(), page, pageSize, protocol, status, search)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.Proxy, 0, len(proxies))
|
||||
for i := range proxies {
|
||||
out = append(out, *dto.ProxyFromService(&proxies[i]))
|
||||
}
|
||||
response.Paginated(c, out, total, page, pageSize)
|
||||
}
|
||||
|
||||
// GetAll handles getting all active proxies without pagination
|
||||
// GET /api/v1/admin/proxies/all
|
||||
// Optional query param: with_count=true to include account count per proxy
|
||||
func (h *ProxyHandler) GetAll(c *gin.Context) {
|
||||
withCount := c.Query("with_count") == "true"
|
||||
|
||||
if withCount {
|
||||
proxies, err := h.adminService.GetAllProxiesWithAccountCount(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
out := make([]dto.ProxyWithAccountCount, 0, len(proxies))
|
||||
for i := range proxies {
|
||||
out = append(out, *dto.ProxyWithAccountCountFromService(&proxies[i]))
|
||||
}
|
||||
response.Success(c, out)
|
||||
return
|
||||
}
|
||||
|
||||
proxies, err := h.adminService.GetAllProxies(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.Proxy, 0, len(proxies))
|
||||
for i := range proxies {
|
||||
out = append(out, *dto.ProxyFromService(&proxies[i]))
|
||||
}
|
||||
response.Success(c, out)
|
||||
}
|
||||
|
||||
// GetByID handles getting a proxy by ID
|
||||
// GET /api/v1/admin/proxies/:id
|
||||
func (h *ProxyHandler) GetByID(c *gin.Context) {
|
||||
proxyID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid proxy ID")
|
||||
return
|
||||
}
|
||||
|
||||
proxy, err := h.adminService.GetProxy(c.Request.Context(), proxyID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.ProxyFromService(proxy))
|
||||
}
|
||||
|
||||
// Create handles creating a new proxy
|
||||
// POST /api/v1/admin/proxies
|
||||
func (h *ProxyHandler) Create(c *gin.Context) {
|
||||
var req CreateProxyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
proxy, err := h.adminService.CreateProxy(c.Request.Context(), &service.CreateProxyInput{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Protocol: strings.TrimSpace(req.Protocol),
|
||||
Host: strings.TrimSpace(req.Host),
|
||||
Port: req.Port,
|
||||
Username: strings.TrimSpace(req.Username),
|
||||
Password: strings.TrimSpace(req.Password),
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.ProxyFromService(proxy))
|
||||
}
|
||||
|
||||
// Update handles updating a proxy
|
||||
// PUT /api/v1/admin/proxies/:id
|
||||
func (h *ProxyHandler) Update(c *gin.Context) {
|
||||
proxyID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid proxy ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateProxyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
proxy, err := h.adminService.UpdateProxy(c.Request.Context(), proxyID, &service.UpdateProxyInput{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Protocol: strings.TrimSpace(req.Protocol),
|
||||
Host: strings.TrimSpace(req.Host),
|
||||
Port: req.Port,
|
||||
Username: strings.TrimSpace(req.Username),
|
||||
Password: strings.TrimSpace(req.Password),
|
||||
Status: strings.TrimSpace(req.Status),
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.ProxyFromService(proxy))
|
||||
}
|
||||
|
||||
// Delete handles deleting a proxy
|
||||
// DELETE /api/v1/admin/proxies/:id
|
||||
func (h *ProxyHandler) Delete(c *gin.Context) {
|
||||
proxyID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid proxy ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.adminService.DeleteProxy(c.Request.Context(), proxyID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "Proxy deleted successfully"})
|
||||
}
|
||||
|
||||
// Test handles testing proxy connectivity
|
||||
// POST /api/v1/admin/proxies/:id/test
|
||||
func (h *ProxyHandler) Test(c *gin.Context) {
|
||||
proxyID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid proxy ID")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.adminService.TestProxy(c.Request.Context(), proxyID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// GetStats handles getting proxy statistics
|
||||
// GET /api/v1/admin/proxies/:id/stats
|
||||
func (h *ProxyHandler) GetStats(c *gin.Context) {
|
||||
proxyID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid proxy ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Return mock data for now
|
||||
_ = proxyID
|
||||
response.Success(c, gin.H{
|
||||
"total_accounts": 0,
|
||||
"active_accounts": 0,
|
||||
"total_requests": 0,
|
||||
"success_rate": 100.0,
|
||||
"average_latency": 0,
|
||||
})
|
||||
}
|
||||
|
||||
// GetProxyAccounts handles getting accounts using a proxy
|
||||
// GET /api/v1/admin/proxies/:id/accounts
|
||||
func (h *ProxyHandler) GetProxyAccounts(c *gin.Context) {
|
||||
proxyID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid proxy ID")
|
||||
return
|
||||
}
|
||||
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
|
||||
accounts, total, err := h.adminService.GetProxyAccounts(c.Request.Context(), proxyID, page, pageSize)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.Account, 0, len(accounts))
|
||||
for i := range accounts {
|
||||
out = append(out, *dto.AccountFromService(&accounts[i]))
|
||||
}
|
||||
response.Paginated(c, out, total, page, pageSize)
|
||||
}
|
||||
|
||||
// BatchCreateProxyItem represents a single proxy in batch create request
|
||||
type BatchCreateProxyItem struct {
|
||||
Protocol string `json:"protocol" binding:"required,oneof=http https socks5 socks5h"`
|
||||
Host string `json:"host" binding:"required"`
|
||||
Port int `json:"port" binding:"required,min=1,max=65535"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// BatchCreateRequest represents batch create proxies request
|
||||
type BatchCreateRequest struct {
|
||||
Proxies []BatchCreateProxyItem `json:"proxies" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// BatchCreate handles batch creating proxies
|
||||
// POST /api/v1/admin/proxies/batch
|
||||
func (h *ProxyHandler) BatchCreate(c *gin.Context) {
|
||||
var req BatchCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
created := 0
|
||||
skipped := 0
|
||||
|
||||
for _, item := range req.Proxies {
|
||||
// Trim all string fields
|
||||
host := strings.TrimSpace(item.Host)
|
||||
protocol := strings.TrimSpace(item.Protocol)
|
||||
username := strings.TrimSpace(item.Username)
|
||||
password := strings.TrimSpace(item.Password)
|
||||
|
||||
// Check for duplicates (same host, port, username, password)
|
||||
exists, err := h.adminService.CheckProxyExists(c.Request.Context(), host, item.Port, username, password)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if exists {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Create proxy with default name
|
||||
_, err = h.adminService.CreateProxy(c.Request.Context(), &service.CreateProxyInput{
|
||||
Name: "default",
|
||||
Protocol: protocol,
|
||||
Host: host,
|
||||
Port: item.Port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
if err != nil {
|
||||
// If creation fails due to duplicate, count as skipped
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
created++
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"created": created,
|
||||
"skipped": skipped,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,157 +1,138 @@
|
||||
// Package httpclient 提供共享 HTTP 客户端池
|
||||
//
|
||||
// 性能优化说明:
|
||||
// 原实现在多个服务中重复创建 http.Client:
|
||||
// 1. proxy_probe_service.go: 每次探测创建新客户端
|
||||
// 2. pricing_service.go: 每次请求创建新客户端
|
||||
// 3. turnstile_service.go: 每次验证创建新客户端
|
||||
// 4. github_release_service.go: 每次请求创建新客户端
|
||||
// 5. claude_usage_service.go: 每次请求创建新客户端
|
||||
//
|
||||
// 新实现使用统一的客户端池:
|
||||
// 1. 相同配置复用同一 http.Client 实例
|
||||
// 2. 复用 Transport 连接池,减少 TCP/TLS 握手开销
|
||||
// 3. 支持 HTTP/HTTPS/SOCKS5 代理
|
||||
// 4. 支持严格代理模式(代理失败则返回错误)
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// Transport 连接池默认配置
|
||||
const (
|
||||
defaultMaxIdleConns = 100 // 最大空闲连接数
|
||||
defaultMaxIdleConnsPerHost = 10 // 每个主机最大空闲连接数
|
||||
defaultIdleConnTimeout = 90 * time.Second // 空闲连接超时时间
|
||||
)
|
||||
|
||||
// Options 定义共享 HTTP 客户端的构建参数
|
||||
type Options struct {
|
||||
ProxyURL string // 代理 URL(支持 http/https/socks5)
|
||||
Timeout time.Duration // 请求总超时时间
|
||||
ResponseHeaderTimeout time.Duration // 等待响应头超时时间
|
||||
InsecureSkipVerify bool // 是否跳过 TLS 证书验证
|
||||
ProxyStrict bool // 严格代理模式:代理失败时返回错误而非回退
|
||||
|
||||
// 可选的连接池参数(不设置则使用默认值)
|
||||
MaxIdleConns int // 最大空闲连接总数(默认 100)
|
||||
MaxIdleConnsPerHost int // 每主机最大空闲连接(默认 10)
|
||||
MaxConnsPerHost int // 每主机最大连接数(默认 0 无限制)
|
||||
}
|
||||
|
||||
// sharedClients 存储按配置参数缓存的 http.Client 实例
|
||||
var sharedClients sync.Map
|
||||
|
||||
// GetClient 返回共享的 HTTP 客户端实例
|
||||
// 性能优化:相同配置复用同一客户端,避免重复创建 Transport
|
||||
func GetClient(opts Options) (*http.Client, error) {
|
||||
key := buildClientKey(opts)
|
||||
if cached, ok := sharedClients.Load(key); ok {
|
||||
if client, ok := cached.(*http.Client); ok {
|
||||
return client, nil
|
||||
}
|
||||
}
|
||||
|
||||
client, err := buildClient(opts)
|
||||
if err != nil {
|
||||
if opts.ProxyStrict {
|
||||
return nil, err
|
||||
}
|
||||
fallback := opts
|
||||
fallback.ProxyURL = ""
|
||||
client, _ = buildClient(fallback)
|
||||
}
|
||||
|
||||
actual, _ := sharedClients.LoadOrStore(key, client)
|
||||
if c, ok := actual.(*http.Client); ok {
|
||||
return c, nil
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func buildClient(opts Options) (*http.Client, error) {
|
||||
transport, err := buildTransport(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: opts.Timeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildTransport(opts Options) (*http.Transport, error) {
|
||||
// 使用自定义值或默认值
|
||||
maxIdleConns := opts.MaxIdleConns
|
||||
if maxIdleConns <= 0 {
|
||||
maxIdleConns = defaultMaxIdleConns
|
||||
}
|
||||
maxIdleConnsPerHost := opts.MaxIdleConnsPerHost
|
||||
if maxIdleConnsPerHost <= 0 {
|
||||
maxIdleConnsPerHost = defaultMaxIdleConnsPerHost
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: maxIdleConns,
|
||||
MaxIdleConnsPerHost: maxIdleConnsPerHost,
|
||||
MaxConnsPerHost: opts.MaxConnsPerHost, // 0 表示无限制
|
||||
IdleConnTimeout: defaultIdleConnTimeout,
|
||||
ResponseHeaderTimeout: opts.ResponseHeaderTimeout,
|
||||
}
|
||||
|
||||
if opts.InsecureSkipVerify {
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
|
||||
proxyURL := strings.TrimSpace(opts.ProxyURL)
|
||||
if proxyURL == "" {
|
||||
return transport, nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "http", "https":
|
||||
transport.Proxy = http.ProxyURL(parsed)
|
||||
case "socks5", "socks5h":
|
||||
dialer, err := proxy.FromURL(parsed, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy protocol: %s", parsed.Scheme)
|
||||
}
|
||||
|
||||
return transport, nil
|
||||
}
|
||||
|
||||
func buildClientKey(opts Options) string {
|
||||
return fmt.Sprintf("%s|%s|%s|%t|%t|%d|%d|%d",
|
||||
strings.TrimSpace(opts.ProxyURL),
|
||||
opts.Timeout.String(),
|
||||
opts.ResponseHeaderTimeout.String(),
|
||||
opts.InsecureSkipVerify,
|
||||
opts.ProxyStrict,
|
||||
opts.MaxIdleConns,
|
||||
opts.MaxIdleConnsPerHost,
|
||||
opts.MaxConnsPerHost,
|
||||
)
|
||||
}
|
||||
// Package httpclient 提供共享 HTTP 客户端池
|
||||
//
|
||||
// 性能优化说明:
|
||||
// 原实现在多个服务中重复创建 http.Client:
|
||||
// 1. proxy_probe_service.go: 每次探测创建新客户端
|
||||
// 2. pricing_service.go: 每次请求创建新客户端
|
||||
// 3. turnstile_service.go: 每次验证创建新客户端
|
||||
// 4. github_release_service.go: 每次请求创建新客户端
|
||||
// 5. claude_usage_service.go: 每次请求创建新客户端
|
||||
//
|
||||
// 新实现使用统一的客户端池:
|
||||
// 1. 相同配置复用同一 http.Client 实例
|
||||
// 2. 复用 Transport 连接池,减少 TCP/TLS 握手开销
|
||||
// 3. 支持 HTTP/HTTPS/SOCKS5/SOCKS5H 代理
|
||||
// 4. 代理配置失败时直接返回错误,不会回退到直连(避免 IP 关联风险)
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
|
||||
)
|
||||
|
||||
// Transport 连接池默认配置
|
||||
const (
|
||||
defaultMaxIdleConns = 100 // 最大空闲连接数
|
||||
defaultMaxIdleConnsPerHost = 10 // 每个主机最大空闲连接数
|
||||
defaultIdleConnTimeout = 90 * time.Second // 空闲连接超时时间
|
||||
)
|
||||
|
||||
// Options 定义共享 HTTP 客户端的构建参数
|
||||
type Options struct {
|
||||
ProxyURL string // 代理 URL(支持 http/https/socks5/socks5h)
|
||||
Timeout time.Duration // 请求总超时时间
|
||||
ResponseHeaderTimeout time.Duration // 等待响应头超时时间
|
||||
InsecureSkipVerify bool // 是否跳过 TLS 证书验证
|
||||
|
||||
// 可选的连接池参数(不设置则使用默认值)
|
||||
MaxIdleConns int // 最大空闲连接总数(默认 100)
|
||||
MaxIdleConnsPerHost int // 每主机最大空闲连接(默认 10)
|
||||
MaxConnsPerHost int // 每主机最大连接数(默认 0 无限制)
|
||||
}
|
||||
|
||||
// sharedClients 存储按配置参数缓存的 http.Client 实例
|
||||
var sharedClients sync.Map
|
||||
|
||||
// GetClient 返回共享的 HTTP 客户端实例
|
||||
// 性能优化:相同配置复用同一客户端,避免重复创建 Transport
|
||||
// 安全说明:代理配置失败时直接返回错误,不会回退到直连,避免 IP 关联风险
|
||||
func GetClient(opts Options) (*http.Client, error) {
|
||||
key := buildClientKey(opts)
|
||||
if cached, ok := sharedClients.Load(key); ok {
|
||||
if client, ok := cached.(*http.Client); ok {
|
||||
return client, nil
|
||||
}
|
||||
}
|
||||
|
||||
client, err := buildClient(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
actual, _ := sharedClients.LoadOrStore(key, client)
|
||||
if c, ok := actual.(*http.Client); ok {
|
||||
return c, nil
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func buildClient(opts Options) (*http.Client, error) {
|
||||
transport, err := buildTransport(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: opts.Timeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildTransport(opts Options) (*http.Transport, error) {
|
||||
// 使用自定义值或默认值
|
||||
maxIdleConns := opts.MaxIdleConns
|
||||
if maxIdleConns <= 0 {
|
||||
maxIdleConns = defaultMaxIdleConns
|
||||
}
|
||||
maxIdleConnsPerHost := opts.MaxIdleConnsPerHost
|
||||
if maxIdleConnsPerHost <= 0 {
|
||||
maxIdleConnsPerHost = defaultMaxIdleConnsPerHost
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: maxIdleConns,
|
||||
MaxIdleConnsPerHost: maxIdleConnsPerHost,
|
||||
MaxConnsPerHost: opts.MaxConnsPerHost, // 0 表示无限制
|
||||
IdleConnTimeout: defaultIdleConnTimeout,
|
||||
ResponseHeaderTimeout: opts.ResponseHeaderTimeout,
|
||||
}
|
||||
|
||||
if opts.InsecureSkipVerify {
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
|
||||
proxyURL := strings.TrimSpace(opts.ProxyURL)
|
||||
if proxyURL == "" {
|
||||
return transport, nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := proxyutil.ConfigureTransportProxy(transport, parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return transport, nil
|
||||
}
|
||||
|
||||
func buildClientKey(opts Options) string {
|
||||
return fmt.Sprintf("%s|%s|%s|%t|%d|%d|%d",
|
||||
strings.TrimSpace(opts.ProxyURL),
|
||||
opts.Timeout.String(),
|
||||
opts.ResponseHeaderTimeout.String(),
|
||||
opts.InsecureSkipVerify,
|
||||
opts.MaxIdleConns,
|
||||
opts.MaxIdleConnsPerHost,
|
||||
opts.MaxConnsPerHost,
|
||||
)
|
||||
}
|
||||
|
||||
62
backend/internal/pkg/proxyutil/dialer.go
Normal file
62
backend/internal/pkg/proxyutil/dialer.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Package proxyutil 提供统一的代理配置功能
|
||||
//
|
||||
// 支持的代理协议:
|
||||
// - HTTP/HTTPS: 通过 Transport.Proxy 设置
|
||||
// - SOCKS5/SOCKS5H: 通过 Transport.DialContext 设置(服务端解析 DNS)
|
||||
package proxyutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// ConfigureTransportProxy 根据代理 URL 配置 Transport
|
||||
//
|
||||
// 支持的协议:
|
||||
// - http/https: 设置 transport.Proxy
|
||||
// - socks5/socks5h: 设置 transport.DialContext(由代理服务端解析 DNS)
|
||||
//
|
||||
// 参数:
|
||||
// - transport: 需要配置的 http.Transport
|
||||
// - proxyURL: 代理地址,nil 表示直连
|
||||
//
|
||||
// 返回:
|
||||
// - error: 代理配置错误(协议不支持或 dialer 创建失败)
|
||||
func ConfigureTransportProxy(transport *http.Transport, proxyURL *url.URL) error {
|
||||
if proxyURL == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(proxyURL.Scheme)
|
||||
switch scheme {
|
||||
case "http", "https":
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
return nil
|
||||
|
||||
case "socks5", "socks5h":
|
||||
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create socks5 dialer: %w", err)
|
||||
}
|
||||
// 优先使用支持 context 的 DialContext,以支持请求取消和超时
|
||||
if contextDialer, ok := dialer.(proxy.ContextDialer); ok {
|
||||
transport.DialContext = contextDialer.DialContext
|
||||
} else {
|
||||
// 回退路径:如果 dialer 不支持 ContextDialer,则包装为简单的 DialContext
|
||||
// 注意:此回退不支持请求取消和超时控制
|
||||
transport.DialContext = func(_ context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported proxy scheme: %s", scheme)
|
||||
}
|
||||
}
|
||||
204
backend/internal/pkg/proxyutil/dialer_test.go
Normal file
204
backend/internal/pkg/proxyutil/dialer_test.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package proxyutil
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConfigureTransportProxy_Nil(t *testing.T) {
|
||||
transport := &http.Transport{}
|
||||
err := ConfigureTransportProxy(transport, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, transport.Proxy, "nil proxy should not set Proxy")
|
||||
assert.Nil(t, transport.DialContext, "nil proxy should not set DialContext")
|
||||
}
|
||||
|
||||
func TestConfigureTransportProxy_HTTP(t *testing.T) {
|
||||
transport := &http.Transport{}
|
||||
proxyURL, _ := url.Parse("http://proxy.example.com:8080")
|
||||
|
||||
err := ConfigureTransportProxy(transport, proxyURL)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, transport.Proxy, "HTTP proxy should set Proxy")
|
||||
assert.Nil(t, transport.DialContext, "HTTP proxy should not set DialContext")
|
||||
}
|
||||
|
||||
func TestConfigureTransportProxy_HTTPS(t *testing.T) {
|
||||
transport := &http.Transport{}
|
||||
proxyURL, _ := url.Parse("https://secure-proxy.example.com:8443")
|
||||
|
||||
err := ConfigureTransportProxy(transport, proxyURL)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, transport.Proxy, "HTTPS proxy should set Proxy")
|
||||
assert.Nil(t, transport.DialContext, "HTTPS proxy should not set DialContext")
|
||||
}
|
||||
|
||||
func TestConfigureTransportProxy_SOCKS5(t *testing.T) {
|
||||
transport := &http.Transport{}
|
||||
proxyURL, _ := url.Parse("socks5://socks.example.com:1080")
|
||||
|
||||
err := ConfigureTransportProxy(transport, proxyURL)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, transport.Proxy, "SOCKS5 proxy should not set Proxy")
|
||||
assert.NotNil(t, transport.DialContext, "SOCKS5 proxy should set DialContext")
|
||||
}
|
||||
|
||||
func TestConfigureTransportProxy_SOCKS5H(t *testing.T) {
|
||||
transport := &http.Transport{}
|
||||
proxyURL, _ := url.Parse("socks5h://socks.example.com:1080")
|
||||
|
||||
err := ConfigureTransportProxy(transport, proxyURL)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, transport.Proxy, "SOCKS5H proxy should not set Proxy")
|
||||
assert.NotNil(t, transport.DialContext, "SOCKS5H proxy should set DialContext")
|
||||
}
|
||||
|
||||
func TestConfigureTransportProxy_CaseInsensitive(t *testing.T) {
|
||||
testCases := []struct {
|
||||
scheme string
|
||||
useProxy bool // true = uses Transport.Proxy, false = uses DialContext
|
||||
}{
|
||||
{"HTTP://proxy.example.com:8080", true},
|
||||
{"Http://proxy.example.com:8080", true},
|
||||
{"HTTPS://proxy.example.com:8443", true},
|
||||
{"Https://proxy.example.com:8443", true},
|
||||
{"SOCKS5://socks.example.com:1080", false},
|
||||
{"Socks5://socks.example.com:1080", false},
|
||||
{"SOCKS5H://socks.example.com:1080", false},
|
||||
{"Socks5h://socks.example.com:1080", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.scheme, func(t *testing.T) {
|
||||
transport := &http.Transport{}
|
||||
proxyURL, _ := url.Parse(tc.scheme)
|
||||
|
||||
err := ConfigureTransportProxy(transport, proxyURL)
|
||||
|
||||
require.NoError(t, err)
|
||||
if tc.useProxy {
|
||||
assert.NotNil(t, transport.Proxy)
|
||||
assert.Nil(t, transport.DialContext)
|
||||
} else {
|
||||
assert.Nil(t, transport.Proxy)
|
||||
assert.NotNil(t, transport.DialContext)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureTransportProxy_Unsupported(t *testing.T) {
|
||||
testCases := []string{
|
||||
"ftp://ftp.example.com",
|
||||
"file:///path/to/file",
|
||||
"unknown://example.com",
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc, func(t *testing.T) {
|
||||
transport := &http.Transport{}
|
||||
proxyURL, _ := url.Parse(tc)
|
||||
|
||||
err := ConfigureTransportProxy(transport, proxyURL)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported proxy scheme")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureTransportProxy_WithAuth(t *testing.T) {
|
||||
transport := &http.Transport{}
|
||||
proxyURL, _ := url.Parse("socks5://user:password@socks.example.com:1080")
|
||||
|
||||
err := ConfigureTransportProxy(transport, proxyURL)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, transport.DialContext, "SOCKS5 with auth should set DialContext")
|
||||
}
|
||||
|
||||
func TestConfigureTransportProxy_EmptyScheme(t *testing.T) {
|
||||
transport := &http.Transport{}
|
||||
// 空 scheme 的 URL
|
||||
proxyURL := &url.URL{Host: "proxy.example.com:8080"}
|
||||
|
||||
err := ConfigureTransportProxy(transport, proxyURL)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported proxy scheme")
|
||||
}
|
||||
|
||||
func TestConfigureTransportProxy_PreservesExistingConfig(t *testing.T) {
|
||||
// 验证代理配置不会覆盖 Transport 的其他配置
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
}
|
||||
proxyURL, _ := url.Parse("socks5://socks.example.com:1080")
|
||||
|
||||
err := ConfigureTransportProxy(transport, proxyURL)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 100, transport.MaxIdleConns, "MaxIdleConns should be preserved")
|
||||
assert.Equal(t, 10, transport.MaxIdleConnsPerHost, "MaxIdleConnsPerHost should be preserved")
|
||||
assert.NotNil(t, transport.DialContext, "DialContext should be set")
|
||||
}
|
||||
|
||||
func TestConfigureTransportProxy_IPv6(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
proxyURL string
|
||||
}{
|
||||
{"SOCKS5H with IPv6 loopback", "socks5h://[::1]:1080"},
|
||||
{"SOCKS5 with full IPv6", "socks5://[2001:db8::1]:1080"},
|
||||
{"HTTP with IPv6", "http://[::1]:8080"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
transport := &http.Transport{}
|
||||
proxyURL, err := url.Parse(tc.proxyURL)
|
||||
require.NoError(t, err, "URL should be parseable")
|
||||
|
||||
err = ConfigureTransportProxy(transport, proxyURL)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureTransportProxy_SpecialCharsInPassword(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
proxyURL string
|
||||
}{
|
||||
// 密码包含 @ 符号(URL 编码为 %40)
|
||||
{"password with @", "socks5://user:p%40ssword@proxy.example.com:1080"},
|
||||
// 密码包含 : 符号(URL 编码为 %3A)
|
||||
{"password with :", "socks5://user:pass%3Aword@proxy.example.com:1080"},
|
||||
// 密码包含 / 符号(URL 编码为 %2F)
|
||||
{"password with /", "socks5://user:pass%2Fword@proxy.example.com:1080"},
|
||||
// 复杂密码
|
||||
{"complex password", "socks5h://admin:P%40ss%3Aw0rd%2F123@proxy.example.com:1080"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
transport := &http.Transport{}
|
||||
proxyURL, err := url.Parse(tc.proxyURL)
|
||||
require.NoError(t, err, "URL should be parseable")
|
||||
|
||||
err = ConfigureTransportProxy(transport, proxyURL)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, transport.DialContext, "SOCKS5 should set DialContext")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,251 +1,257 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
|
||||
func NewClaudeOAuthClient() service.ClaudeOAuthClient {
|
||||
return &claudeOAuthService{
|
||||
baseURL: "https://claude.ai",
|
||||
tokenURL: oauth.TokenURL,
|
||||
clientFactory: createReqClient,
|
||||
}
|
||||
}
|
||||
|
||||
type claudeOAuthService struct {
|
||||
baseURL string
|
||||
tokenURL string
|
||||
clientFactory func(proxyURL string) *req.Client
|
||||
}
|
||||
|
||||
func (s *claudeOAuthService) GetOrganizationUUID(ctx context.Context, sessionKey, proxyURL string) (string, error) {
|
||||
client := s.clientFactory(proxyURL)
|
||||
|
||||
var orgs []struct {
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
|
||||
targetURL := s.baseURL + "/api/organizations"
|
||||
log.Printf("[OAuth] Step 1: Getting organization UUID from %s", targetURL)
|
||||
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetCookies(&http.Cookie{
|
||||
Name: "sessionKey",
|
||||
Value: sessionKey,
|
||||
}).
|
||||
SetSuccessResult(&orgs).
|
||||
Get(targetURL)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[OAuth] Step 1 FAILED - Request error: %v", err)
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[OAuth] Step 1 Response - Status: %d, Body: %s", resp.StatusCode, resp.String())
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
return "", fmt.Errorf("failed to get organizations: status %d, body: %s", resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
if len(orgs) == 0 {
|
||||
return "", fmt.Errorf("no organizations found")
|
||||
}
|
||||
|
||||
log.Printf("[OAuth] Step 1 SUCCESS - Got org UUID: %s", orgs[0].UUID)
|
||||
return orgs[0].UUID, nil
|
||||
}
|
||||
|
||||
func (s *claudeOAuthService) GetAuthorizationCode(ctx context.Context, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL string) (string, error) {
|
||||
client := s.clientFactory(proxyURL)
|
||||
|
||||
authURL := fmt.Sprintf("%s/v1/oauth/%s/authorize", s.baseURL, orgUUID)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"response_type": "code",
|
||||
"client_id": oauth.ClientID,
|
||||
"organization_uuid": orgUUID,
|
||||
"redirect_uri": oauth.RedirectURI,
|
||||
"scope": scope,
|
||||
"state": state,
|
||||
"code_challenge": codeChallenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
|
||||
reqBodyJSON, _ := json.Marshal(reqBody)
|
||||
log.Printf("[OAuth] Step 2: Getting authorization code from %s", authURL)
|
||||
log.Printf("[OAuth] Step 2 Request Body: %s", string(reqBodyJSON))
|
||||
|
||||
var result struct {
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetCookies(&http.Cookie{
|
||||
Name: "sessionKey",
|
||||
Value: sessionKey,
|
||||
}).
|
||||
SetHeader("Accept", "application/json").
|
||||
SetHeader("Accept-Language", "en-US,en;q=0.9").
|
||||
SetHeader("Cache-Control", "no-cache").
|
||||
SetHeader("Origin", "https://claude.ai").
|
||||
SetHeader("Referer", "https://claude.ai/new").
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(reqBody).
|
||||
SetSuccessResult(&result).
|
||||
Post(authURL)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[OAuth] Step 2 FAILED - Request error: %v", err)
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[OAuth] Step 2 Response - Status: %d, Body: %s", resp.StatusCode, resp.String())
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
return "", fmt.Errorf("failed to get authorization code: status %d, body: %s", resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
if result.RedirectURI == "" {
|
||||
return "", fmt.Errorf("no redirect_uri in response")
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(result.RedirectURI)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse redirect_uri: %w", err)
|
||||
}
|
||||
|
||||
queryParams := parsedURL.Query()
|
||||
authCode := queryParams.Get("code")
|
||||
responseState := queryParams.Get("state")
|
||||
|
||||
if authCode == "" {
|
||||
return "", fmt.Errorf("no authorization code in redirect_uri")
|
||||
}
|
||||
|
||||
fullCode := authCode
|
||||
if responseState != "" {
|
||||
fullCode = authCode + "#" + responseState
|
||||
}
|
||||
|
||||
log.Printf("[OAuth] Step 2 SUCCESS - Got authorization code: %s...", prefix(authCode, 20))
|
||||
return fullCode, nil
|
||||
}
|
||||
|
||||
func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error) {
|
||||
client := s.clientFactory(proxyURL)
|
||||
|
||||
// Parse code which may contain state in format "authCode#state"
|
||||
authCode := code
|
||||
codeState := ""
|
||||
if idx := strings.Index(code, "#"); idx != -1 {
|
||||
authCode = code[:idx]
|
||||
codeState = code[idx+1:]
|
||||
}
|
||||
|
||||
reqBody := map[string]any{
|
||||
"code": authCode,
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": oauth.ClientID,
|
||||
"redirect_uri": oauth.RedirectURI,
|
||||
"code_verifier": codeVerifier,
|
||||
}
|
||||
|
||||
if codeState != "" {
|
||||
reqBody["state"] = codeState
|
||||
}
|
||||
|
||||
// Setup token requires longer expiration (1 year)
|
||||
if isSetupToken {
|
||||
reqBody["expires_in"] = 31536000 // 365 * 24 * 60 * 60 seconds
|
||||
}
|
||||
|
||||
reqBodyJSON, _ := json.Marshal(reqBody)
|
||||
log.Printf("[OAuth] Step 3: Exchanging code for token at %s", s.tokenURL)
|
||||
log.Printf("[OAuth] Step 3 Request Body: %s", string(reqBodyJSON))
|
||||
|
||||
var tokenResp oauth.TokenResponse
|
||||
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(reqBody).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(s.tokenURL)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[OAuth] Step 3 FAILED - Request error: %v", err)
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[OAuth] Step 3 Response - Status: %d, Body: %s", resp.StatusCode, resp.String())
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
return nil, fmt.Errorf("token exchange failed: status %d, body: %s", resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
log.Printf("[OAuth] Step 3 SUCCESS - Got access token")
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) {
|
||||
client := s.clientFactory(proxyURL)
|
||||
|
||||
// 使用 JSON 格式(与 ExchangeCodeForToken 保持一致)
|
||||
// Anthropic OAuth API 期望 JSON 格式的请求体
|
||||
reqBody := map[string]any{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refreshToken,
|
||||
"client_id": oauth.ClientID,
|
||||
}
|
||||
|
||||
var tokenResp oauth.TokenResponse
|
||||
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(reqBody).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(s.tokenURL)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
return nil, fmt.Errorf("token refresh failed: status %d, body: %s", resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
func createReqClient(proxyURL string) *req.Client {
|
||||
return getSharedReqClient(reqClientOptions{
|
||||
ProxyURL: proxyURL,
|
||||
Timeout: 60 * time.Second,
|
||||
Impersonate: true,
|
||||
})
|
||||
}
|
||||
|
||||
func prefix(s string, n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
|
||||
func NewClaudeOAuthClient() service.ClaudeOAuthClient {
|
||||
return &claudeOAuthService{
|
||||
baseURL: "https://claude.ai",
|
||||
tokenURL: oauth.TokenURL,
|
||||
clientFactory: createReqClient,
|
||||
}
|
||||
}
|
||||
|
||||
type claudeOAuthService struct {
|
||||
baseURL string
|
||||
tokenURL string
|
||||
clientFactory func(proxyURL string) *req.Client
|
||||
}
|
||||
|
||||
func (s *claudeOAuthService) GetOrganizationUUID(ctx context.Context, sessionKey, proxyURL string) (string, error) {
|
||||
client := s.clientFactory(proxyURL)
|
||||
|
||||
var orgs []struct {
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
|
||||
targetURL := s.baseURL + "/api/organizations"
|
||||
log.Printf("[OAuth] Step 1: Getting organization UUID from %s", targetURL)
|
||||
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetCookies(&http.Cookie{
|
||||
Name: "sessionKey",
|
||||
Value: sessionKey,
|
||||
}).
|
||||
SetSuccessResult(&orgs).
|
||||
Get(targetURL)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[OAuth] Step 1 FAILED - Request error: %v", err)
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[OAuth] Step 1 Response - Status: %d, Body: %s", resp.StatusCode, resp.String())
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
return "", fmt.Errorf("failed to get organizations: status %d, body: %s", resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
if len(orgs) == 0 {
|
||||
return "", fmt.Errorf("no organizations found")
|
||||
}
|
||||
|
||||
log.Printf("[OAuth] Step 1 SUCCESS - Got org UUID: %s", orgs[0].UUID)
|
||||
return orgs[0].UUID, nil
|
||||
}
|
||||
|
||||
func (s *claudeOAuthService) GetAuthorizationCode(ctx context.Context, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL string) (string, error) {
|
||||
client := s.clientFactory(proxyURL)
|
||||
|
||||
authURL := fmt.Sprintf("%s/v1/oauth/%s/authorize", s.baseURL, orgUUID)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"response_type": "code",
|
||||
"client_id": oauth.ClientID,
|
||||
"organization_uuid": orgUUID,
|
||||
"redirect_uri": oauth.RedirectURI,
|
||||
"scope": scope,
|
||||
"state": state,
|
||||
"code_challenge": codeChallenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
|
||||
reqBodyJSON, _ := json.Marshal(reqBody)
|
||||
log.Printf("[OAuth] Step 2: Getting authorization code from %s", authURL)
|
||||
log.Printf("[OAuth] Step 2 Request Body: %s", string(reqBodyJSON))
|
||||
|
||||
var result struct {
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetCookies(&http.Cookie{
|
||||
Name: "sessionKey",
|
||||
Value: sessionKey,
|
||||
}).
|
||||
SetHeader("Accept", "application/json").
|
||||
SetHeader("Accept-Language", "en-US,en;q=0.9").
|
||||
SetHeader("Cache-Control", "no-cache").
|
||||
SetHeader("Origin", "https://claude.ai").
|
||||
SetHeader("Referer", "https://claude.ai/new").
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(reqBody).
|
||||
SetSuccessResult(&result).
|
||||
Post(authURL)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[OAuth] Step 2 FAILED - Request error: %v", err)
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[OAuth] Step 2 Response - Status: %d, Body: %s", resp.StatusCode, resp.String())
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
return "", fmt.Errorf("failed to get authorization code: status %d, body: %s", resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
if result.RedirectURI == "" {
|
||||
return "", fmt.Errorf("no redirect_uri in response")
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(result.RedirectURI)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse redirect_uri: %w", err)
|
||||
}
|
||||
|
||||
queryParams := parsedURL.Query()
|
||||
authCode := queryParams.Get("code")
|
||||
responseState := queryParams.Get("state")
|
||||
|
||||
if authCode == "" {
|
||||
return "", fmt.Errorf("no authorization code in redirect_uri")
|
||||
}
|
||||
|
||||
fullCode := authCode
|
||||
if responseState != "" {
|
||||
fullCode = authCode + "#" + responseState
|
||||
}
|
||||
|
||||
log.Printf("[OAuth] Step 2 SUCCESS - Got authorization code: %s...", prefix(authCode, 20))
|
||||
return fullCode, nil
|
||||
}
|
||||
|
||||
func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error) {
|
||||
client := s.clientFactory(proxyURL)
|
||||
|
||||
// Parse code which may contain state in format "authCode#state"
|
||||
authCode := code
|
||||
codeState := ""
|
||||
if idx := strings.Index(code, "#"); idx != -1 {
|
||||
authCode = code[:idx]
|
||||
codeState = code[idx+1:]
|
||||
}
|
||||
|
||||
reqBody := map[string]any{
|
||||
"code": authCode,
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": oauth.ClientID,
|
||||
"redirect_uri": oauth.RedirectURI,
|
||||
"code_verifier": codeVerifier,
|
||||
}
|
||||
|
||||
if codeState != "" {
|
||||
reqBody["state"] = codeState
|
||||
}
|
||||
|
||||
// Setup token requires longer expiration (1 year)
|
||||
if isSetupToken {
|
||||
reqBody["expires_in"] = 31536000 // 365 * 24 * 60 * 60 seconds
|
||||
}
|
||||
|
||||
reqBodyJSON, _ := json.Marshal(reqBody)
|
||||
log.Printf("[OAuth] Step 3: Exchanging code for token at %s", s.tokenURL)
|
||||
log.Printf("[OAuth] Step 3 Request Body: %s", string(reqBodyJSON))
|
||||
|
||||
var tokenResp oauth.TokenResponse
|
||||
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(reqBody).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(s.tokenURL)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[OAuth] Step 3 FAILED - Request error: %v", err)
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[OAuth] Step 3 Response - Status: %d, Body: %s", resp.StatusCode, resp.String())
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
return nil, fmt.Errorf("token exchange failed: status %d, body: %s", resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
log.Printf("[OAuth] Step 3 SUCCESS - Got access token")
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) {
|
||||
client := s.clientFactory(proxyURL)
|
||||
|
||||
// 使用 JSON 格式(与 ExchangeCodeForToken 保持一致)
|
||||
// Anthropic OAuth API 期望 JSON 格式的请求体
|
||||
reqBody := map[string]any{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refreshToken,
|
||||
"client_id": oauth.ClientID,
|
||||
}
|
||||
|
||||
var tokenResp oauth.TokenResponse
|
||||
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(reqBody).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(s.tokenURL)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccessState() {
|
||||
return nil, fmt.Errorf("token refresh failed: status %d, body: %s", resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
func createReqClient(proxyURL string) *req.Client {
|
||||
// 禁用 CookieJar,确保每次授权都是干净的会话
|
||||
client := req.C().
|
||||
SetTimeout(60 * time.Second).
|
||||
ImpersonateChrome().
|
||||
SetCookieJar(nil) // 禁用 CookieJar
|
||||
|
||||
if strings.TrimSpace(proxyURL) != "" {
|
||||
client.SetProxyURL(strings.TrimSpace(proxyURL))
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func prefix(s string, n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,66 +1,70 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
)
|
||||
|
||||
// httpClientSink 用于防止编译器优化掉基准测试中的赋值操作
|
||||
// 这是 Go 基准测试的常见模式,确保测试结果准确
|
||||
var httpClientSink *http.Client
|
||||
|
||||
// BenchmarkHTTPUpstreamProxyClient 对比重复创建与复用代理客户端的开销
|
||||
//
|
||||
// 测试目的:
|
||||
// - 验证连接池复用相比每次新建的性能提升
|
||||
// - 量化内存分配差异
|
||||
//
|
||||
// 预期结果:
|
||||
// - "复用" 子测试应显著快于 "新建"
|
||||
// - "复用" 子测试应零内存分配
|
||||
func BenchmarkHTTPUpstreamProxyClient(b *testing.B) {
|
||||
// 创建测试配置
|
||||
cfg := &config.Config{
|
||||
Gateway: config.GatewayConfig{ResponseHeaderTimeout: 300},
|
||||
}
|
||||
upstream := NewHTTPUpstream(cfg)
|
||||
svc, ok := upstream.(*httpUpstreamService)
|
||||
if !ok {
|
||||
b.Fatalf("类型断言失败,无法获取 httpUpstreamService")
|
||||
}
|
||||
|
||||
proxyURL := "http://127.0.0.1:8080"
|
||||
b.ReportAllocs() // 报告内存分配统计
|
||||
|
||||
// 子测试:每次新建客户端
|
||||
// 模拟未优化前的行为,每次请求都创建新的 http.Client
|
||||
b.Run("新建", func(b *testing.B) {
|
||||
parsedProxy, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
b.Fatalf("解析代理地址失败: %v", err)
|
||||
}
|
||||
settings := defaultPoolSettings(cfg)
|
||||
for i := 0; i < b.N; i++ {
|
||||
// 每次迭代都创建新客户端,包含 Transport 分配
|
||||
httpClientSink = &http.Client{
|
||||
Transport: buildUpstreamTransport(settings, parsedProxy),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 子测试:复用已缓存的客户端
|
||||
// 模拟优化后的行为,从缓存获取客户端
|
||||
b.Run("复用", func(b *testing.B) {
|
||||
// 预热:确保客户端已缓存
|
||||
entry := svc.getOrCreateClient(proxyURL, 1, 1)
|
||||
client := entry.client
|
||||
b.ResetTimer() // 重置计时器,排除预热时间
|
||||
for i := 0; i < b.N; i++ {
|
||||
// 直接使用缓存的客户端,无内存分配
|
||||
httpClientSink = client
|
||||
}
|
||||
})
|
||||
}
|
||||
package repository
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
)
|
||||
|
||||
// httpClientSink 用于防止编译器优化掉基准测试中的赋值操作
|
||||
// 这是 Go 基准测试的常见模式,确保测试结果准确
|
||||
var httpClientSink *http.Client
|
||||
|
||||
// BenchmarkHTTPUpstreamProxyClient 对比重复创建与复用代理客户端的开销
|
||||
//
|
||||
// 测试目的:
|
||||
// - 验证连接池复用相比每次新建的性能提升
|
||||
// - 量化内存分配差异
|
||||
//
|
||||
// 预期结果:
|
||||
// - "复用" 子测试应显著快于 "新建"
|
||||
// - "复用" 子测试应零内存分配
|
||||
func BenchmarkHTTPUpstreamProxyClient(b *testing.B) {
|
||||
// 创建测试配置
|
||||
cfg := &config.Config{
|
||||
Gateway: config.GatewayConfig{ResponseHeaderTimeout: 300},
|
||||
}
|
||||
upstream := NewHTTPUpstream(cfg)
|
||||
svc, ok := upstream.(*httpUpstreamService)
|
||||
if !ok {
|
||||
b.Fatalf("类型断言失败,无法获取 httpUpstreamService")
|
||||
}
|
||||
|
||||
proxyURL := "http://127.0.0.1:8080"
|
||||
b.ReportAllocs() // 报告内存分配统计
|
||||
|
||||
// 子测试:每次新建客户端
|
||||
// 模拟未优化前的行为,每次请求都创建新的 http.Client
|
||||
b.Run("新建", func(b *testing.B) {
|
||||
parsedProxy, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
b.Fatalf("解析代理地址失败: %v", err)
|
||||
}
|
||||
settings := defaultPoolSettings(cfg)
|
||||
for i := 0; i < b.N; i++ {
|
||||
// 每次迭代都创建新客户端,包含 Transport 分配
|
||||
transport, err := buildUpstreamTransport(settings, parsedProxy)
|
||||
if err != nil {
|
||||
b.Fatalf("创建 Transport 失败: %v", err)
|
||||
}
|
||||
httpClientSink = &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 子测试:复用已缓存的客户端
|
||||
// 模拟优化后的行为,从缓存获取客户端
|
||||
b.Run("复用", func(b *testing.B) {
|
||||
// 预热:确保客户端已缓存
|
||||
entry := svc.getOrCreateClient(proxyURL, 1, 1)
|
||||
client := entry.client
|
||||
b.ResetTimer() // 重置计时器,排除预热时间
|
||||
for i := 0; i < b.N; i++ {
|
||||
// 直接使用缓存的客户端,无内存分配
|
||||
httpClientSink = client
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,76 +1,75 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
func NewProxyExitInfoProber() service.ProxyExitInfoProber {
|
||||
return &proxyProbeService{ipInfoURL: defaultIPInfoURL}
|
||||
}
|
||||
|
||||
const defaultIPInfoURL = "https://ipinfo.io/json"
|
||||
|
||||
type proxyProbeService struct {
|
||||
ipInfoURL string
|
||||
}
|
||||
|
||||
func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*service.ProxyExitInfo, int64, error) {
|
||||
client, err := httpclient.GetClient(httpclient.Options{
|
||||
ProxyURL: proxyURL,
|
||||
Timeout: 15 * time.Second,
|
||||
InsecureSkipVerify: true,
|
||||
ProxyStrict: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to create proxy client: %w", err)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", s.ipInfoURL, nil)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("proxy connection failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
latencyMs := time.Since(startTime).Milliseconds()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, latencyMs, fmt.Errorf("request failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var ipInfo struct {
|
||||
IP string `json:"ip"`
|
||||
City string `json:"city"`
|
||||
Region string `json:"region"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, latencyMs, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &ipInfo); err != nil {
|
||||
return nil, latencyMs, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
return &service.ProxyExitInfo{
|
||||
IP: ipInfo.IP,
|
||||
City: ipInfo.City,
|
||||
Region: ipInfo.Region,
|
||||
Country: ipInfo.Country,
|
||||
}, latencyMs, nil
|
||||
}
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
func NewProxyExitInfoProber() service.ProxyExitInfoProber {
|
||||
return &proxyProbeService{ipInfoURL: defaultIPInfoURL}
|
||||
}
|
||||
|
||||
const defaultIPInfoURL = "https://ipinfo.io/json"
|
||||
|
||||
type proxyProbeService struct {
|
||||
ipInfoURL string
|
||||
}
|
||||
|
||||
func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*service.ProxyExitInfo, int64, error) {
|
||||
client, err := httpclient.GetClient(httpclient.Options{
|
||||
ProxyURL: proxyURL,
|
||||
Timeout: 15 * time.Second,
|
||||
InsecureSkipVerify: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to create proxy client: %w", err)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", s.ipInfoURL, nil)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("proxy connection failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
latencyMs := time.Since(startTime).Milliseconds()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, latencyMs, fmt.Errorf("request failed with status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var ipInfo struct {
|
||||
IP string `json:"ip"`
|
||||
City string `json:"city"`
|
||||
Region string `json:"region"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, latencyMs, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &ipInfo); err != nil {
|
||||
return nil, latencyMs, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
return &service.ProxyExitInfo{
|
||||
IP: ipInfo.IP,
|
||||
City: ipInfo.City,
|
||||
Region: ipInfo.Region,
|
||||
Country: ipInfo.Country,
|
||||
}, latencyMs, nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
233
backend/internal/service/gateway_prompt_test.go
Normal file
233
backend/internal/service/gateway_prompt_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsClaudeCodeClient(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
userAgent string
|
||||
metadataUserID string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Claude Code client",
|
||||
userAgent: "claude-cli/1.0.62 (darwin; arm64)",
|
||||
metadataUserID: "session_123e4567-e89b-12d3-a456-426614174000",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Claude Code without version suffix",
|
||||
userAgent: "claude-cli/2.0.0",
|
||||
metadataUserID: "session_abc",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Missing metadata user_id",
|
||||
userAgent: "claude-cli/1.0.0",
|
||||
metadataUserID: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Different user agent",
|
||||
userAgent: "curl/7.68.0",
|
||||
metadataUserID: "user123",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Empty user agent",
|
||||
userAgent: "",
|
||||
metadataUserID: "user123",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Similar but not Claude CLI",
|
||||
userAgent: "claude-api/1.0.0",
|
||||
metadataUserID: "user123",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isClaudeCodeClient(tt.userAgent, tt.metadataUserID)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemIncludesClaudeCodePrompt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
system any
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil system",
|
||||
system: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
system: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "string with Claude Code prompt",
|
||||
system: claudeCodeSystemPrompt,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "string with different content",
|
||||
system: "You are a helpful assistant.",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty array",
|
||||
system: []any{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "array with Claude Code prompt",
|
||||
system: []any{
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": claudeCodeSystemPrompt,
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "array with Claude Code prompt in second position",
|
||||
system: []any{
|
||||
map[string]any{"type": "text", "text": "First prompt"},
|
||||
map[string]any{"type": "text", "text": claudeCodeSystemPrompt},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "array without Claude Code prompt",
|
||||
system: []any{
|
||||
map[string]any{"type": "text", "text": "Custom prompt"},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "array with partial match (should not match)",
|
||||
system: []any{
|
||||
map[string]any{"type": "text", "text": "You are Claude"},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := systemIncludesClaudeCodePrompt(tt.system)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectClaudeCodePrompt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
system any
|
||||
wantSystemLen int
|
||||
wantFirstText string
|
||||
wantSecondText string
|
||||
}{
|
||||
{
|
||||
name: "nil system",
|
||||
body: `{"model":"claude-3"}`,
|
||||
system: nil,
|
||||
wantSystemLen: 1,
|
||||
wantFirstText: claudeCodeSystemPrompt,
|
||||
},
|
||||
{
|
||||
name: "empty string system",
|
||||
body: `{"model":"claude-3"}`,
|
||||
system: "",
|
||||
wantSystemLen: 1,
|
||||
wantFirstText: claudeCodeSystemPrompt,
|
||||
},
|
||||
{
|
||||
name: "string system",
|
||||
body: `{"model":"claude-3"}`,
|
||||
system: "Custom prompt",
|
||||
wantSystemLen: 2,
|
||||
wantFirstText: claudeCodeSystemPrompt,
|
||||
wantSecondText: "Custom prompt",
|
||||
},
|
||||
{
|
||||
name: "string system equals Claude Code prompt",
|
||||
body: `{"model":"claude-3"}`,
|
||||
system: claudeCodeSystemPrompt,
|
||||
wantSystemLen: 1,
|
||||
wantFirstText: claudeCodeSystemPrompt,
|
||||
},
|
||||
{
|
||||
name: "array system",
|
||||
body: `{"model":"claude-3"}`,
|
||||
system: []any{map[string]any{"type": "text", "text": "Custom"}},
|
||||
// Claude Code + Custom = 2
|
||||
wantSystemLen: 2,
|
||||
wantFirstText: claudeCodeSystemPrompt,
|
||||
wantSecondText: "Custom",
|
||||
},
|
||||
{
|
||||
name: "array system with existing Claude Code prompt (should dedupe)",
|
||||
body: `{"model":"claude-3"}`,
|
||||
system: []any{
|
||||
map[string]any{"type": "text", "text": claudeCodeSystemPrompt},
|
||||
map[string]any{"type": "text", "text": "Other"},
|
||||
},
|
||||
// Claude Code at start + Other = 2 (deduped)
|
||||
wantSystemLen: 2,
|
||||
wantFirstText: claudeCodeSystemPrompt,
|
||||
wantSecondText: "Other",
|
||||
},
|
||||
{
|
||||
name: "empty array",
|
||||
body: `{"model":"claude-3"}`,
|
||||
system: []any{},
|
||||
wantSystemLen: 1,
|
||||
wantFirstText: claudeCodeSystemPrompt,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := injectClaudeCodePrompt([]byte(tt.body), tt.system)
|
||||
|
||||
var parsed map[string]any
|
||||
err := json.Unmarshal(result, &parsed)
|
||||
require.NoError(t, err)
|
||||
|
||||
system, ok := parsed["system"].([]any)
|
||||
require.True(t, ok, "system should be an array")
|
||||
require.Len(t, system, tt.wantSystemLen)
|
||||
|
||||
first, ok := system[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, tt.wantFirstText, first["text"])
|
||||
require.Equal(t, "text", first["type"])
|
||||
|
||||
// Check cache_control
|
||||
cc, ok := first["cache_control"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "ephemeral", cc["type"])
|
||||
|
||||
if tt.wantSecondText != "" && len(system) > 1 {
|
||||
second, ok := system[1].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, tt.wantSecondText, second["text"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,444 +1,452 @@
|
||||
<template>
|
||||
<div class="relative" ref="containerRef">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggle"
|
||||
:class="['date-picker-trigger', isOpen && 'date-picker-trigger-open']"
|
||||
>
|
||||
<span class="date-picker-icon">
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="date-picker-value">
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
<span class="date-picker-chevron">
|
||||
<svg
|
||||
:class="['h-4 w-4 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition name="date-picker-dropdown">
|
||||
<div v-if="isOpen" class="date-picker-dropdown">
|
||||
<!-- Quick presets -->
|
||||
<div class="date-picker-presets">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset.value"
|
||||
@click="selectPreset(preset)"
|
||||
:class="['date-picker-preset', isPresetActive(preset) && 'date-picker-preset-active']"
|
||||
>
|
||||
{{ t(preset.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="date-picker-divider"></div>
|
||||
|
||||
<!-- Custom date range inputs -->
|
||||
<div class="date-picker-custom">
|
||||
<div class="date-picker-field">
|
||||
<label class="date-picker-label">{{ t('dates.startDate') }}</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="localStartDate"
|
||||
:max="localEndDate || today"
|
||||
class="date-picker-input"
|
||||
@change="onDateChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="date-picker-separator">
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="date-picker-field">
|
||||
<label class="date-picker-label">{{ t('dates.endDate') }}</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="localEndDate"
|
||||
:min="localStartDate"
|
||||
:max="today"
|
||||
class="date-picker-input"
|
||||
@change="onDateChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apply button -->
|
||||
<div class="date-picker-actions">
|
||||
<button @click="apply" class="date-picker-apply">
|
||||
{{ t('dates.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface DatePreset {
|
||||
labelKey: string
|
||||
value: string
|
||||
getRange: () => { start: string; end: string }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
startDate: string
|
||||
endDate: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:startDate', value: string): void
|
||||
(e: 'update:endDate', value: string): void
|
||||
(e: 'change', range: { startDate: string; endDate: string; preset: string | null }): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const localStartDate = ref(props.startDate)
|
||||
const localEndDate = ref(props.endDate)
|
||||
const activePreset = ref<string | null>('7days')
|
||||
|
||||
const today = computed(() => {
|
||||
// Use local timezone to avoid UTC timezone issues
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
})
|
||||
|
||||
// Helper function to format date to YYYY-MM-DD using local timezone
|
||||
const formatDateToString = (date: Date): string => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const presets: DatePreset[] = [
|
||||
{
|
||||
labelKey: 'dates.today',
|
||||
value: 'today',
|
||||
getRange: () => {
|
||||
const t = today.value
|
||||
return { start: t, end: t }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.yesterday',
|
||||
value: 'yesterday',
|
||||
getRange: () => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 1)
|
||||
const yesterday = formatDateToString(d)
|
||||
return { start: yesterday, end: yesterday }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.last7Days',
|
||||
value: '7days',
|
||||
getRange: () => {
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 6)
|
||||
const start = formatDateToString(d)
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.last14Days',
|
||||
value: '14days',
|
||||
getRange: () => {
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 13)
|
||||
const start = formatDateToString(d)
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.last30Days',
|
||||
value: '30days',
|
||||
getRange: () => {
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 29)
|
||||
const start = formatDateToString(d)
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.thisMonth',
|
||||
value: 'thisMonth',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const start = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 1))
|
||||
return { start, end: today.value }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.lastMonth',
|
||||
value: 'lastMonth',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const start = formatDateToString(new Date(now.getFullYear(), now.getMonth() - 1, 1))
|
||||
const end = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 0))
|
||||
return { start, end }
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (activePreset.value) {
|
||||
const preset = presets.find((p) => p.value === activePreset.value)
|
||||
if (preset) return t(preset.labelKey)
|
||||
}
|
||||
|
||||
if (localStartDate.value && localEndDate.value) {
|
||||
if (localStartDate.value === localEndDate.value) {
|
||||
return formatDate(localStartDate.value)
|
||||
}
|
||||
return `${formatDate(localStartDate.value)} - ${formatDate(localEndDate.value)}`
|
||||
}
|
||||
|
||||
return t('dates.selectDateRange')
|
||||
})
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
const dateLocale = locale.value === 'zh' ? 'zh-CN' : 'en-US'
|
||||
return date.toLocaleDateString(dateLocale, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
const isPresetActive = (preset: DatePreset): boolean => {
|
||||
return activePreset.value === preset.value
|
||||
}
|
||||
|
||||
const selectPreset = (preset: DatePreset) => {
|
||||
const range = preset.getRange()
|
||||
localStartDate.value = range.start
|
||||
localEndDate.value = range.end
|
||||
activePreset.value = preset.value
|
||||
}
|
||||
|
||||
const onDateChange = () => {
|
||||
// Check if current dates match any preset
|
||||
activePreset.value = null
|
||||
for (const preset of presets) {
|
||||
const range = preset.getRange()
|
||||
if (range.start === localStartDate.value && range.end === localEndDate.value) {
|
||||
activePreset.value = preset.value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
emit('update:startDate', localStartDate.value)
|
||||
emit('update:endDate', localEndDate.value)
|
||||
emit('change', {
|
||||
startDate: localStartDate.value,
|
||||
endDate: localEndDate.value,
|
||||
preset: activePreset.value
|
||||
})
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen.value) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Sync local state with props
|
||||
watch(
|
||||
() => props.startDate,
|
||||
(val) => {
|
||||
localStartDate.value = val
|
||||
onDateChange()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.endDate,
|
||||
(val) => {
|
||||
localEndDate.value = val
|
||||
onDateChange()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
// Initialize active preset detection
|
||||
onDateChange()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.date-picker-trigger {
|
||||
@apply flex items-center gap-2;
|
||||
@apply rounded-lg px-3 py-2 text-sm;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
@apply transition-all duration-200;
|
||||
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
||||
@apply hover:border-gray-300 dark:hover:border-dark-500;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.date-picker-trigger-open {
|
||||
@apply border-primary-500 ring-2 ring-primary-500/30;
|
||||
}
|
||||
|
||||
.date-picker-icon {
|
||||
@apply text-gray-400 dark:text-dark-400;
|
||||
}
|
||||
|
||||
.date-picker-value {
|
||||
@apply font-medium;
|
||||
}
|
||||
|
||||
.date-picker-chevron {
|
||||
@apply text-gray-400 dark:text-dark-400;
|
||||
}
|
||||
|
||||
.date-picker-dropdown {
|
||||
@apply absolute left-0 z-[100] mt-2;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-xl;
|
||||
@apply border border-gray-200 dark:border-dark-700;
|
||||
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
|
||||
@apply overflow-hidden;
|
||||
@apply min-w-[320px];
|
||||
}
|
||||
|
||||
.date-picker-presets {
|
||||
@apply grid grid-cols-2 gap-1 p-2;
|
||||
}
|
||||
|
||||
.date-picker-preset {
|
||||
@apply rounded-md px-3 py-1.5 text-xs font-medium;
|
||||
@apply text-gray-600 dark:text-gray-400;
|
||||
@apply hover:bg-gray-100 dark:hover:bg-dark-700;
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
|
||||
.date-picker-preset-active {
|
||||
@apply bg-primary-100 dark:bg-primary-900/30;
|
||||
@apply text-primary-700 dark:text-primary-300;
|
||||
}
|
||||
|
||||
.date-picker-divider {
|
||||
@apply border-t border-gray-100 dark:border-dark-700;
|
||||
}
|
||||
|
||||
.date-picker-custom {
|
||||
@apply flex items-end gap-2 p-3;
|
||||
}
|
||||
|
||||
.date-picker-field {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.date-picker-label {
|
||||
@apply mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.date-picker-input {
|
||||
@apply w-full rounded-md px-2 py-1.5 text-sm;
|
||||
@apply bg-gray-50 dark:bg-dark-700;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
||||
}
|
||||
|
||||
.date-picker-input::-webkit-calendar-picker-indicator {
|
||||
@apply cursor-pointer opacity-60 hover:opacity-100;
|
||||
filter: invert(0.5);
|
||||
}
|
||||
|
||||
.dark .date-picker-input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(0.7);
|
||||
}
|
||||
|
||||
.date-picker-separator {
|
||||
@apply flex items-center justify-center pb-1;
|
||||
}
|
||||
|
||||
.date-picker-actions {
|
||||
@apply flex justify-end p-2 pt-0;
|
||||
}
|
||||
|
||||
.date-picker-apply {
|
||||
@apply rounded-lg px-4 py-1.5 text-sm font-medium;
|
||||
@apply bg-primary-600 text-white;
|
||||
@apply hover:bg-primary-700;
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
|
||||
/* Dropdown animation */
|
||||
.date-picker-dropdown-enter-active,
|
||||
.date-picker-dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.date-picker-dropdown-enter-from,
|
||||
.date-picker-dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="relative" ref="containerRef">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggle"
|
||||
:class="['date-picker-trigger', isOpen && 'date-picker-trigger-open']"
|
||||
>
|
||||
<span class="date-picker-icon">
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="date-picker-value">
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
<span class="date-picker-chevron">
|
||||
<svg
|
||||
:class="['h-4 w-4 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition name="date-picker-dropdown">
|
||||
<div v-if="isOpen" class="date-picker-dropdown">
|
||||
<!-- Quick presets -->
|
||||
<div class="date-picker-presets">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset.value"
|
||||
@click="selectPreset(preset)"
|
||||
:class="['date-picker-preset', isPresetActive(preset) && 'date-picker-preset-active']"
|
||||
>
|
||||
{{ t(preset.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="date-picker-divider"></div>
|
||||
|
||||
<!-- Custom date range inputs -->
|
||||
<div class="date-picker-custom">
|
||||
<div class="date-picker-field">
|
||||
<label class="date-picker-label">{{ t('dates.startDate') }}</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="localStartDate"
|
||||
:max="localEndDate || tomorrow"
|
||||
class="date-picker-input"
|
||||
@change="onDateChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="date-picker-separator">
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="date-picker-field">
|
||||
<label class="date-picker-label">{{ t('dates.endDate') }}</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="localEndDate"
|
||||
:min="localStartDate"
|
||||
:max="tomorrow"
|
||||
class="date-picker-input"
|
||||
@change="onDateChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apply button -->
|
||||
<div class="date-picker-actions">
|
||||
<button @click="apply" class="date-picker-apply">
|
||||
{{ t('dates.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface DatePreset {
|
||||
labelKey: string
|
||||
value: string
|
||||
getRange: () => { start: string; end: string }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
startDate: string
|
||||
endDate: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:startDate', value: string): void
|
||||
(e: 'update:endDate', value: string): void
|
||||
(e: 'change', range: { startDate: string; endDate: string; preset: string | null }): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const localStartDate = ref(props.startDate)
|
||||
const localEndDate = ref(props.endDate)
|
||||
const activePreset = ref<string | null>('7days')
|
||||
|
||||
const today = computed(() => {
|
||||
// Use local timezone to avoid UTC timezone issues
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
})
|
||||
|
||||
// Tomorrow's date - used for max date to handle timezone differences
|
||||
// When user is in a timezone behind the server, "today" on server might be "tomorrow" locally
|
||||
const tomorrow = computed(() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 1)
|
||||
return formatDateToString(d)
|
||||
})
|
||||
|
||||
// Helper function to format date to YYYY-MM-DD using local timezone
|
||||
const formatDateToString = (date: Date): string => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const presets: DatePreset[] = [
|
||||
{
|
||||
labelKey: 'dates.today',
|
||||
value: 'today',
|
||||
getRange: () => {
|
||||
const t = today.value
|
||||
return { start: t, end: t }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.yesterday',
|
||||
value: 'yesterday',
|
||||
getRange: () => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 1)
|
||||
const yesterday = formatDateToString(d)
|
||||
return { start: yesterday, end: yesterday }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.last7Days',
|
||||
value: '7days',
|
||||
getRange: () => {
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 6)
|
||||
const start = formatDateToString(d)
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.last14Days',
|
||||
value: '14days',
|
||||
getRange: () => {
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 13)
|
||||
const start = formatDateToString(d)
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.last30Days',
|
||||
value: '30days',
|
||||
getRange: () => {
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 29)
|
||||
const start = formatDateToString(d)
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.thisMonth',
|
||||
value: 'thisMonth',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const start = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 1))
|
||||
return { start, end: today.value }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.lastMonth',
|
||||
value: 'lastMonth',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const start = formatDateToString(new Date(now.getFullYear(), now.getMonth() - 1, 1))
|
||||
const end = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 0))
|
||||
return { start, end }
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (activePreset.value) {
|
||||
const preset = presets.find((p) => p.value === activePreset.value)
|
||||
if (preset) return t(preset.labelKey)
|
||||
}
|
||||
|
||||
if (localStartDate.value && localEndDate.value) {
|
||||
if (localStartDate.value === localEndDate.value) {
|
||||
return formatDate(localStartDate.value)
|
||||
}
|
||||
return `${formatDate(localStartDate.value)} - ${formatDate(localEndDate.value)}`
|
||||
}
|
||||
|
||||
return t('dates.selectDateRange')
|
||||
})
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
const dateLocale = locale.value === 'zh' ? 'zh-CN' : 'en-US'
|
||||
return date.toLocaleDateString(dateLocale, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
const isPresetActive = (preset: DatePreset): boolean => {
|
||||
return activePreset.value === preset.value
|
||||
}
|
||||
|
||||
const selectPreset = (preset: DatePreset) => {
|
||||
const range = preset.getRange()
|
||||
localStartDate.value = range.start
|
||||
localEndDate.value = range.end
|
||||
activePreset.value = preset.value
|
||||
}
|
||||
|
||||
const onDateChange = () => {
|
||||
// Check if current dates match any preset
|
||||
activePreset.value = null
|
||||
for (const preset of presets) {
|
||||
const range = preset.getRange()
|
||||
if (range.start === localStartDate.value && range.end === localEndDate.value) {
|
||||
activePreset.value = preset.value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
emit('update:startDate', localStartDate.value)
|
||||
emit('update:endDate', localEndDate.value)
|
||||
emit('change', {
|
||||
startDate: localStartDate.value,
|
||||
endDate: localEndDate.value,
|
||||
preset: activePreset.value
|
||||
})
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen.value) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Sync local state with props
|
||||
watch(
|
||||
() => props.startDate,
|
||||
(val) => {
|
||||
localStartDate.value = val
|
||||
onDateChange()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.endDate,
|
||||
(val) => {
|
||||
localEndDate.value = val
|
||||
onDateChange()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
// Initialize active preset detection
|
||||
onDateChange()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.date-picker-trigger {
|
||||
@apply flex items-center gap-2;
|
||||
@apply rounded-lg px-3 py-2 text-sm;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
@apply transition-all duration-200;
|
||||
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
||||
@apply hover:border-gray-300 dark:hover:border-dark-500;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.date-picker-trigger-open {
|
||||
@apply border-primary-500 ring-2 ring-primary-500/30;
|
||||
}
|
||||
|
||||
.date-picker-icon {
|
||||
@apply text-gray-400 dark:text-dark-400;
|
||||
}
|
||||
|
||||
.date-picker-value {
|
||||
@apply font-medium;
|
||||
}
|
||||
|
||||
.date-picker-chevron {
|
||||
@apply text-gray-400 dark:text-dark-400;
|
||||
}
|
||||
|
||||
.date-picker-dropdown {
|
||||
@apply absolute left-0 z-[100] mt-2;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-xl;
|
||||
@apply border border-gray-200 dark:border-dark-700;
|
||||
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
|
||||
@apply overflow-hidden;
|
||||
@apply min-w-[320px];
|
||||
}
|
||||
|
||||
.date-picker-presets {
|
||||
@apply grid grid-cols-2 gap-1 p-2;
|
||||
}
|
||||
|
||||
.date-picker-preset {
|
||||
@apply rounded-md px-3 py-1.5 text-xs font-medium;
|
||||
@apply text-gray-600 dark:text-gray-400;
|
||||
@apply hover:bg-gray-100 dark:hover:bg-dark-700;
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
|
||||
.date-picker-preset-active {
|
||||
@apply bg-primary-100 dark:bg-primary-900/30;
|
||||
@apply text-primary-700 dark:text-primary-300;
|
||||
}
|
||||
|
||||
.date-picker-divider {
|
||||
@apply border-t border-gray-100 dark:border-dark-700;
|
||||
}
|
||||
|
||||
.date-picker-custom {
|
||||
@apply flex items-end gap-2 p-3;
|
||||
}
|
||||
|
||||
.date-picker-field {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.date-picker-label {
|
||||
@apply mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.date-picker-input {
|
||||
@apply w-full rounded-md px-2 py-1.5 text-sm;
|
||||
@apply bg-gray-50 dark:bg-dark-700;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
||||
}
|
||||
|
||||
.date-picker-input::-webkit-calendar-picker-indicator {
|
||||
@apply cursor-pointer opacity-60 hover:opacity-100;
|
||||
filter: invert(0.5);
|
||||
}
|
||||
|
||||
.dark .date-picker-input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(0.7);
|
||||
}
|
||||
|
||||
.date-picker-separator {
|
||||
@apply flex items-center justify-center pb-1;
|
||||
}
|
||||
|
||||
.date-picker-actions {
|
||||
@apply flex justify-end p-2 pt-0;
|
||||
}
|
||||
|
||||
.date-picker-apply {
|
||||
@apply rounded-lg px-4 py-1.5 text-sm font-medium;
|
||||
@apply bg-primary-600 text-white;
|
||||
@apply hover:bg-primary-700;
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
|
||||
/* Dropdown animation */
|
||||
.date-picker-dropdown-enter-active,
|
||||
.date-picker-dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.date-picker-dropdown-enter-from,
|
||||
.date-picker-dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user