From ce9a247a9d8c861c85b641ffc9cb06bc896db7a6 Mon Sep 17 00:00:00 2001 From: LLLLLLiulei <1065070665@qq.com> Date: Thu, 5 Feb 2026 18:23:49 +0800 Subject: [PATCH] feat: add proxy import flow --- .../internal/handler/admin/account_data.go | 7 + .../handler/admin/admin_service_stub_test.go | 16 ++ backend/internal/handler/admin/proxy_data.go | 114 +++++++++++ .../handler/admin/proxy_data_handler_test.go | 79 ++++++++ backend/internal/server/routes/admin.go | 1 + .../integration/proxy-data-import.spec.ts | 70 +++++++ frontend/src/api/admin/proxies.ts | 13 +- .../admin/account/ImportDataModal.vue | 27 ++- .../admin/proxy/ImportDataModal.vue | 183 ++++++++++++++++++ frontend/src/i18n/locales/en.ts | 20 +- frontend/src/i18n/locales/zh.ts | 20 +- frontend/src/views/admin/AccountsView.vue | 24 +++ frontend/src/views/admin/ProxiesView.vue | 16 ++ 13 files changed, 580 insertions(+), 10 deletions(-) create mode 100644 frontend/src/__tests__/integration/proxy-data-import.spec.ts create mode 100644 frontend/src/components/admin/proxy/ImportDataModal.vue diff --git a/backend/internal/handler/admin/account_data.go b/backend/internal/handler/admin/account_data.go index 65ef62ac..6e90c2e5 100644 --- a/backend/internal/handler/admin/account_data.go +++ b/backend/internal/handler/admin/account_data.go @@ -469,6 +469,13 @@ func validateDataProxy(item DataProxy) error { default: return fmt.Errorf("proxy protocol is invalid: %s", item.Protocol) } + if item.Status != "" { + switch item.Status { + case service.StatusActive, service.StatusDisabled, "inactive": + default: + return fmt.Errorf("proxy status is invalid: %s", item.Status) + } + } return nil } diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index b355b5ad..c1256081 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -3,6 +3,7 @@ package admin import ( "context" "strings" + "sync" "time" "github.com/Wei-Shaw/sub2api/internal/service" @@ -18,6 +19,10 @@ type stubAdminService struct { redeems []service.RedeemCode createdAccounts []*service.CreateAccountInput createdProxies []*service.CreateProxyInput + updatedProxyIDs []int64 + updatedProxies []*service.UpdateProxyInput + testedProxyIDs []int64 + mu sync.Mutex } func newStubAdminService() *stubAdminService { @@ -180,7 +185,9 @@ func (s *stubAdminService) GetAccountsByIDs(ctx context.Context, ids []int64) ([ } func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) { + s.mu.Lock() s.createdAccounts = append(s.createdAccounts, input) + s.mu.Unlock() account := service.Account{ID: 300, Name: input.Name, Status: service.StatusActive} return &account, nil } @@ -257,12 +264,18 @@ func (s *stubAdminService) GetProxy(ctx context.Context, id int64) (*service.Pro } func (s *stubAdminService) CreateProxy(ctx context.Context, input *service.CreateProxyInput) (*service.Proxy, error) { + s.mu.Lock() s.createdProxies = append(s.createdProxies, input) + s.mu.Unlock() proxy := service.Proxy{ID: 400, Name: input.Name, Status: service.StatusActive} return &proxy, nil } func (s *stubAdminService) UpdateProxy(ctx context.Context, id int64, input *service.UpdateProxyInput) (*service.Proxy, error) { + s.mu.Lock() + s.updatedProxyIDs = append(s.updatedProxyIDs, id) + s.updatedProxies = append(s.updatedProxies, input) + s.mu.Unlock() proxy := service.Proxy{ID: id, Name: input.Name, Status: service.StatusActive} return &proxy, nil } @@ -284,6 +297,9 @@ func (s *stubAdminService) CheckProxyExists(ctx context.Context, host string, po } func (s *stubAdminService) TestProxy(ctx context.Context, id int64) (*service.ProxyTestResult, error) { + s.mu.Lock() + s.testedProxyIDs = append(s.testedProxyIDs, id) + s.mu.Unlock() return &service.ProxyTestResult{Success: true, Message: "ok"}, nil } diff --git a/backend/internal/handler/admin/proxy_data.go b/backend/internal/handler/admin/proxy_data.go index 5ede58d6..0bcab027 100644 --- a/backend/internal/handler/admin/proxy_data.go +++ b/backend/internal/handler/admin/proxy_data.go @@ -54,6 +54,120 @@ func (h *ProxyHandler) ExportData(c *gin.Context) { response.Success(c, payload) } +// ImportData imports proxy-only data for migration. +func (h *ProxyHandler) ImportData(c *gin.Context) { + type ProxyImportRequest struct { + Data DataPayload `json:"data"` + } + + var req ProxyImportRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + if err := validateDataHeader(req.Data); err != nil { + response.BadRequest(c, err.Error()) + return + } + + ctx := c.Request.Context() + result := DataImportResult{} + + existingProxies, err := h.listProxiesFiltered(ctx, "", "", "") + if err != nil { + response.ErrorFrom(c, err) + return + } + + proxyByKey := make(map[string]service.Proxy, len(existingProxies)) + for i := range existingProxies { + p := existingProxies[i] + key := buildProxyKey(p.Protocol, p.Host, p.Port, p.Username, p.Password) + proxyByKey[key] = p + } + + latencyProbeIDs := make([]int64, 0, len(req.Data.Proxies)) + for i := range req.Data.Proxies { + item := req.Data.Proxies[i] + key := item.ProxyKey + if key == "" { + key = buildProxyKey(item.Protocol, item.Host, item.Port, item.Username, item.Password) + } + + if err := validateDataProxy(item); err != nil { + result.ProxyFailed++ + result.Errors = append(result.Errors, DataImportError{ + Kind: "proxy", + Name: item.Name, + ProxyKey: key, + Message: err.Error(), + }) + continue + } + + if existing, ok := proxyByKey[key]; ok { + result.ProxyReused++ + if item.Status != "" && item.Status != existing.Status { + if _, err := h.adminService.UpdateProxy(ctx, existing.ID, &service.UpdateProxyInput{Status: item.Status}); err != nil { + result.Errors = append(result.Errors, DataImportError{ + Kind: "proxy", + Name: item.Name, + ProxyKey: key, + Message: "update status failed: " + err.Error(), + }) + } + } + latencyProbeIDs = append(latencyProbeIDs, existing.ID) + continue + } + + created, err := h.adminService.CreateProxy(ctx, &service.CreateProxyInput{ + Name: defaultProxyName(item.Name), + Protocol: item.Protocol, + Host: item.Host, + Port: item.Port, + Username: item.Username, + Password: item.Password, + }) + if err != nil { + result.ProxyFailed++ + result.Errors = append(result.Errors, DataImportError{ + Kind: "proxy", + Name: item.Name, + ProxyKey: key, + Message: err.Error(), + }) + continue + } + result.ProxyCreated++ + proxyByKey[key] = *created + + if item.Status != "" && item.Status != created.Status { + if _, err := h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{Status: item.Status}); err != nil { + result.Errors = append(result.Errors, DataImportError{ + Kind: "proxy", + Name: item.Name, + ProxyKey: key, + Message: "update status failed: " + err.Error(), + }) + } + } + latencyProbeIDs = append(latencyProbeIDs, created.ID) + } + + if len(latencyProbeIDs) > 0 { + ids := append([]int64(nil), latencyProbeIDs...) + go func() { + for _, id := range ids { + _, _ = h.adminService.TestProxy(context.Background(), id) + } + }() + } + + response.Success(c, result) +} + func (h *ProxyHandler) listProxiesFiltered(ctx context.Context, protocol, status, search string) ([]service.Proxy, error) { page := 1 pageSize := dataPageCap diff --git a/backend/internal/handler/admin/proxy_data_handler_test.go b/backend/internal/handler/admin/proxy_data_handler_test.go index 21c5dcf6..f7609097 100644 --- a/backend/internal/handler/admin/proxy_data_handler_test.go +++ b/backend/internal/handler/admin/proxy_data_handler_test.go @@ -1,10 +1,12 @@ package admin import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -16,6 +18,11 @@ type proxyDataResponse struct { Data DataPayload `json:"data"` } +type proxyImportResponse struct { + Code int `json:"code"` + Data DataImportResult `json:"data"` +} + func setupProxyDataRouter() (*gin.Engine, *stubAdminService) { gin.SetMode(gin.TestMode) router := gin.New() @@ -23,6 +30,7 @@ func setupProxyDataRouter() (*gin.Engine, *stubAdminService) { h := NewProxyHandler(adminSvc) router.GET("/api/v1/admin/proxies/data", h.ExportData) + router.POST("/api/v1/admin/proxies/data", h.ImportData) return router, adminSvc } @@ -66,3 +74,74 @@ func TestProxyExportDataRespectsFilters(t *testing.T) { require.Len(t, resp.Data.Accounts, 0) require.Equal(t, "https", resp.Data.Proxies[0].Protocol) } + +func TestProxyImportDataReusesAndTriggersLatencyProbe(t *testing.T) { + router, adminSvc := setupProxyDataRouter() + + adminSvc.proxies = []service.Proxy{ + { + ID: 1, + Name: "proxy-a", + Protocol: "http", + Host: "127.0.0.1", + Port: 8080, + Username: "user", + Password: "pass", + Status: service.StatusActive, + }, + } + + payload := map[string]any{ + "data": map[string]any{ + "type": dataType, + "version": dataVersion, + "proxies": []map[string]any{ + { + "proxy_key": "http|127.0.0.1|8080|user|pass", + "name": "proxy-a", + "protocol": "http", + "host": "127.0.0.1", + "port": 8080, + "username": "user", + "password": "pass", + "status": "inactive", + }, + { + "proxy_key": "https|10.0.0.2|443|u|p", + "name": "proxy-b", + "protocol": "https", + "host": "10.0.0.2", + "port": 443, + "username": "u", + "password": "p", + "status": "active", + }, + }, + }, + } + + body, _ := json.Marshal(payload) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/proxies/data", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var resp proxyImportResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Code) + require.Equal(t, 1, resp.Data.ProxyCreated) + require.Equal(t, 1, resp.Data.ProxyReused) + require.Equal(t, 0, resp.Data.ProxyFailed) + + adminSvc.mu.Lock() + updatedIDs := append([]int64(nil), adminSvc.updatedProxyIDs...) + adminSvc.mu.Unlock() + require.Contains(t, updatedIDs, int64(1)) + + require.Eventually(t, func() bool { + adminSvc.mu.Lock() + defer adminSvc.mu.Unlock() + return len(adminSvc.testedProxyIDs) == 2 + }, time.Second, 10*time.Millisecond) +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index accd12a3..3bda4527 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -281,6 +281,7 @@ func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) { proxies.GET("", h.Admin.Proxy.List) proxies.GET("/all", h.Admin.Proxy.GetAll) proxies.GET("/data", h.Admin.Proxy.ExportData) + proxies.POST("/data", h.Admin.Proxy.ImportData) proxies.GET("/:id", h.Admin.Proxy.GetByID) proxies.POST("", h.Admin.Proxy.Create) proxies.PUT("/:id", h.Admin.Proxy.Update) diff --git a/frontend/src/__tests__/integration/proxy-data-import.spec.ts b/frontend/src/__tests__/integration/proxy-data-import.spec.ts new file mode 100644 index 00000000..f0433898 --- /dev/null +++ b/frontend/src/__tests__/integration/proxy-data-import.spec.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import ImportDataModal from '@/components/admin/proxy/ImportDataModal.vue' + +const showError = vi.fn() +const showSuccess = vi.fn() + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showError, + showSuccess + }) +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + proxies: { + importData: vi.fn() + } + } +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key + }) +})) + +describe('Proxy ImportDataModal', () => { + beforeEach(() => { + showError.mockReset() + showSuccess.mockReset() + }) + + it('未选择文件时提示错误', async () => { + const wrapper = mount(ImportDataModal, { + props: { show: true }, + global: { + stubs: { + BaseDialog: { template: '
- {{ fileName }} -