diff --git a/backend/internal/handler/admin/account_data.go b/backend/internal/handler/admin/account_data.go new file mode 100644 index 00000000..b5d1dd0a --- /dev/null +++ b/backend/internal/handler/admin/account_data.go @@ -0,0 +1,544 @@ +package admin + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +const ( + dataType = "sub2api-data" + legacyDataType = "sub2api-bundle" + dataVersion = 1 + dataPageCap = 1000 +) + +type DataPayload struct { + Type string `json:"type,omitempty"` + Version int `json:"version,omitempty"` + ExportedAt string `json:"exported_at"` + Proxies []DataProxy `json:"proxies"` + Accounts []DataAccount `json:"accounts"` +} + +type DataProxy struct { + ProxyKey string `json:"proxy_key"` + Name string `json:"name"` + Protocol string `json:"protocol"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Status string `json:"status"` +} + +type DataAccount struct { + Name string `json:"name"` + Notes *string `json:"notes,omitempty"` + Platform string `json:"platform"` + Type string `json:"type"` + Credentials map[string]any `json:"credentials"` + Extra map[string]any `json:"extra,omitempty"` + ProxyKey *string `json:"proxy_key,omitempty"` + Concurrency int `json:"concurrency"` + Priority int `json:"priority"` + RateMultiplier *float64 `json:"rate_multiplier,omitempty"` + ExpiresAt *int64 `json:"expires_at,omitempty"` + AutoPauseOnExpired *bool `json:"auto_pause_on_expired,omitempty"` +} + +type DataImportRequest struct { + Data DataPayload `json:"data"` + SkipDefaultGroupBind *bool `json:"skip_default_group_bind"` +} + +type DataImportResult struct { + ProxyCreated int `json:"proxy_created"` + ProxyReused int `json:"proxy_reused"` + ProxyFailed int `json:"proxy_failed"` + AccountCreated int `json:"account_created"` + AccountFailed int `json:"account_failed"` + Errors []DataImportError `json:"errors,omitempty"` +} + +type DataImportError struct { + Kind string `json:"kind"` + Name string `json:"name,omitempty"` + ProxyKey string `json:"proxy_key,omitempty"` + Message string `json:"message"` +} + +func buildProxyKey(protocol, host string, port int, username, password string) string { + return fmt.Sprintf("%s|%s|%d|%s|%s", strings.TrimSpace(protocol), strings.TrimSpace(host), port, strings.TrimSpace(username), strings.TrimSpace(password)) +} + +func (h *AccountHandler) ExportData(c *gin.Context) { + ctx := c.Request.Context() + + selectedIDs, err := parseAccountIDs(c) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + + accounts, err := h.resolveExportAccounts(ctx, selectedIDs, c) + if err != nil { + response.ErrorFrom(c, err) + return + } + + includeProxies, err := parseIncludeProxies(c) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + + var proxies []service.Proxy + if includeProxies { + proxies, err = h.resolveExportProxies(ctx, accounts) + if err != nil { + response.ErrorFrom(c, err) + return + } + } else { + proxies = []service.Proxy{} + } + + proxyKeyByID := make(map[int64]string, len(proxies)) + dataProxies := make([]DataProxy, 0, len(proxies)) + for i := range proxies { + p := proxies[i] + key := buildProxyKey(p.Protocol, p.Host, p.Port, p.Username, p.Password) + proxyKeyByID[p.ID] = key + dataProxies = append(dataProxies, DataProxy{ + ProxyKey: key, + Name: p.Name, + Protocol: p.Protocol, + Host: p.Host, + Port: p.Port, + Username: p.Username, + Password: p.Password, + Status: p.Status, + }) + } + + dataAccounts := make([]DataAccount, 0, len(accounts)) + for i := range accounts { + acc := accounts[i] + var proxyKey *string + if acc.ProxyID != nil { + if key, ok := proxyKeyByID[*acc.ProxyID]; ok { + proxyKey = &key + } + } + var expiresAt *int64 + if acc.ExpiresAt != nil { + v := acc.ExpiresAt.Unix() + expiresAt = &v + } + dataAccounts = append(dataAccounts, DataAccount{ + Name: acc.Name, + Notes: acc.Notes, + Platform: acc.Platform, + Type: acc.Type, + Credentials: acc.Credentials, + Extra: acc.Extra, + ProxyKey: proxyKey, + Concurrency: acc.Concurrency, + Priority: acc.Priority, + RateMultiplier: acc.RateMultiplier, + ExpiresAt: expiresAt, + AutoPauseOnExpired: &acc.AutoPauseOnExpired, + }) + } + + payload := DataPayload{ + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Proxies: dataProxies, + Accounts: dataAccounts, + } + + response.Success(c, payload) +} + +func (h *AccountHandler) ImportData(c *gin.Context) { + var req DataImportRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + dataPayload := req.Data + if err := validateDataHeader(dataPayload); err != nil { + response.BadRequest(c, err.Error()) + return + } + + skipDefaultGroupBind := true + if req.SkipDefaultGroupBind != nil { + skipDefaultGroupBind = *req.SkipDefaultGroupBind + } + + result := DataImportResult{} + existingProxies, err := h.listAllProxies(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + proxyKeyToID := make(map[string]int64, len(existingProxies)) + for i := range existingProxies { + p := existingProxies[i] + key := buildProxyKey(p.Protocol, p.Host, p.Port, p.Username, p.Password) + proxyKeyToID[key] = p.ID + } + + for i := range dataPayload.Proxies { + item := dataPayload.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 + } + normalizedStatus := normalizeProxyStatus(item.Status) + if existingID, ok := proxyKeyToID[key]; ok { + proxyKeyToID[key] = existingID + result.ProxyReused++ + if normalizedStatus != "" { + if proxy, err := h.adminService.GetProxy(c.Request.Context(), existingID); err == nil && proxy != nil && proxy.Status != normalizedStatus { + _, _ = h.adminService.UpdateProxy(c.Request.Context(), existingID, &service.UpdateProxyInput{ + Status: normalizedStatus, + }) + } + } + continue + } + + created, err := h.adminService.CreateProxy(c.Request.Context(), &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 + } + proxyKeyToID[key] = created.ID + result.ProxyCreated++ + + if normalizedStatus != "" && normalizedStatus != created.Status { + _, _ = h.adminService.UpdateProxy(c.Request.Context(), created.ID, &service.UpdateProxyInput{ + Status: normalizedStatus, + }) + } + } + + for i := range dataPayload.Accounts { + item := dataPayload.Accounts[i] + if err := validateDataAccount(item); err != nil { + result.AccountFailed++ + result.Errors = append(result.Errors, DataImportError{ + Kind: "account", + Name: item.Name, + Message: err.Error(), + }) + continue + } + + var proxyID *int64 + if item.ProxyKey != nil && *item.ProxyKey != "" { + if id, ok := proxyKeyToID[*item.ProxyKey]; ok { + proxyID = &id + } else { + result.AccountFailed++ + result.Errors = append(result.Errors, DataImportError{ + Kind: "account", + Name: item.Name, + ProxyKey: *item.ProxyKey, + Message: "proxy_key not found", + }) + continue + } + } + + accountInput := &service.CreateAccountInput{ + Name: item.Name, + Notes: item.Notes, + Platform: item.Platform, + Type: item.Type, + Credentials: item.Credentials, + Extra: item.Extra, + ProxyID: proxyID, + Concurrency: item.Concurrency, + Priority: item.Priority, + RateMultiplier: item.RateMultiplier, + GroupIDs: nil, + ExpiresAt: item.ExpiresAt, + AutoPauseOnExpired: item.AutoPauseOnExpired, + SkipDefaultGroupBind: skipDefaultGroupBind, + } + + if _, err := h.adminService.CreateAccount(c.Request.Context(), accountInput); err != nil { + result.AccountFailed++ + result.Errors = append(result.Errors, DataImportError{ + Kind: "account", + Name: item.Name, + Message: err.Error(), + }) + continue + } + result.AccountCreated++ + } + + response.Success(c, result) +} + +func (h *AccountHandler) listAllProxies(ctx context.Context) ([]service.Proxy, error) { + page := 1 + pageSize := dataPageCap + var out []service.Proxy + for { + items, total, err := h.adminService.ListProxies(ctx, page, pageSize, "", "", "") + if err != nil { + return nil, err + } + out = append(out, items...) + if len(out) >= int(total) || len(items) == 0 { + break + } + page++ + } + return out, nil +} + +func (h *AccountHandler) listAccountsFiltered(ctx context.Context, platform, accountType, status, search string) ([]service.Account, error) { + page := 1 + pageSize := dataPageCap + var out []service.Account + for { + items, total, err := h.adminService.ListAccounts(ctx, page, pageSize, platform, accountType, status, search) + if err != nil { + return nil, err + } + out = append(out, items...) + if len(out) >= int(total) || len(items) == 0 { + break + } + page++ + } + return out, nil +} + +func (h *AccountHandler) resolveExportAccounts(ctx context.Context, ids []int64, c *gin.Context) ([]service.Account, error) { + if len(ids) > 0 { + accounts, err := h.adminService.GetAccountsByIDs(ctx, ids) + if err != nil { + return nil, err + } + out := make([]service.Account, 0, len(accounts)) + for _, acc := range accounts { + if acc == nil { + continue + } + out = append(out, *acc) + } + return out, nil + } + + platform := c.Query("platform") + accountType := c.Query("type") + status := c.Query("status") + search := strings.TrimSpace(c.Query("search")) + if len(search) > 100 { + search = search[:100] + } + return h.listAccountsFiltered(ctx, platform, accountType, status, search) +} + +func (h *AccountHandler) resolveExportProxies(ctx context.Context, accounts []service.Account) ([]service.Proxy, error) { + if len(accounts) == 0 { + return []service.Proxy{}, nil + } + + seen := make(map[int64]struct{}) + ids := make([]int64, 0) + for i := range accounts { + if accounts[i].ProxyID == nil { + continue + } + id := *accounts[i].ProxyID + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(ids) == 0 { + return []service.Proxy{}, nil + } + + return h.adminService.GetProxiesByIDs(ctx, ids) +} + +func parseAccountIDs(c *gin.Context) ([]int64, error) { + values := c.QueryArray("ids") + if len(values) == 0 { + raw := strings.TrimSpace(c.Query("ids")) + if raw != "" { + values = []string{raw} + } + } + if len(values) == 0 { + return nil, nil + } + + ids := make([]int64, 0, len(values)) + for _, item := range values { + for _, part := range strings.Split(item, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + id, err := strconv.ParseInt(part, 10, 64) + if err != nil || id <= 0 { + return nil, fmt.Errorf("invalid account id: %s", part) + } + ids = append(ids, id) + } + } + return ids, nil +} + +func parseIncludeProxies(c *gin.Context) (bool, error) { + raw := strings.TrimSpace(strings.ToLower(c.Query("include_proxies"))) + if raw == "" { + return true, nil + } + switch raw { + case "1", "true", "yes", "on": + return true, nil + case "0", "false", "no", "off": + return false, nil + default: + return true, fmt.Errorf("invalid include_proxies value: %s", raw) + } +} + +func validateDataHeader(payload DataPayload) error { + if payload.Type != "" && payload.Type != dataType && payload.Type != legacyDataType { + return fmt.Errorf("unsupported data type: %s", payload.Type) + } + if payload.Version != 0 && payload.Version != dataVersion { + return fmt.Errorf("unsupported data version: %d", payload.Version) + } + if payload.Proxies == nil { + return errors.New("proxies is required") + } + if payload.Accounts == nil { + return errors.New("accounts is required") + } + return nil +} + +func validateDataProxy(item DataProxy) error { + if strings.TrimSpace(item.Protocol) == "" { + return errors.New("proxy protocol is required") + } + if strings.TrimSpace(item.Host) == "" { + return errors.New("proxy host is required") + } + if item.Port <= 0 || item.Port > 65535 { + return errors.New("proxy port is invalid") + } + switch item.Protocol { + case "http", "https", "socks5", "socks5h": + default: + return fmt.Errorf("proxy protocol is invalid: %s", item.Protocol) + } + if item.Status != "" { + normalizedStatus := normalizeProxyStatus(item.Status) + if normalizedStatus != service.StatusActive && normalizedStatus != "inactive" { + return fmt.Errorf("proxy status is invalid: %s", item.Status) + } + } + return nil +} + +func validateDataAccount(item DataAccount) error { + if strings.TrimSpace(item.Name) == "" { + return errors.New("account name is required") + } + if strings.TrimSpace(item.Platform) == "" { + return errors.New("account platform is required") + } + if strings.TrimSpace(item.Type) == "" { + return errors.New("account type is required") + } + if len(item.Credentials) == 0 { + return errors.New("account credentials is required") + } + switch item.Type { + case service.AccountTypeOAuth, service.AccountTypeSetupToken, service.AccountTypeAPIKey, service.AccountTypeUpstream: + default: + return fmt.Errorf("account type is invalid: %s", item.Type) + } + if item.RateMultiplier != nil && *item.RateMultiplier < 0 { + return errors.New("rate_multiplier must be >= 0") + } + if item.Concurrency < 0 { + return errors.New("concurrency must be >= 0") + } + if item.Priority < 0 { + return errors.New("priority must be >= 0") + } + return nil +} + +func defaultProxyName(name string) string { + if strings.TrimSpace(name) == "" { + return "imported-proxy" + } + return name +} + +func normalizeProxyStatus(status string) string { + normalized := strings.TrimSpace(strings.ToLower(status)) + switch normalized { + case "": + return "" + case service.StatusActive: + return service.StatusActive + case "inactive", service.StatusDisabled: + return "inactive" + default: + return normalized + } +} diff --git a/backend/internal/handler/admin/account_data_handler_test.go b/backend/internal/handler/admin/account_data_handler_test.go new file mode 100644 index 00000000..c8b04c2a --- /dev/null +++ b/backend/internal/handler/admin/account_data_handler_test.go @@ -0,0 +1,231 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type dataResponse struct { + Code int `json:"code"` + Data dataPayload `json:"data"` +} + +type dataPayload struct { + Type string `json:"type"` + Version int `json:"version"` + Proxies []dataProxy `json:"proxies"` + Accounts []dataAccount `json:"accounts"` +} + +type dataProxy struct { + ProxyKey string `json:"proxy_key"` + Name string `json:"name"` + Protocol string `json:"protocol"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + Status string `json:"status"` +} + +type dataAccount struct { + Name string `json:"name"` + Platform string `json:"platform"` + Type string `json:"type"` + Credentials map[string]any `json:"credentials"` + Extra map[string]any `json:"extra"` + ProxyKey *string `json:"proxy_key"` + Concurrency int `json:"concurrency"` + Priority int `json:"priority"` +} + +func setupAccountDataRouter() (*gin.Engine, *stubAdminService) { + gin.SetMode(gin.TestMode) + router := gin.New() + adminSvc := newStubAdminService() + + h := NewAccountHandler( + adminSvc, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + ) + + router.GET("/api/v1/admin/accounts/data", h.ExportData) + router.POST("/api/v1/admin/accounts/data", h.ImportData) + return router, adminSvc +} + +func TestExportDataIncludesSecrets(t *testing.T) { + router, adminSvc := setupAccountDataRouter() + + proxyID := int64(11) + adminSvc.proxies = []service.Proxy{ + { + ID: proxyID, + Name: "proxy", + Protocol: "http", + Host: "127.0.0.1", + Port: 8080, + Username: "user", + Password: "pass", + Status: service.StatusActive, + }, + { + ID: 12, + Name: "orphan", + Protocol: "https", + Host: "10.0.0.1", + Port: 443, + Username: "o", + Password: "p", + Status: service.StatusActive, + }, + } + adminSvc.accounts = []service.Account{ + { + ID: 21, + Name: "account", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{"token": "secret"}, + Extra: map[string]any{"note": "x"}, + ProxyID: &proxyID, + Concurrency: 3, + Priority: 50, + Status: service.StatusDisabled, + }, + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/data", nil) + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var resp dataResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Code) + require.Empty(t, resp.Data.Type) + require.Equal(t, 0, resp.Data.Version) + require.Len(t, resp.Data.Proxies, 1) + require.Equal(t, "pass", resp.Data.Proxies[0].Password) + require.Len(t, resp.Data.Accounts, 1) + require.Equal(t, "secret", resp.Data.Accounts[0].Credentials["token"]) +} + +func TestExportDataWithoutProxies(t *testing.T) { + router, adminSvc := setupAccountDataRouter() + + proxyID := int64(11) + adminSvc.proxies = []service.Proxy{ + { + ID: proxyID, + Name: "proxy", + Protocol: "http", + Host: "127.0.0.1", + Port: 8080, + Username: "user", + Password: "pass", + Status: service.StatusActive, + }, + } + adminSvc.accounts = []service.Account{ + { + ID: 21, + Name: "account", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{"token": "secret"}, + ProxyID: &proxyID, + Concurrency: 3, + Priority: 50, + Status: service.StatusDisabled, + }, + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/data?include_proxies=false", nil) + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var resp dataResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Code) + require.Len(t, resp.Data.Proxies, 0) + require.Len(t, resp.Data.Accounts, 1) + require.Nil(t, resp.Data.Accounts[0].ProxyKey) +} + +func TestImportDataReusesProxyAndSkipsDefaultGroup(t *testing.T) { + router, adminSvc := setupAccountDataRouter() + + adminSvc.proxies = []service.Proxy{ + { + ID: 1, + Name: "proxy", + Protocol: "socks5", + Host: "1.2.3.4", + Port: 1080, + Username: "u", + Password: "p", + Status: service.StatusActive, + }, + } + + dataPayload := map[string]any{ + "data": map[string]any{ + "type": dataType, + "version": dataVersion, + "proxies": []map[string]any{ + { + "proxy_key": "socks5|1.2.3.4|1080|u|p", + "name": "proxy", + "protocol": "socks5", + "host": "1.2.3.4", + "port": 1080, + "username": "u", + "password": "p", + "status": "active", + }, + }, + "accounts": []map[string]any{ + { + "name": "acc", + "platform": service.PlatformOpenAI, + "type": service.AccountTypeOAuth, + "credentials": map[string]any{"token": "x"}, + "proxy_key": "socks5|1.2.3.4|1080|u|p", + "concurrency": 3, + "priority": 50, + }, + }, + }, + "skip_default_group_bind": true, + } + + body, _ := json.Marshal(dataPayload) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/data", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + require.Len(t, adminSvc.createdProxies, 0) + require.Len(t, adminSvc.createdAccounts, 1) + require.True(t, adminSvc.createdAccounts[0].SkipDefaultGroupBind) +} diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 6d42f726..2673e614 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -696,11 +696,61 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) { return } - // Return mock data for now + ctx := c.Request.Context() + success := 0 + failed := 0 + results := make([]gin.H, 0, len(req.Accounts)) + + for _, item := range req.Accounts { + if item.RateMultiplier != nil && *item.RateMultiplier < 0 { + failed++ + results = append(results, gin.H{ + "name": item.Name, + "success": false, + "error": "rate_multiplier must be >= 0", + }) + continue + } + + skipCheck := item.ConfirmMixedChannelRisk != nil && *item.ConfirmMixedChannelRisk + + account, err := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{ + Name: item.Name, + Notes: item.Notes, + Platform: item.Platform, + Type: item.Type, + Credentials: item.Credentials, + Extra: item.Extra, + ProxyID: item.ProxyID, + Concurrency: item.Concurrency, + Priority: item.Priority, + RateMultiplier: item.RateMultiplier, + GroupIDs: item.GroupIDs, + ExpiresAt: item.ExpiresAt, + AutoPauseOnExpired: item.AutoPauseOnExpired, + SkipMixedChannelCheck: skipCheck, + }) + if err != nil { + failed++ + results = append(results, gin.H{ + "name": item.Name, + "success": false, + "error": err.Error(), + }) + continue + } + success++ + results = append(results, gin.H{ + "name": item.Name, + "id": account.ID, + "success": true, + }) + } + response.Success(c, gin.H{ - "success": len(req.Accounts), - "failed": 0, - "results": []gin.H{}, + "success": success, + "failed": failed, + "results": results, }) } diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index ea2ea963..77d288f9 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -2,19 +2,27 @@ package admin import ( "context" + "strings" + "sync" "time" "github.com/Wei-Shaw/sub2api/internal/service" ) type stubAdminService struct { - users []service.User - apiKeys []service.APIKey - groups []service.Group - accounts []service.Account - proxies []service.Proxy - proxyCounts []service.ProxyWithAccountCount - redeems []service.RedeemCode + users []service.User + apiKeys []service.APIKey + groups []service.Group + accounts []service.Account + proxies []service.Proxy + proxyCounts []service.ProxyWithAccountCount + redeems []service.RedeemCode + createdAccounts []*service.CreateAccountInput + createdProxies []*service.CreateProxyInput + updatedProxyIDs []int64 + updatedProxies []*service.UpdateProxyInput + testedProxyIDs []int64 + mu sync.Mutex } func newStubAdminService() *stubAdminService { @@ -177,6 +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 } @@ -214,7 +225,25 @@ func (s *stubAdminService) BulkUpdateAccounts(ctx context.Context, input *servic } func (s *stubAdminService) ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]service.Proxy, int64, error) { - return s.proxies, int64(len(s.proxies)), nil + search = strings.TrimSpace(strings.ToLower(search)) + filtered := make([]service.Proxy, 0, len(s.proxies)) + for _, proxy := range s.proxies { + if protocol != "" && proxy.Protocol != protocol { + continue + } + if status != "" && proxy.Status != status { + continue + } + if search != "" { + name := strings.ToLower(proxy.Name) + host := strings.ToLower(proxy.Host) + if !strings.Contains(name, search) && !strings.Contains(host, search) { + continue + } + } + filtered = append(filtered, proxy) + } + return filtered, int64(len(filtered)), nil } func (s *stubAdminService) ListProxiesWithAccountCount(ctx context.Context, page, pageSize int, protocol, status, search string) ([]service.ProxyWithAccountCount, int64, error) { @@ -230,16 +259,47 @@ func (s *stubAdminService) GetAllProxiesWithAccountCount(ctx context.Context) ([ } func (s *stubAdminService) GetProxy(ctx context.Context, id int64) (*service.Proxy, error) { + for i := range s.proxies { + proxy := s.proxies[i] + if proxy.ID == id { + return &proxy, nil + } + } proxy := service.Proxy{ID: id, Name: "proxy", Status: service.StatusActive} return &proxy, nil } +func (s *stubAdminService) GetProxiesByIDs(ctx context.Context, ids []int64) ([]service.Proxy, error) { + if len(ids) == 0 { + return []service.Proxy{}, nil + } + out := make([]service.Proxy, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + seen[id] = struct{}{} + } + for i := range s.proxies { + proxy := s.proxies[i] + if _, ok := seen[proxy.ID]; ok { + out = append(out, proxy) + } + } + return out, nil +} + 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 } @@ -261,6 +321,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 new file mode 100644 index 00000000..72ecd6c1 --- /dev/null +++ b/backend/internal/handler/admin/proxy_data.go @@ -0,0 +1,239 @@ +package admin + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// ExportData exports proxy-only data for migration. +func (h *ProxyHandler) ExportData(c *gin.Context) { + ctx := c.Request.Context() + + selectedIDs, err := parseProxyIDs(c) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + + var proxies []service.Proxy + if len(selectedIDs) > 0 { + proxies, err = h.getProxiesByIDs(ctx, selectedIDs) + if err != nil { + response.ErrorFrom(c, err) + return + } + } else { + protocol := c.Query("protocol") + status := c.Query("status") + search := strings.TrimSpace(c.Query("search")) + if len(search) > 100 { + search = search[:100] + } + + proxies, err = h.listProxiesFiltered(ctx, protocol, status, search) + if err != nil { + response.ErrorFrom(c, err) + return + } + } + + dataProxies := make([]DataProxy, 0, len(proxies)) + for i := range proxies { + p := proxies[i] + key := buildProxyKey(p.Protocol, p.Host, p.Port, p.Username, p.Password) + dataProxies = append(dataProxies, DataProxy{ + ProxyKey: key, + Name: p.Name, + Protocol: p.Protocol, + Host: p.Host, + Port: p.Port, + Username: p.Username, + Password: p.Password, + Status: p.Status, + }) + } + + payload := DataPayload{ + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Proxies: dataProxies, + Accounts: []DataAccount{}, + } + + 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 + } + + normalizedStatus := normalizeProxyStatus(item.Status) + if existing, ok := proxyByKey[key]; ok { + result.ProxyReused++ + if normalizedStatus != "" && normalizedStatus != existing.Status { + if _, err := h.adminService.UpdateProxy(ctx, existing.ID, &service.UpdateProxyInput{Status: normalizedStatus}); 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 normalizedStatus != "" && normalizedStatus != created.Status { + if _, err := h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{Status: normalizedStatus}); err != nil { + result.Errors = append(result.Errors, DataImportError{ + Kind: "proxy", + Name: item.Name, + ProxyKey: key, + Message: "update status failed: " + err.Error(), + }) + } + } + // CreateProxy already triggers a latency probe, avoid double probing here. + } + + 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) getProxiesByIDs(ctx context.Context, ids []int64) ([]service.Proxy, error) { + if len(ids) == 0 { + return []service.Proxy{}, nil + } + return h.adminService.GetProxiesByIDs(ctx, ids) +} + +func parseProxyIDs(c *gin.Context) ([]int64, error) { + values := c.QueryArray("ids") + if len(values) == 0 { + raw := strings.TrimSpace(c.Query("ids")) + if raw != "" { + values = []string{raw} + } + } + if len(values) == 0 { + return nil, nil + } + + ids := make([]int64, 0, len(values)) + for _, item := range values { + for _, part := range strings.Split(item, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + id, err := strconv.ParseInt(part, 10, 64) + if err != nil || id <= 0 { + return nil, fmt.Errorf("invalid proxy id: %s", part) + } + ids = append(ids, id) + } + } + return ids, nil +} + +func (h *ProxyHandler) listProxiesFiltered(ctx context.Context, protocol, status, search string) ([]service.Proxy, error) { + page := 1 + pageSize := dataPageCap + var out []service.Proxy + for { + items, total, err := h.adminService.ListProxies(ctx, page, pageSize, protocol, status, search) + if err != nil { + return nil, err + } + out = append(out, items...) + if len(out) >= int(total) || len(items) == 0 { + break + } + page++ + } + return out, nil +} diff --git a/backend/internal/handler/admin/proxy_data_handler_test.go b/backend/internal/handler/admin/proxy_data_handler_test.go new file mode 100644 index 00000000..803f9b61 --- /dev/null +++ b/backend/internal/handler/admin/proxy_data_handler_test.go @@ -0,0 +1,188 @@ +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" + "github.com/stretchr/testify/require" +) + +type proxyDataResponse struct { + Code int `json:"code"` + 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() + adminSvc := newStubAdminService() + + h := NewProxyHandler(adminSvc) + router.GET("/api/v1/admin/proxies/data", h.ExportData) + router.POST("/api/v1/admin/proxies/data", h.ImportData) + + return router, adminSvc +} + +func TestProxyExportDataRespectsFilters(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, + }, + { + ID: 2, + Name: "proxy-b", + Protocol: "https", + Host: "10.0.0.2", + Port: 443, + Username: "u", + Password: "p", + Status: service.StatusDisabled, + }, + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/proxies/data?protocol=https", nil) + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var resp proxyDataResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Code) + require.Empty(t, resp.Data.Type) + require.Equal(t, 0, resp.Data.Version) + require.Len(t, resp.Data.Proxies, 1) + require.Len(t, resp.Data.Accounts, 0) + require.Equal(t, "https", resp.Data.Proxies[0].Protocol) +} + +func TestProxyExportDataWithSelectedIDs(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, + }, + { + ID: 2, + Name: "proxy-b", + Protocol: "https", + Host: "10.0.0.2", + Port: 443, + Username: "u", + Password: "p", + Status: service.StatusDisabled, + }, + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/proxies/data?ids=2", nil) + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var resp proxyDataResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Code) + require.Len(t, resp.Data.Proxies, 1) + require.Equal(t, "https", resp.Data.Proxies[0].Protocol) + require.Equal(t, "10.0.0.2", resp.Data.Proxies[0].Host) +} + +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", + }, + }, + "accounts": []map[string]any{}, + }, + } + + 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) == 1 + }, time.Second, 10*time.Millisecond) +} diff --git a/backend/internal/repository/proxy_repo.go b/backend/internal/repository/proxy_repo.go index 36965c05..07c2a204 100644 --- a/backend/internal/repository/proxy_repo.go +++ b/backend/internal/repository/proxy_repo.go @@ -60,6 +60,25 @@ func (r *proxyRepository) GetByID(ctx context.Context, id int64) (*service.Proxy return proxyEntityToService(m), nil } +func (r *proxyRepository) ListByIDs(ctx context.Context, ids []int64) ([]service.Proxy, error) { + if len(ids) == 0 { + return []service.Proxy{}, nil + } + + proxies, err := r.client.Proxy.Query(). + Where(proxy.IDIn(ids...)). + All(ctx) + if err != nil { + return nil, err + } + + out := make([]service.Proxy, 0, len(proxies)) + for i := range proxies { + out = append(out, *proxyEntityToService(proxies[i])) + } + return out, nil +} + func (r *proxyRepository) Update(ctx context.Context, proxyIn *service.Proxy) error { builder := r.client.Proxy.UpdateOneID(proxyIn.ID). SetName(proxyIn.Name). diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index f5f8cda7..efef0452 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1059,6 +1059,10 @@ func (stubProxyRepo) GetByID(ctx context.Context, id int64) (*service.Proxy, err return nil, service.ErrProxyNotFound } +func (stubProxyRepo) ListByIDs(ctx context.Context, ids []int64) ([]service.Proxy, error) { + return nil, errors.New("not implemented") +} + func (stubProxyRepo) Update(ctx context.Context, proxy *service.Proxy) error { return errors.New("not implemented") } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index a1c27b00..f78e36a2 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -222,6 +222,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable) accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels) accounts.POST("/batch", h.Admin.Account.BatchCreate) + accounts.GET("/data", h.Admin.Account.ExportData) + accounts.POST("/data", h.Admin.Account.ImportData) accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials) accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier) accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate) @@ -281,6 +283,8 @@ 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/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index f215f82e..59d7062b 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -56,6 +56,7 @@ type AdminService interface { GetAllProxies(ctx context.Context) ([]Proxy, error) GetAllProxiesWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) GetProxy(ctx context.Context, id int64) (*Proxy, error) + GetProxiesByIDs(ctx context.Context, ids []int64) ([]Proxy, error) 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 @@ -169,6 +170,8 @@ type CreateAccountInput struct { GroupIDs []int64 ExpiresAt *int64 AutoPauseOnExpired *bool + // SkipDefaultGroupBind prevents auto-binding to platform default group when GroupIDs is empty. + SkipDefaultGroupBind bool // SkipMixedChannelCheck skips the mixed channel risk check when binding groups. // This should only be set when the caller has explicitly confirmed the risk. SkipMixedChannelCheck bool @@ -1043,7 +1046,7 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou // 绑定分组 groupIDs := input.GroupIDs // 如果没有指定分组,自动绑定对应平台的默认分组 - if len(groupIDs) == 0 { + if len(groupIDs) == 0 && !input.SkipDefaultGroupBind { defaultGroupName := input.Platform + "-default" groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform) if err == nil { @@ -1383,6 +1386,10 @@ func (s *adminServiceImpl) GetProxy(ctx context.Context, id int64) (*Proxy, erro return s.proxyRepo.GetByID(ctx, id) } +func (s *adminServiceImpl) GetProxiesByIDs(ctx context.Context, ids []int64) ([]Proxy, error) { + return s.proxyRepo.ListByIDs(ctx, ids) +} + func (s *adminServiceImpl) CreateProxy(ctx context.Context, input *CreateProxyInput) (*Proxy, error) { proxy := &Proxy{ Name: input.Name, diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index e2aa83d9..c775749d 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -187,6 +187,10 @@ func (s *proxyRepoStub) GetByID(ctx context.Context, id int64) (*Proxy, error) { panic("unexpected GetByID call") } +func (s *proxyRepoStub) ListByIDs(ctx context.Context, ids []int64) ([]Proxy, error) { + panic("unexpected ListByIDs call") +} + func (s *proxyRepoStub) Update(ctx context.Context, proxy *Proxy) error { panic("unexpected Update call") } diff --git a/backend/internal/service/proxy_service.go b/backend/internal/service/proxy_service.go index a5d897f6..80045187 100644 --- a/backend/internal/service/proxy_service.go +++ b/backend/internal/service/proxy_service.go @@ -16,6 +16,7 @@ var ( type ProxyRepository interface { Create(ctx context.Context, proxy *Proxy) error GetByID(ctx context.Context, id int64) (*Proxy, error) + ListByIDs(ctx context.Context, ids []int64) ([]Proxy, error) Update(ctx context.Context, proxy *Proxy) error Delete(ctx context.Context, id int64) error diff --git a/frontend/src/__tests__/integration/data-import.spec.ts b/frontend/src/__tests__/integration/data-import.spec.ts new file mode 100644 index 00000000..1fe870ab --- /dev/null +++ b/frontend/src/__tests__/integration/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/account/ImportDataModal.vue' + +const showError = vi.fn() +const showSuccess = vi.fn() + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showError, + showSuccess + }) +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + accounts: { + importData: vi.fn() + } + } +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key + }) +})) + +describe('ImportDataModal', () => { + beforeEach(() => { + showError.mockReset() + showSuccess.mockReset() + }) + + it('未选择文件时提示错误', async () => { + const wrapper = mount(ImportDataModal, { + props: { show: true }, + global: { + stubs: { + BaseDialog: { template: '
{{ message }}
+