diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 6b5ffad4..1e14b1c4 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -67,7 +67,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userHandler := handler.NewUserHandler(userService) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageLogRepository := repository.NewUsageLogRepository(client, db) - dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db) usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator) usageHandler := handler.NewUsageHandler(usageService, apiKeyService) redeemCodeRepository := repository.NewRedeemCodeRepository(client) @@ -76,15 +75,17 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator) redeemHandler := handler.NewRedeemHandler(redeemService) subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService) + dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db) dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig) + dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig) timingWheelService := service.ProvideTimingWheelService() dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, configConfig) - dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig) dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService) accountRepository := repository.NewAccountRepository(client, db) proxyRepository := repository.NewProxyRepository(client, db) proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) - adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, billingCacheService, proxyExitInfoProber, apiKeyAuthCacheInvalidator) + proxyLatencyCache := repository.NewProxyLatencyCache(redisClient) + adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator) adminUserHandler := admin.NewUserHandler(adminService) groupHandler := admin.NewGroupHandler(adminService) claudeOAuthClient := repository.NewClaudeOAuthClient() @@ -112,9 +113,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig) concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig) concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig) - schedulerCache := repository.NewSchedulerCache(redisClient) - schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db) - schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService) oAuthHandler := admin.NewOAuthHandler(oAuthService) @@ -125,6 +123,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { adminRedeemHandler := admin.NewRedeemHandler(adminService) promoHandler := admin.NewPromoHandler(promoService) opsRepository := repository.NewOpsRepository(db) + schedulerCache := repository.NewSchedulerCache(redisClient) + schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db) + schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig) pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig) pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient) if err != nil { diff --git a/backend/internal/handler/admin/proxy_handler.go b/backend/internal/handler/admin/proxy_handler.go index 437e9300..a6758f69 100644 --- a/backend/internal/handler/admin/proxy_handler.go +++ b/backend/internal/handler/admin/proxy_handler.go @@ -196,6 +196,28 @@ func (h *ProxyHandler) Delete(c *gin.Context) { response.Success(c, gin.H{"message": "Proxy deleted successfully"}) } +// BatchDelete handles batch deleting proxies +// POST /api/v1/admin/proxies/batch-delete +func (h *ProxyHandler) BatchDelete(c *gin.Context) { + type BatchDeleteRequest struct { + IDs []int64 `json:"ids" binding:"required,min=1"` + } + + var req BatchDeleteRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + result, err := h.adminService.BatchDeleteProxies(c.Request.Context(), req.IDs) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, result) +} + // Test handles testing proxy connectivity // POST /api/v1/admin/proxies/:id/test func (h *ProxyHandler) Test(c *gin.Context) { @@ -243,19 +265,17 @@ func (h *ProxyHandler) GetProxyAccounts(c *gin.Context) { return } - page, pageSize := response.ParsePagination(c) - - accounts, total, err := h.adminService.GetProxyAccounts(c.Request.Context(), proxyID, page, pageSize) + accounts, err := h.adminService.GetProxyAccounts(c.Request.Context(), proxyID) if err != nil { response.ErrorFrom(c, err) return } - out := make([]dto.Account, 0, len(accounts)) + out := make([]dto.ProxyAccountSummary, 0, len(accounts)) for i := range accounts { - out = append(out, *dto.AccountFromService(&accounts[i])) + out = append(out, *dto.ProxyAccountSummaryFromService(&accounts[i])) } - response.Paginated(c, out, total, page, pageSize) + response.Success(c, out) } // BatchCreateProxyItem represents a single proxy in batch create request diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 6ffaedea..aa3053be 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -212,8 +212,24 @@ func ProxyWithAccountCountFromService(p *service.ProxyWithAccountCount) *ProxyWi return nil } return &ProxyWithAccountCount{ - Proxy: *ProxyFromService(&p.Proxy), - AccountCount: p.AccountCount, + Proxy: *ProxyFromService(&p.Proxy), + AccountCount: p.AccountCount, + LatencyMs: p.LatencyMs, + LatencyStatus: p.LatencyStatus, + LatencyMessage: p.LatencyMessage, + } +} + +func ProxyAccountSummaryFromService(a *service.ProxyAccountSummary) *ProxyAccountSummary { + if a == nil { + return nil + } + return &ProxyAccountSummary{ + ID: a.ID, + Name: a.Name, + Platform: a.Platform, + Type: a.Type, + Notes: a.Notes, } } diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index a9b010b9..2d61eb0e 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -129,7 +129,18 @@ type Proxy struct { type ProxyWithAccountCount struct { Proxy - AccountCount int64 `json:"account_count"` + AccountCount int64 `json:"account_count"` + LatencyMs *int64 `json:"latency_ms,omitempty"` + LatencyStatus string `json:"latency_status,omitempty"` + LatencyMessage string `json:"latency_message,omitempty"` +} + +type ProxyAccountSummary struct { + ID int64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + Type string `json:"type"` + Notes *string `json:"notes,omitempty"` } type RedeemCode struct { diff --git a/backend/internal/repository/proxy_latency_cache.go b/backend/internal/repository/proxy_latency_cache.go new file mode 100644 index 00000000..4458b5e1 --- /dev/null +++ b/backend/internal/repository/proxy_latency_cache.go @@ -0,0 +1,74 @@ +package repository + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/redis/go-redis/v9" +) + +const proxyLatencyKeyPrefix = "proxy:latency:" + +func proxyLatencyKey(proxyID int64) string { + return fmt.Sprintf("%s%d", proxyLatencyKeyPrefix, proxyID) +} + +type proxyLatencyCache struct { + rdb *redis.Client +} + +func NewProxyLatencyCache(rdb *redis.Client) service.ProxyLatencyCache { + return &proxyLatencyCache{rdb: rdb} +} + +func (c *proxyLatencyCache) GetProxyLatencies(ctx context.Context, proxyIDs []int64) (map[int64]*service.ProxyLatencyInfo, error) { + results := make(map[int64]*service.ProxyLatencyInfo) + if len(proxyIDs) == 0 { + return results, nil + } + + keys := make([]string, 0, len(proxyIDs)) + for _, id := range proxyIDs { + keys = append(keys, proxyLatencyKey(id)) + } + + values, err := c.rdb.MGet(ctx, keys...).Result() + if err != nil { + return results, err + } + + for i, raw := range values { + if raw == nil { + continue + } + var payload []byte + switch v := raw.(type) { + case string: + payload = []byte(v) + case []byte: + payload = v + default: + continue + } + var info service.ProxyLatencyInfo + if err := json.Unmarshal(payload, &info); err != nil { + continue + } + results[proxyIDs[i]] = &info + } + + return results, nil +} + +func (c *proxyLatencyCache) SetProxyLatency(ctx context.Context, proxyID int64, info *service.ProxyLatencyInfo) error { + if info == nil { + return nil + } + payload, err := json.Marshal(info) + if err != nil { + return err + } + return c.rdb.Set(ctx, proxyLatencyKey(proxyID), payload, 0).Err() +} diff --git a/backend/internal/repository/proxy_probe_service.go b/backend/internal/repository/proxy_probe_service.go index 5c42e4d1..ad7b6e1c 100644 --- a/backend/internal/repository/proxy_probe_service.go +++ b/backend/internal/repository/proxy_probe_service.go @@ -34,7 +34,10 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { } } -const defaultIPInfoURL = "https://ipinfo.io/json" +const ( + defaultIPInfoURL = "https://ipinfo.io/json" + defaultProxyProbeTimeout = 30 * time.Second +) type proxyProbeService struct { ipInfoURL string @@ -46,7 +49,7 @@ type proxyProbeService struct { 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, + Timeout: defaultProxyProbeTimeout, InsecureSkipVerify: s.insecureSkipVerify, ProxyStrict: true, ValidateResolvedIP: s.validateResolvedIP, diff --git a/backend/internal/repository/proxy_repo.go b/backend/internal/repository/proxy_repo.go index 622b0aeb..36965c05 100644 --- a/backend/internal/repository/proxy_repo.go +++ b/backend/internal/repository/proxy_repo.go @@ -219,12 +219,54 @@ func (r *proxyRepository) ExistsByHostPortAuth(ctx context.Context, host string, // CountAccountsByProxyID returns the number of accounts using a specific proxy func (r *proxyRepository) CountAccountsByProxyID(ctx context.Context, proxyID int64) (int64, error) { var count int64 - if err := scanSingleRow(ctx, r.sql, "SELECT COUNT(*) FROM accounts WHERE proxy_id = $1", []any{proxyID}, &count); err != nil { + if err := scanSingleRow(ctx, r.sql, "SELECT COUNT(*) FROM accounts WHERE proxy_id = $1 AND deleted_at IS NULL", []any{proxyID}, &count); err != nil { return 0, err } return count, nil } +func (r *proxyRepository) ListAccountSummariesByProxyID(ctx context.Context, proxyID int64) ([]service.ProxyAccountSummary, error) { + rows, err := r.sql.QueryContext(ctx, ` + SELECT id, name, platform, type, notes + FROM accounts + WHERE proxy_id = $1 AND deleted_at IS NULL + ORDER BY id DESC + `, proxyID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + out := make([]service.ProxyAccountSummary, 0) + for rows.Next() { + var ( + id int64 + name string + platform string + accType string + notes sql.NullString + ) + if err := rows.Scan(&id, &name, &platform, &accType, ¬es); err != nil { + return nil, err + } + var notesPtr *string + if notes.Valid { + notesPtr = ¬es.String + } + out = append(out, service.ProxyAccountSummary{ + ID: id, + Name: name, + Platform: platform, + Type: accType, + Notes: notesPtr, + }) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + // GetAccountCountsForProxies returns a map of proxy ID to account count for all proxies func (r *proxyRepository) GetAccountCountsForProxies(ctx context.Context) (counts map[int64]int64, err error) { rows, err := r.sql.QueryContext(ctx, "SELECT proxy_id, COUNT(*) AS count FROM accounts WHERE proxy_id IS NOT NULL AND deleted_at IS NULL GROUP BY proxy_id") diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 45a8f182..91ef9413 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -69,6 +69,7 @@ var ProviderSet = wire.NewSet( NewGeminiTokenCache, NewSchedulerCache, NewSchedulerOutboxRepository, + NewProxyLatencyCache, // HTTP service ports (DI Strategy A: return interface directly) NewTurnstileVerifier, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index d96732bd..d6cd5c14 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -262,11 +262,11 @@ func TestAPIContracts(t *testing.T) { name: "GET /api/v1/admin/settings", setup: func(t *testing.T, deps *contractDeps) { t.Helper() - deps.settingRepo.SetAll(map[string]string{ - service.SettingKeyRegistrationEnabled: "true", - service.SettingKeyEmailVerifyEnabled: "false", + deps.settingRepo.SetAll(map[string]string{ + service.SettingKeyRegistrationEnabled: "true", + service.SettingKeyEmailVerifyEnabled: "false", - service.SettingKeySMTPHost: "smtp.example.com", + service.SettingKeySMTPHost: "smtp.example.com", service.SettingKeySMTPPort: "587", service.SettingKeySMTPUsername: "user", service.SettingKeySMTPPassword: "secret", @@ -285,15 +285,15 @@ func TestAPIContracts(t *testing.T) { service.SettingKeyContactInfo: "support", service.SettingKeyDocURL: "https://docs.example.com", - service.SettingKeyDefaultConcurrency: "5", - service.SettingKeyDefaultBalance: "1.25", + service.SettingKeyDefaultConcurrency: "5", + service.SettingKeyDefaultBalance: "1.25", - service.SettingKeyOpsMonitoringEnabled: "false", - service.SettingKeyOpsRealtimeMonitoringEnabled: "true", - service.SettingKeyOpsQueryModeDefault: "auto", - service.SettingKeyOpsMetricsIntervalSeconds: "60", - }) - }, + service.SettingKeyOpsMonitoringEnabled: "false", + service.SettingKeyOpsRealtimeMonitoringEnabled: "true", + service.SettingKeyOpsQueryModeDefault: "auto", + service.SettingKeyOpsMetricsIntervalSeconds: "60", + }) + }, method: http.MethodGet, path: "/api/v1/admin/settings", wantStatus: http.StatusOK, @@ -435,7 +435,7 @@ func newContractDeps(t *testing.T) *contractDeps { settingRepo := newStubSettingRepo() settingService := service.NewSettingService(settingRepo, cfg) - adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil) + adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil) authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService) diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 9bb019bb..f2ee05e0 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -250,6 +250,7 @@ func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) { proxies.POST("/:id/test", h.Admin.Proxy.Test) proxies.GET("/:id/stats", h.Admin.Proxy.GetStats) proxies.GET("/:id/accounts", h.Admin.Proxy.GetProxyAccounts) + proxies.POST("/batch-delete", h.Admin.Proxy.BatchDelete) proxies.POST("/batch", h.Admin.Proxy.BatchCreate) } } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 1874c5c1..808d0e7d 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -54,7 +54,8 @@ type AdminService interface { CreateProxy(ctx context.Context, input *CreateProxyInput) (*Proxy, error) UpdateProxy(ctx context.Context, id int64, input *UpdateProxyInput) (*Proxy, error) DeleteProxy(ctx context.Context, id int64) error - GetProxyAccounts(ctx context.Context, proxyID int64, page, pageSize int) ([]Account, int64, error) + BatchDeleteProxies(ctx context.Context, ids []int64) (*ProxyBatchDeleteResult, error) + GetProxyAccounts(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) CheckProxyExists(ctx context.Context, host string, port int, username, password string) (bool, error) TestProxy(ctx context.Context, id int64) (*ProxyTestResult, error) @@ -220,6 +221,16 @@ type GenerateRedeemCodesInput struct { ValidityDays int // 订阅类型专用:有效天数 } +type ProxyBatchDeleteResult struct { + DeletedIDs []int64 `json:"deleted_ids"` + Skipped []ProxyBatchDeleteSkipped `json:"skipped"` +} + +type ProxyBatchDeleteSkipped struct { + ID int64 `json:"id"` + Reason string `json:"reason"` +} + // ProxyTestResult represents the result of testing a proxy type ProxyTestResult struct { Success bool `json:"success"` @@ -254,6 +265,7 @@ type adminServiceImpl struct { redeemCodeRepo RedeemCodeRepository billingCacheService *BillingCacheService proxyProber ProxyExitInfoProber + proxyLatencyCache ProxyLatencyCache authCacheInvalidator APIKeyAuthCacheInvalidator } @@ -267,6 +279,7 @@ func NewAdminService( redeemCodeRepo RedeemCodeRepository, billingCacheService *BillingCacheService, proxyProber ProxyExitInfoProber, + proxyLatencyCache ProxyLatencyCache, authCacheInvalidator APIKeyAuthCacheInvalidator, ) AdminService { return &adminServiceImpl{ @@ -278,6 +291,7 @@ func NewAdminService( redeemCodeRepo: redeemCodeRepo, billingCacheService: billingCacheService, proxyProber: proxyProber, + proxyLatencyCache: proxyLatencyCache, authCacheInvalidator: authCacheInvalidator, } } @@ -1069,6 +1083,7 @@ func (s *adminServiceImpl) ListProxiesWithAccountCount(ctx context.Context, page if err != nil { return nil, 0, err } + s.attachProxyLatency(ctx, proxies) return proxies, result.Total, nil } @@ -1077,7 +1092,12 @@ func (s *adminServiceImpl) GetAllProxies(ctx context.Context) ([]Proxy, error) { } func (s *adminServiceImpl) GetAllProxiesWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) { - return s.proxyRepo.ListActiveWithAccountCount(ctx) + proxies, err := s.proxyRepo.ListActiveWithAccountCount(ctx) + if err != nil { + return nil, err + } + s.attachProxyLatency(ctx, proxies) + return proxies, nil } func (s *adminServiceImpl) GetProxy(ctx context.Context, id int64) (*Proxy, error) { @@ -1097,6 +1117,8 @@ func (s *adminServiceImpl) CreateProxy(ctx context.Context, input *CreateProxyIn if err := s.proxyRepo.Create(ctx, proxy); err != nil { return nil, err } + // Probe latency asynchronously so creation isn't blocked by network timeout. + go s.probeProxyLatency(context.Background(), proxy) return proxy, nil } @@ -1135,12 +1157,53 @@ func (s *adminServiceImpl) UpdateProxy(ctx context.Context, id int64, input *Upd } func (s *adminServiceImpl) DeleteProxy(ctx context.Context, id int64) error { + count, err := s.proxyRepo.CountAccountsByProxyID(ctx, id) + if err != nil { + return err + } + if count > 0 { + return ErrProxyInUse + } return s.proxyRepo.Delete(ctx, id) } -func (s *adminServiceImpl) GetProxyAccounts(ctx context.Context, proxyID int64, page, pageSize int) ([]Account, int64, error) { - // Return mock data for now - would need a dedicated repository method - return []Account{}, 0, nil +func (s *adminServiceImpl) BatchDeleteProxies(ctx context.Context, ids []int64) (*ProxyBatchDeleteResult, error) { + result := &ProxyBatchDeleteResult{} + if len(ids) == 0 { + return result, nil + } + + for _, id := range ids { + count, err := s.proxyRepo.CountAccountsByProxyID(ctx, id) + if err != nil { + result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{ + ID: id, + Reason: err.Error(), + }) + continue + } + if count > 0 { + result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{ + ID: id, + Reason: ErrProxyInUse.Error(), + }) + continue + } + if err := s.proxyRepo.Delete(ctx, id); err != nil { + result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{ + ID: id, + Reason: err.Error(), + }) + continue + } + result.DeletedIDs = append(result.DeletedIDs, id) + } + + return result, nil +} + +func (s *adminServiceImpl) GetProxyAccounts(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) { + return s.proxyRepo.ListAccountSummariesByProxyID(ctx, proxyID) } func (s *adminServiceImpl) CheckProxyExists(ctx context.Context, host string, port int, username, password string) (bool, error) { @@ -1240,12 +1303,24 @@ func (s *adminServiceImpl) TestProxy(ctx context.Context, id int64) (*ProxyTestR proxyURL := proxy.URL() exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxyURL) if err != nil { + s.saveProxyLatency(ctx, id, &ProxyLatencyInfo{ + Success: false, + Message: err.Error(), + UpdatedAt: time.Now(), + }) return &ProxyTestResult{ Success: false, Message: err.Error(), }, nil } + latency := latencyMs + s.saveProxyLatency(ctx, id, &ProxyLatencyInfo{ + Success: true, + LatencyMs: &latency, + Message: "Proxy is accessible", + UpdatedAt: time.Now(), + }) return &ProxyTestResult{ Success: true, Message: "Proxy is accessible", @@ -1257,6 +1332,29 @@ func (s *adminServiceImpl) TestProxy(ctx context.Context, id int64) (*ProxyTestR }, nil } +func (s *adminServiceImpl) probeProxyLatency(ctx context.Context, proxy *Proxy) { + if s.proxyProber == nil || proxy == nil { + return + } + _, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxy.URL()) + if err != nil { + s.saveProxyLatency(ctx, proxy.ID, &ProxyLatencyInfo{ + Success: false, + Message: err.Error(), + UpdatedAt: time.Now(), + }) + return + } + + latency := latencyMs + s.saveProxyLatency(ctx, proxy.ID, &ProxyLatencyInfo{ + Success: true, + LatencyMs: &latency, + Message: "Proxy is accessible", + UpdatedAt: time.Now(), + }) +} + // checkMixedChannelRisk 检查分组中是否存在混合渠道(Antigravity + Anthropic) // 如果存在混合,返回错误提示用户确认 func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error { @@ -1306,6 +1404,46 @@ func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAcc return nil } +func (s *adminServiceImpl) attachProxyLatency(ctx context.Context, proxies []ProxyWithAccountCount) { + if s.proxyLatencyCache == nil || len(proxies) == 0 { + return + } + + ids := make([]int64, 0, len(proxies)) + for i := range proxies { + ids = append(ids, proxies[i].ID) + } + + latencies, err := s.proxyLatencyCache.GetProxyLatencies(ctx, ids) + if err != nil { + log.Printf("Warning: load proxy latency cache failed: %v", err) + return + } + + for i := range proxies { + info := latencies[proxies[i].ID] + if info == nil { + continue + } + if info.Success { + proxies[i].LatencyStatus = "success" + proxies[i].LatencyMs = info.LatencyMs + } else { + proxies[i].LatencyStatus = "failed" + } + proxies[i].LatencyMessage = info.Message + } +} + +func (s *adminServiceImpl) saveProxyLatency(ctx context.Context, proxyID int64, info *ProxyLatencyInfo) { + if s.proxyLatencyCache == nil || info == nil { + return + } + if err := s.proxyLatencyCache.SetProxyLatency(ctx, proxyID, info); err != nil { + log.Printf("Warning: store proxy latency cache failed: %v", err) + } +} + // getAccountPlatform 根据账号 platform 判断混合渠道检查用的平台标识 func getAccountPlatform(accountPlatform string) string { switch strings.ToLower(strings.TrimSpace(accountPlatform)) { diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index 31639472..afa433af 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -153,8 +153,10 @@ func (s *groupRepoStub) DeleteAccountGroupsByGroupID(ctx context.Context, groupI } type proxyRepoStub struct { - deleteErr error - deletedIDs []int64 + deleteErr error + countErr error + accountCount int64 + deletedIDs []int64 } func (s *proxyRepoStub) Create(ctx context.Context, proxy *Proxy) error { @@ -199,7 +201,14 @@ func (s *proxyRepoStub) ExistsByHostPortAuth(ctx context.Context, host string, p } func (s *proxyRepoStub) CountAccountsByProxyID(ctx context.Context, proxyID int64) (int64, error) { - panic("unexpected CountAccountsByProxyID call") + if s.countErr != nil { + return 0, s.countErr + } + return s.accountCount, nil +} + +func (s *proxyRepoStub) ListAccountSummariesByProxyID(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) { + panic("unexpected ListAccountSummariesByProxyID call") } type redeemRepoStub struct { @@ -409,6 +418,15 @@ func TestAdminService_DeleteProxy_Idempotent(t *testing.T) { require.Equal(t, []int64{404}, repo.deletedIDs) } +func TestAdminService_DeleteProxy_InUse(t *testing.T) { + repo := &proxyRepoStub{accountCount: 2} + svc := &adminServiceImpl{proxyRepo: repo} + + err := svc.DeleteProxy(context.Background(), 77) + require.ErrorIs(t, err, ErrProxyInUse) + require.Empty(t, repo.deletedIDs) +} + func TestAdminService_DeleteProxy_Error(t *testing.T) { deleteErr := errors.New("delete failed") repo := &proxyRepoStub{deleteErr: deleteErr} diff --git a/backend/internal/service/proxy.go b/backend/internal/service/proxy.go index 768e2a0a..9cb31808 100644 --- a/backend/internal/service/proxy.go +++ b/backend/internal/service/proxy.go @@ -31,5 +31,16 @@ func (p *Proxy) URL() string { type ProxyWithAccountCount struct { Proxy - AccountCount int64 + AccountCount int64 + LatencyMs *int64 + LatencyStatus string + LatencyMessage string +} + +type ProxyAccountSummary struct { + ID int64 + Name string + Platform string + Type string + Notes *string } diff --git a/backend/internal/service/proxy_latency_cache.go b/backend/internal/service/proxy_latency_cache.go new file mode 100644 index 00000000..2901df0b --- /dev/null +++ b/backend/internal/service/proxy_latency_cache.go @@ -0,0 +1,18 @@ +package service + +import ( + "context" + "time" +) + +type ProxyLatencyInfo struct { + Success bool `json:"success"` + LatencyMs *int64 `json:"latency_ms,omitempty"` + Message string `json:"message,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ProxyLatencyCache interface { + GetProxyLatencies(ctx context.Context, proxyIDs []int64) (map[int64]*ProxyLatencyInfo, error) + SetProxyLatency(ctx context.Context, proxyID int64, info *ProxyLatencyInfo) error +} diff --git a/backend/internal/service/proxy_service.go b/backend/internal/service/proxy_service.go index 58408d04..a5d897f6 100644 --- a/backend/internal/service/proxy_service.go +++ b/backend/internal/service/proxy_service.go @@ -10,6 +10,7 @@ import ( var ( ErrProxyNotFound = infraerrors.NotFound("PROXY_NOT_FOUND", "proxy not found") + ErrProxyInUse = infraerrors.Conflict("PROXY_IN_USE", "proxy is in use by accounts") ) type ProxyRepository interface { @@ -26,6 +27,7 @@ type ProxyRepository interface { ExistsByHostPortAuth(ctx context.Context, host string, port int, username, password string) (bool, error) CountAccountsByProxyID(ctx context.Context, proxyID int64) (int64, error) + ListAccountSummariesByProxyID(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) } // CreateProxyRequest 创建代理请求 diff --git a/frontend/src/api/admin/proxies.ts b/frontend/src/api/admin/proxies.ts index fe20a205..fc526f7a 100644 --- a/frontend/src/api/admin/proxies.ts +++ b/frontend/src/api/admin/proxies.ts @@ -4,7 +4,13 @@ */ import { apiClient } from '../client' -import type { Proxy, CreateProxyRequest, UpdateProxyRequest, PaginatedResponse } from '@/types' +import type { + Proxy, + ProxyAccountSummary, + CreateProxyRequest, + UpdateProxyRequest, + PaginatedResponse +} from '@/types' /** * List all proxies with pagination @@ -160,8 +166,8 @@ export async function getStats(id: number): Promise<{ * @param id - Proxy ID * @returns List of accounts using the proxy */ -export async function getProxyAccounts(id: number): Promise> { - const { data } = await apiClient.get>(`/admin/proxies/${id}/accounts`) +export async function getProxyAccounts(id: number): Promise { + const { data } = await apiClient.get(`/admin/proxies/${id}/accounts`) return data } @@ -189,6 +195,17 @@ export async function batchCreate( return data } +export async function batchDelete(ids: number[]): Promise<{ + deleted_ids: number[] + skipped: Array<{ id: number; reason: string }> +}> { + const { data } = await apiClient.post<{ + deleted_ids: number[] + skipped: Array<{ id: number; reason: string }> + }>('/admin/proxies/batch-delete', { ids }) + return data +} + export const proxiesAPI = { list, getAll, @@ -201,7 +218,8 @@ export const proxiesAPI = { testProxy, getStats, getProxyAccounts, - batchCreate + batchCreate, + batchDelete } export default proxiesAPI diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index dc492d36..1c200908 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -22,29 +22,36 @@ ]" @click="column.sortable && handleSort(column.key)" > -
- {{ column.label }} - - - - - - - - -
+ +
+ {{ column.label }} + + + + + + + + +
+
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 3c4fbcc1..df524efb 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1627,11 +1627,29 @@ export default { address: 'Address', status: 'Status', accounts: 'Accounts', + latency: 'Latency', actions: 'Actions' }, testConnection: 'Test Connection', batchTest: 'Test All Proxies', testFailed: 'Failed', + latencyFailed: 'Connection failed', + batchTestEmpty: 'No proxies available for testing', + batchTestDone: 'Batch test completed for {count} proxies', + batchTestFailed: 'Batch test failed', + batchDeleteAction: 'Delete', + batchDelete: 'Batch delete', + batchDeleteConfirm: 'Delete {count} selected proxies? In-use ones will be skipped.', + batchDeleteDone: 'Deleted {deleted} proxies, skipped {skipped}', + batchDeleteSkipped: 'Skipped {skipped} proxies', + batchDeleteFailed: 'Batch delete failed', + deleteBlockedInUse: 'This proxy is in use and cannot be deleted', + accountsTitle: 'Accounts using this IP', + accountsEmpty: 'No accounts are using this proxy', + accountsFailed: 'Failed to load accounts list', + accountName: 'Account', + accountPlatform: 'Platform', + accountNotes: 'Notes', name: 'Name', protocol: 'Protocol', host: 'Host', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 70d2fb35..2b467e6d 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1713,6 +1713,7 @@ export default { address: '地址', status: '状态', accounts: '账号数', + latency: '延迟', actions: '操作', nameLabel: '名称', namePlaceholder: '请输入代理名称', @@ -1749,11 +1750,32 @@ export default { enterProxyName: '请输入代理名称', optionalAuth: '可选认证信息', leaveEmptyToKeep: '留空保持不变', + form: { + hostPlaceholder: '请输入主机地址', + portPlaceholder: '请输入端口' + }, noProxiesYet: '暂无代理', createFirstProxy: '添加您的第一个代理以开始使用。', testConnection: '测试连接', batchTest: '批量测试', testFailed: '失败', + latencyFailed: '链接失败', + batchTestEmpty: '暂无可测试的代理', + batchTestDone: '批量测试完成,共测试 {count} 个代理', + batchTestFailed: '批量测试失败', + batchDeleteAction: '删除', + batchDelete: '批量删除', + batchDeleteConfirm: '确定删除选中的 {count} 个代理吗?已被账号使用的将自动跳过。', + batchDeleteDone: '已删除 {deleted} 个代理,跳过 {skipped} 个', + batchDeleteSkipped: '已跳过 {skipped} 个代理', + batchDeleteFailed: '批量删除失败', + deleteBlockedInUse: '该代理已有账号使用,无法删除', + accountsTitle: '使用该IP的账号', + accountsEmpty: '暂无账号使用此代理', + accountsFailed: '获取账号列表失败', + accountName: '账号名称', + accountPlatform: '所属平台', + accountNotes: '备注', // Batch import standardAdd: '标准添加', batchAdd: '快捷添加', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5eb74596..c9219e22 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -364,10 +364,21 @@ export interface Proxy { password?: string | null status: 'active' | 'inactive' account_count?: number // Number of accounts using this proxy + latency_ms?: number + latency_status?: 'success' | 'failed' + latency_message?: string created_at: string updated_at: string } +export interface ProxyAccountSummary { + id: number + name: string + platform: AccountPlatform + type: AccountType + notes?: string | null +} + // Gemini credentials structure for OAuth and API Key authentication export interface GeminiCredentials { // API Key authentication diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue index 22d778d9..9d876972 100644 --- a/frontend/src/views/admin/ProxiesView.vue +++ b/frontend/src/views/admin/ProxiesView.vue @@ -51,6 +51,24 @@ > + +