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: '
' } + } + } + }) + + await wrapper.find('form').trigger('submit') + expect(showError).toHaveBeenCalledWith('admin.proxies.dataImportSelectFile') + }) + + it('无效 JSON 时提示解析失败', async () => { + const wrapper = mount(ImportDataModal, { + props: { show: true }, + global: { + stubs: { + BaseDialog: { template: '
' } + } + } + }) + + const input = wrapper.find('input[type="file"]') + const file = new File(['invalid json'], 'data.json', { type: 'application/json' }) + Object.defineProperty(input.element, 'files', { + value: [file] + }) + + await input.trigger('change') + await wrapper.find('form').trigger('submit') + + expect(showError).toHaveBeenCalledWith('admin.proxies.dataImportParseFailed') + }) +}) diff --git a/frontend/src/api/admin/proxies.ts b/frontend/src/api/admin/proxies.ts index b2545a7e..76c96c7d 100644 --- a/frontend/src/api/admin/proxies.ts +++ b/frontend/src/api/admin/proxies.ts @@ -10,7 +10,8 @@ import type { CreateProxyRequest, UpdateProxyRequest, PaginatedResponse, - AdminDataPayload + AdminDataPayload, + AdminDataImportResult } from '@/types' /** @@ -220,6 +221,13 @@ export async function exportData(filters?: { return data } +export async function importData(payload: { + data: AdminDataPayload +}): Promise { + const { data } = await apiClient.post('/admin/proxies/data', payload) + return data +} + export const proxiesAPI = { list, getAll, @@ -234,7 +242,8 @@ export const proxiesAPI = { getProxyAccounts, batchCreate, batchDelete, - exportData + exportData, + importData } export default proxiesAPI diff --git a/frontend/src/components/admin/account/ImportDataModal.vue b/frontend/src/components/admin/account/ImportDataModal.vue index 5b42fe17..0d6de420 100644 --- a/frontend/src/components/admin/account/ImportDataModal.vue +++ b/frontend/src/components/admin/account/ImportDataModal.vue @@ -18,15 +18,26 @@
+
+
+
+ {{ fileName || t('admin.accounts.dataImportSelectFile') }} +
+
JSON (.json)
+
+ +
-

- {{ fileName }} -

(null) const result = ref(null) +const fileInput = ref(null) const fileName = computed(() => file.value?.name || '') const errorItems = computed(() => result.value?.errors || []) @@ -110,10 +122,17 @@ watch( if (open) { file.value = null result.value = null + if (fileInput.value) { + fileInput.value.value = '' + } } } ) +const openFilePicker = () => { + fileInput.value?.click() +} + const handleFileChange = (event: Event) => { const target = event.target as HTMLInputElement file.value = target.files?.[0] || null diff --git a/frontend/src/components/admin/proxy/ImportDataModal.vue b/frontend/src/components/admin/proxy/ImportDataModal.vue new file mode 100644 index 00000000..6999ecc1 --- /dev/null +++ b/frontend/src/components/admin/proxy/ImportDataModal.vue @@ -0,0 +1,183 @@ + + + diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 8a7fb48f..3c407080 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -165,6 +165,7 @@ export default { selectedCount: '({count} selected)', refresh: 'Refresh', settings: 'Settings', + chooseFile: 'Choose File', notAvailable: 'N/A', now: 'Now', unknown: 'Unknown', @@ -1190,7 +1191,7 @@ export default { syncFromCrs: 'Sync from CRS', dataExport: 'Export', dataExportSelected: 'Export Selected', - dataExportIncludeProxies: 'Include proxies (unchecked = no proxy linkage on import)', + dataExportIncludeProxies: 'Include proxies linked to the exported accounts', dataImport: 'Import', dataExportConfirmMessage: 'The exported data contains sensitive account and proxy information. Store it securely.', dataExportConfirm: 'Confirm Export', @@ -1198,7 +1199,7 @@ export default { dataExportFailed: 'Failed to export data', dataImportTitle: 'Import Data', dataImportHint: 'Upload the exported JSON file to import accounts and proxies.', - dataImportWarning: 'Import will create new accounts/proxies; groups must be bound manually. Ensure no conflicts in the target instance.', + dataImportWarning: 'Import will create new accounts/proxies; groups must be bound manually. Ensure existing data does not conflict.', dataImportFile: 'Data file', dataImportButton: 'Start Import', dataImporting: 'Importing...', @@ -1901,6 +1902,21 @@ export default { createProxy: 'Create Proxy', editProxy: 'Edit Proxy', deleteProxy: 'Delete Proxy', + dataImport: 'Import', + dataImportTitle: 'Import Proxies', + dataImportHint: 'Upload the exported proxy JSON file to import proxies in bulk.', + dataImportWarning: 'Import will create or reuse proxies, keep their status, and trigger latency checks after completion.', + dataImportFile: 'Data File', + dataImportButton: 'Start Import', + dataImporting: 'Importing...', + dataImportSelectFile: 'Please select a data file', + dataImportParseFailed: 'Failed to parse data', + dataImportFailed: 'Failed to import data', + dataImportResult: 'Import Result', + dataImportResultSummary: 'Created {proxy_created}, reused {proxy_reused}, failed {proxy_failed}', + dataImportErrors: 'Failure Details', + dataImportSuccess: 'Import completed: created {proxy_created}, reused {proxy_reused}', + dataImportCompletedWithErrors: 'Import completed with errors: failed {proxy_failed}', dataExport: 'Export', dataExportConfirmMessage: 'The exported data contains sensitive proxy information. Store it securely.', dataExportConfirm: 'Confirm Export', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 006a7bd2..9ff89dfc 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -162,6 +162,7 @@ export default { selectedCount: '(已选 {count} 个)', refresh: '刷新', settings: '设置', + chooseFile: '选择文件', notAvailable: '不可用', now: '现在', unknown: '未知', @@ -1275,7 +1276,7 @@ export default { syncFromCrs: '从 CRS 同步', dataExport: '导出', dataExportSelected: '导出选中', - dataExportIncludeProxies: '导出代理(取消后导入时不关联代理)', + dataExportIncludeProxies: '导出代理(导出账号关联的代理)', dataImport: '导入', dataExportConfirmMessage: '导出的数据包含账号与代理的敏感信息,请妥善保存。', dataExportConfirm: '确认导出', @@ -1283,7 +1284,7 @@ export default { dataExportFailed: '数据导出失败', dataImportTitle: '导入数据', dataImportHint: '上传导出的 JSON 文件以批量导入账号与代理。', - dataImportWarning: '导入将创建新账号与代理,分组需手工绑定;请确认目标实例已有数据不会冲突。', + dataImportWarning: '导入将创建新账号与代理,分组需手工绑定;请确认已有数据不会冲突。', dataImportFile: '数据文件', dataImportButton: '开始导入', dataImporting: '导入中...', @@ -2010,6 +2011,21 @@ export default { deleteProxy: '删除代理', deleteConfirmMessage: "确定要删除代理 '{name}' 吗?", testProxy: '测试代理', + dataImport: '导入', + dataImportTitle: '导入代理', + dataImportHint: '上传代理导出的 JSON 文件以批量导入代理。', + dataImportWarning: '导入将创建或复用代理,保留状态并在完成后自动触发延迟检测。', + dataImportFile: '数据文件', + dataImportButton: '开始导入', + dataImporting: '导入中...', + dataImportSelectFile: '请选择数据文件', + dataImportParseFailed: '数据解析失败', + dataImportFailed: '数据导入失败', + dataImportResult: '导入结果', + dataImportResultSummary: '创建 {proxy_created},复用 {proxy_reused},失败 {proxy_failed}', + dataImportErrors: '失败详情', + dataImportSuccess: '导入完成:创建 {proxy_created},复用 {proxy_reused}', + dataImportCompletedWithErrors: '导入完成但有错误:失败 {proxy_failed}', dataExport: '导出', dataExportConfirmMessage: '导出的数据包含代理的敏感信息,请妥善保存。', dataExportConfirm: '确认导出', diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index d8ecd372..e6fe25c8 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -118,6 +118,15 @@ default-sort-order="asc" :sort-storage-key="ACCOUNT_SORT_STORAGE_KEY" > + @@ -551,6 +560,21 @@ const openMenu = (a: Account, e: MouseEvent) => { menu.show = true } const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) } +const allVisibleSelected = computed(() => { + if (accounts.value.length === 0) return false + return accounts.value.every(account => selIds.value.includes(account.id)) +}) +const toggleSelectAllVisible = (event: Event) => { + const target = event.target as HTMLInputElement + if (target.checked) { + const next = new Set(selIds.value) + accounts.value.forEach(account => next.add(account.id)) + selIds.value = Array.from(next) + return + } + const visibleIds = new Set(accounts.value.map(account => account.id)) + selIds.value = selIds.value.filter(id => !visibleIds.has(id)) +} const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] } const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } } const updateSchedulableInList = (accountIds: number[], schedulable: boolean) => { diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue index f6cec7ac..b644eb33 100644 --- a/frontend/src/views/admin/ProxiesView.vue +++ b/frontend/src/views/admin/ProxiesView.vue @@ -69,6 +69,9 @@ {{ t('admin.proxies.batchDeleteAction') }} + @@ -619,6 +622,12 @@ @cancel="showExportDataDialog = false" /> + + { batchParseResult.proxies = [] } +const handleDataImported = () => { + showImportData.value = false + loadProxies() +} + // Parse proxy URL: protocol://user:pass@host:port or protocol://host:port const parseProxyUrl = ( line: string