diff --git a/backend/internal/handler/admin/account_data.go b/backend/internal/handler/admin/account_data.go new file mode 100644 index 00000000..65ef62ac --- /dev/null +++ b/backend/internal/handler/admin/account_data.go @@ -0,0 +1,510 @@ +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"` + Version int `json:"version"` + 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{ + Type: dataType, + Version: dataVersion, + 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 + } + if existingID, ok := proxyKeyToID[key]; ok { + proxyKeyToID[key] = existingID + result.ProxyReused++ + 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 item.Status != "" && item.Status != created.Status { + _, _ = h.adminService.UpdateProxy(c.Request.Context(), created.ID, &service.UpdateProxyInput{ + Status: item.Status, + }) + } + } + + 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) listAllAccounts(ctx context.Context) ([]service.Account, error) { + page := 1 + pageSize := dataPageCap + var out []service.Account + for { + items, total, err := h.adminService.ListAccounts(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) 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) { + _ = accounts + return h.listAllProxies(ctx) +} + +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 == "" { + return errors.New("data type is required") + } + if payload.Type != dataType && payload.Type != legacyDataType { + return fmt.Errorf("unsupported data type: %s", payload.Type) + } + if payload.Version != dataVersion { + return fmt.Errorf("unsupported data version: %d", payload.Version) + } + 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) + } + 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 +} 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..9a6870f4 --- /dev/null +++ b/backend/internal/handler/admin/account_data_handler_test.go @@ -0,0 +1,230 @@ +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.Equal(t, dataType, resp.Data.Type) + require.Len(t, resp.Data.Proxies, 2) + 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..b355b5ad 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -2,19 +2,22 @@ package admin import ( "context" + "strings" "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 } func newStubAdminService() *stubAdminService { @@ -177,6 +180,7 @@ func (s *stubAdminService) GetAccountsByIDs(ctx context.Context, ids []int64) ([ } func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) { + s.createdAccounts = append(s.createdAccounts, input) account := service.Account{ID: 300, Name: input.Name, Status: service.StatusActive} return &account, nil } @@ -214,7 +218,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) { @@ -235,6 +257,7 @@ 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.createdProxies = append(s.createdProxies, input) proxy := service.Proxy{ID: 400, Name: input.Name, Status: service.StatusActive} return &proxy, 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..5ede58d6 --- /dev/null +++ b/backend/internal/handler/admin/proxy_data.go @@ -0,0 +1,73 @@ +package admin + +import ( + "context" + "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() + + 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{ + Type: dataType, + Version: dataVersion, + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Proxies: dataProxies, + Accounts: []DataAccount{}, + } + + response.Success(c, payload) +} + +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..21c5dcf6 --- /dev/null +++ b/backend/internal/handler/admin/proxy_data_handler_test.go @@ -0,0 +1,68 @@ +package admin + +import ( + "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 proxyDataResponse struct { + Code int `json:"code"` + Data DataPayload `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) + + 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.Equal(t, dataType, resp.Data.Type) + require.Len(t, resp.Data.Proxies, 1) + require.Len(t, resp.Data.Accounts, 0) + require.Equal(t, "https", resp.Data.Proxies[0].Protocol) +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index ca9d627e..accd12a3 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -219,6 +219,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) @@ -278,6 +280,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.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 c512f235..3f7624ac 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -166,6 +166,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 @@ -1004,7 +1006,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 { 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: '
' } + } + } + }) + + await wrapper.find('form').trigger('submit') + expect(showError).toHaveBeenCalledWith('admin.accounts.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.accounts.dataImportParseFailed') + }) +}) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 54d0ad94..97776a06 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -13,7 +13,9 @@ import type { WindowStats, ClaudeModel, AccountUsageStatsResponse, - TempUnschedulableStatus + TempUnschedulableStatus, + AdminDataPayload, + AdminDataImportResult } from '@/types' /** @@ -347,6 +349,44 @@ export async function syncFromCrs(params: { return data } +export async function exportData(options?: { + ids?: number[] + filters?: { + platform?: string + type?: string + status?: string + search?: string + } + includeProxies?: boolean +}): Promise { + const params: Record = {} + if (options?.ids && options.ids.length > 0) { + params.ids = options.ids.join(',') + } else if (options?.filters) { + const { platform, type, status, search } = options.filters + if (platform) params.platform = platform + if (type) params.type = type + if (status) params.status = status + if (search) params.search = search + } + if (options?.includeProxies === false) { + params.include_proxies = 'false' + } + const { data } = await apiClient.get('/admin/accounts/data', { params }) + return data +} + +export async function importData(payload: { + data: AdminDataPayload + skip_default_group_bind?: boolean +}): Promise { + const { data } = await apiClient.post('/admin/accounts/data', { + data: payload.data, + skip_default_group_bind: payload.skip_default_group_bind + }) + return data +} + export const accountsAPI = { list, getById, @@ -370,7 +410,9 @@ export const accountsAPI = { batchCreate, batchUpdateCredentials, bulkUpdate, - syncFromCrs + syncFromCrs, + exportData, + importData } export default accountsAPI diff --git a/frontend/src/api/admin/proxies.ts b/frontend/src/api/admin/proxies.ts index 1af2ea39..b2545a7e 100644 --- a/frontend/src/api/admin/proxies.ts +++ b/frontend/src/api/admin/proxies.ts @@ -9,7 +9,8 @@ import type { ProxyAccountSummary, CreateProxyRequest, UpdateProxyRequest, - PaginatedResponse + PaginatedResponse, + AdminDataPayload } from '@/types' /** @@ -208,6 +209,17 @@ export async function batchDelete(ids: number[]): Promise<{ return data } +export async function exportData(filters?: { + protocol?: string + status?: 'active' | 'inactive' + search?: string +}): Promise { + const { data } = await apiClient.get('/admin/proxies/data', { + params: filters + }) + return data +} + export const proxiesAPI = { list, getAll, @@ -221,7 +233,8 @@ export const proxiesAPI = { getStats, getProxyAccounts, batchCreate, - batchDelete + batchDelete, + exportData } export default proxiesAPI diff --git a/frontend/src/components/admin/account/AccountTableActions.vue b/frontend/src/components/admin/account/AccountTableActions.vue index 8dffd6d1..a449f866 100644 --- a/frontend/src/components/admin/account/AccountTableActions.vue +++ b/frontend/src/components/admin/account/AccountTableActions.vue @@ -7,6 +7,7 @@ + diff --git a/frontend/src/components/admin/account/ImportDataModal.vue b/frontend/src/components/admin/account/ImportDataModal.vue new file mode 100644 index 00000000..5b42fe17 --- /dev/null +++ b/frontend/src/components/admin/account/ImportDataModal.vue @@ -0,0 +1,168 @@ + + + diff --git a/frontend/src/components/common/ConfirmDialog.vue b/frontend/src/components/common/ConfirmDialog.vue index abccc416..6ffd9b77 100644 --- a/frontend/src/components/common/ConfirmDialog.vue +++ b/frontend/src/components/common/ConfirmDialog.vue @@ -2,6 +2,7 @@

{{ message }}

+
+ @@ -218,9 +226,16 @@ + + + + @@ -242,6 +257,7 @@ import AccountTableActions from '@/components/admin/account/AccountTableActions. import AccountTableFilters from '@/components/admin/account/AccountTableFilters.vue' import AccountBulkActionsBar from '@/components/admin/account/AccountBulkActionsBar.vue' import AccountActionMenu from '@/components/admin/account/AccountActionMenu.vue' +import ImportDataModal from '@/components/admin/account/ImportDataModal.vue' import ReAuthAccountModal from '@/components/admin/account/ReAuthAccountModal.vue' import AccountTestModal from '@/components/admin/account/AccountTestModal.vue' import AccountStatsModal from '@/components/admin/account/AccountStatsModal.vue' @@ -265,6 +281,9 @@ const selIds = ref([]) const showCreate = ref(false) const showEdit = ref(false) const showSync = ref(false) +const showImportData = ref(false) +const showExportDataDialog = ref(false) +const includeProxyOnExport = ref(true) const showBulkEdit = ref(false) const showTempUnsched = ref(false) const showDeleteDialog = ref(false) @@ -279,6 +298,7 @@ const testingAcc = ref(null) const statsAcc = ref(null) const togglingSchedulable = ref(null) const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null }) +const exportingData = ref(false) // Column settings const showColumnDropdown = ref(false) @@ -405,6 +425,8 @@ const isAnyModalOpen = computed(() => { showCreate.value || showEdit.value || showSync.value || + showImportData.value || + showExportDataDialog.value || showBulkEdit.value || showTempUnsched.value || showDeleteDialog.value || @@ -633,6 +655,50 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => { } } const handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; reload() } +const handleDataImported = () => { showImportData.value = false; reload() } +const formatExportTimestamp = () => { + const now = new Date() + const pad2 = (value: number) => String(value).padStart(2, '0') + return `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}` +} +const openExportDataDialog = () => { + includeProxyOnExport.value = true + showExportDataDialog.value = true +} +const handleExportData = async () => { + if (exportingData.value) return + exportingData.value = true + try { + const dataPayload = await adminAPI.accounts.exportData( + selIds.value.length > 0 + ? { ids: selIds.value, includeProxies: includeProxyOnExport.value } + : { + includeProxies: includeProxyOnExport.value, + filters: { + platform: params.platform, + type: params.type, + status: params.status, + search: params.search + } + } + ) + const timestamp = formatExportTimestamp() + const filename = `sub2api-account-${timestamp}.json` + const blob = new Blob([JSON.stringify(dataPayload, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + link.click() + URL.revokeObjectURL(url) + appStore.showSuccess(t('admin.accounts.dataExported')) + } catch (error: any) { + appStore.showError(error?.message || t('admin.accounts.dataExportFailed')) + } finally { + exportingData.value = false + showExportDataDialog.value = false + } +} const closeTestModal = () => { showTest.value = false; testingAcc.value = null } const closeStatsModal = () => { showStats.value = false; statsAcc.value = null } const closeReAuthModal = () => { showReAuth.value = false; reAuthAcc.value = null } diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue index 3bd766b6..f6cec7ac 100644 --- a/frontend/src/views/admin/ProxiesView.vue +++ b/frontend/src/views/admin/ProxiesView.vue @@ -69,6 +69,9 @@ {{ t('admin.proxies.batchDeleteAction') }} +