Merge pull request #489 from LLLLLLiulei/feat/import-export-bundle
feat: implement account & proxy import/export with migration-ready JSON bundles
This commit is contained in:
544
backend/internal/handler/admin/account_data.go
Normal file
544
backend/internal/handler/admin/account_data.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
231
backend/internal/handler/admin/account_data_handler_test.go
Normal file
231
backend/internal/handler/admin/account_data_handler_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
239
backend/internal/handler/admin/proxy_data.go
Normal file
239
backend/internal/handler/admin/proxy_data.go
Normal file
@@ -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
|
||||
}
|
||||
188
backend/internal/handler/admin/proxy_data_handler_test.go
Normal file
188
backend/internal/handler/admin/proxy_data_handler_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
70
frontend/src/__tests__/integration/data-import.spec.ts
Normal file
70
frontend/src/__tests__/integration/data-import.spec.ts
Normal file
@@ -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: '<div><slot /><slot name="footer" /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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: '<div><slot /><slot name="footer" /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
70
frontend/src/__tests__/integration/proxy-data-import.spec.ts
Normal file
70
frontend/src/__tests__/integration/proxy-data-import.spec.ts
Normal file
@@ -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: '<div><slot /><slot name="footer" /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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: '<div><slot /><slot name="footer" /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<AdminDataPayload> {
|
||||
const params: Record<string, string> = {}
|
||||
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<AdminDataPayload>('/admin/accounts/data', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function importData(payload: {
|
||||
data: AdminDataPayload
|
||||
skip_default_group_bind?: boolean
|
||||
}): Promise<AdminDataImportResult> {
|
||||
const { data } = await apiClient.post<AdminDataImportResult>('/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
|
||||
|
||||
@@ -9,7 +9,9 @@ import type {
|
||||
ProxyAccountSummary,
|
||||
CreateProxyRequest,
|
||||
UpdateProxyRequest,
|
||||
PaginatedResponse
|
||||
PaginatedResponse,
|
||||
AdminDataPayload,
|
||||
AdminDataImportResult
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
@@ -208,6 +210,34 @@ export async function batchDelete(ids: number[]): Promise<{
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exportData(options?: {
|
||||
ids?: number[]
|
||||
filters?: {
|
||||
protocol?: string
|
||||
status?: 'active' | 'inactive'
|
||||
search?: string
|
||||
}
|
||||
}): Promise<AdminDataPayload> {
|
||||
const params: Record<string, string> = {}
|
||||
if (options?.ids && options.ids.length > 0) {
|
||||
params.ids = options.ids.join(',')
|
||||
} else if (options?.filters) {
|
||||
const { protocol, status, search } = options.filters
|
||||
if (protocol) params.protocol = protocol
|
||||
if (status) params.status = status
|
||||
if (search) params.search = search
|
||||
}
|
||||
const { data } = await apiClient.get<AdminDataPayload>('/admin/proxies/data', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function importData(payload: {
|
||||
data: AdminDataPayload
|
||||
}): Promise<AdminDataImportResult> {
|
||||
const { data } = await apiClient.post<AdminDataImportResult>('/admin/proxies/data', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const proxiesAPI = {
|
||||
list,
|
||||
getAll,
|
||||
@@ -221,7 +251,9 @@ export const proxiesAPI = {
|
||||
getStats,
|
||||
getProxyAccounts,
|
||||
batchCreate,
|
||||
batchDelete
|
||||
batchDelete,
|
||||
exportData,
|
||||
importData
|
||||
}
|
||||
|
||||
export default proxiesAPI
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
</button>
|
||||
<slot name="after"></slot>
|
||||
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
|
||||
<slot name="beforeCreate"></slot>
|
||||
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
|
||||
<slot name="afterCreate"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
187
frontend/src/components/admin/account/ImportDataModal.vue
Normal file
187
frontend/src/components/admin/account/ImportDataModal.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.accounts.dataImportTitle')"
|
||||
width="normal"
|
||||
close-on-click-outside
|
||||
@close="handleClose"
|
||||
>
|
||||
<form id="import-data-form" class="space-y-4" @submit.prevent="handleImport">
|
||||
<div class="text-sm text-gray-600 dark:text-dark-300">
|
||||
{{ t('admin.accounts.dataImportHint') }}
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
|
||||
>
|
||||
{{ t('admin.accounts.dataImportWarning') }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.dataImportFile') }}</label>
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm text-gray-700 dark:text-dark-200">
|
||||
{{ fileName || t('admin.accounts.dataImportSelectFile') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">JSON (.json)</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary shrink-0" @click="openFilePicker">
|
||||
{{ t('common.chooseFile') }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="application/json,.json"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="result"
|
||||
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.dataImportResult') }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-dark-300">
|
||||
{{ t('admin.accounts.dataImportResultSummary', result) }}
|
||||
</div>
|
||||
|
||||
<div v-if="errorItems.length" class="mt-2">
|
||||
<div class="text-sm font-medium text-red-600 dark:text-red-400">
|
||||
{{ t('admin.accounts.dataImportErrors') }}
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
|
||||
>
|
||||
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
|
||||
{{ item.kind }} {{ item.name || item.proxy_key || '-' }} — {{ item.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button class="btn btn-secondary" type="button" :disabled="importing" @click="handleClose">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
form="import-data-form"
|
||||
:disabled="importing"
|
||||
>
|
||||
{{ importing ? t('admin.accounts.dataImporting') : t('admin.accounts.dataImportButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import type { AdminDataImportResult } from '@/types'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'imported'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const importing = ref(false)
|
||||
const file = ref<File | null>(null)
|
||||
const result = ref<AdminDataImportResult | null>(null)
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const fileName = computed(() => file.value?.name || '')
|
||||
|
||||
const errorItems = computed(() => result.value?.errors || [])
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
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
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (importing.value) return
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
appStore.showError(t('admin.accounts.dataImportSelectFile'))
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await file.value.text()
|
||||
const dataPayload = JSON.parse(text)
|
||||
|
||||
const res = await adminAPI.accounts.importData({
|
||||
data: dataPayload,
|
||||
skip_default_group_bind: true
|
||||
})
|
||||
|
||||
result.value = res
|
||||
|
||||
const msgParams: Record<string, unknown> = {
|
||||
account_created: res.account_created,
|
||||
account_failed: res.account_failed,
|
||||
proxy_created: res.proxy_created,
|
||||
proxy_reused: res.proxy_reused,
|
||||
proxy_failed: res.proxy_failed,
|
||||
}
|
||||
if (res.account_failed > 0 || res.proxy_failed > 0) {
|
||||
appStore.showError(t('admin.accounts.dataImportCompletedWithErrors', msgParams))
|
||||
} else {
|
||||
appStore.showSuccess(t('admin.accounts.dataImportSuccess', msgParams))
|
||||
emit('imported')
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error instanceof SyntaxError) {
|
||||
appStore.showError(t('admin.accounts.dataImportParseFailed'))
|
||||
} else {
|
||||
appStore.showError(error?.message || t('admin.accounts.dataImportFailed'))
|
||||
}
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
183
frontend/src/components/admin/proxy/ImportDataModal.vue
Normal file
183
frontend/src/components/admin/proxy/ImportDataModal.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.proxies.dataImportTitle')"
|
||||
width="normal"
|
||||
close-on-click-outside
|
||||
@close="handleClose"
|
||||
>
|
||||
<form id="import-proxy-data-form" class="space-y-4" @submit.prevent="handleImport">
|
||||
<div class="text-sm text-gray-600 dark:text-dark-300">
|
||||
{{ t('admin.proxies.dataImportHint') }}
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
|
||||
>
|
||||
{{ t('admin.proxies.dataImportWarning') }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.dataImportFile') }}</label>
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm text-gray-700 dark:text-dark-200">
|
||||
{{ fileName || t('admin.proxies.dataImportSelectFile') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">JSON (.json)</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary shrink-0" @click="openFilePicker">
|
||||
{{ t('common.chooseFile') }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="application/json,.json"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="result"
|
||||
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.proxies.dataImportResult') }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-dark-300">
|
||||
{{ t('admin.proxies.dataImportResultSummary', result) }}
|
||||
</div>
|
||||
|
||||
<div v-if="errorItems.length" class="mt-2">
|
||||
<div class="text-sm font-medium text-red-600 dark:text-red-400">
|
||||
{{ t('admin.proxies.dataImportErrors') }}
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
|
||||
>
|
||||
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
|
||||
{{ item.kind }} {{ item.name || item.proxy_key || '-' }} — {{ item.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button class="btn btn-secondary" type="button" :disabled="importing" @click="handleClose">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
form="import-proxy-data-form"
|
||||
:disabled="importing"
|
||||
>
|
||||
{{ importing ? t('admin.proxies.dataImporting') : t('admin.proxies.dataImportButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import type { AdminDataImportResult } from '@/types'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'imported'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const importing = ref(false)
|
||||
const file = ref<File | null>(null)
|
||||
const result = ref<AdminDataImportResult | null>(null)
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const fileName = computed(() => file.value?.name || '')
|
||||
|
||||
const errorItems = computed(() => result.value?.errors || [])
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
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
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (importing.value) return
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
appStore.showError(t('admin.proxies.dataImportSelectFile'))
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await file.value.text()
|
||||
const dataPayload = JSON.parse(text)
|
||||
|
||||
const res = await adminAPI.proxies.importData({ data: dataPayload })
|
||||
|
||||
result.value = res
|
||||
|
||||
const msgParams: Record<string, unknown> = {
|
||||
proxy_created: res.proxy_created,
|
||||
proxy_reused: res.proxy_reused,
|
||||
proxy_failed: res.proxy_failed
|
||||
}
|
||||
|
||||
if (res.proxy_failed > 0) {
|
||||
appStore.showError(t('admin.proxies.dataImportCompletedWithErrors', msgParams))
|
||||
} else {
|
||||
appStore.showSuccess(t('admin.proxies.dataImportSuccess', msgParams))
|
||||
emit('imported')
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error instanceof SyntaxError) {
|
||||
appStore.showError(t('admin.proxies.dataImportParseFailed'))
|
||||
} else {
|
||||
appStore.showError(error?.message || t('admin.proxies.dataImportFailed'))
|
||||
}
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,6 +2,7 @@
|
||||
<BaseDialog :show="show" :title="title" width="narrow" @close="handleCancel">
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ message }}</p>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
|
||||
@@ -165,6 +165,7 @@ export default {
|
||||
selectedCount: '({count} selected)',
|
||||
refresh: 'Refresh',
|
||||
settings: 'Settings',
|
||||
chooseFile: 'Choose File',
|
||||
notAvailable: 'N/A',
|
||||
now: 'Now',
|
||||
unknown: 'Unknown',
|
||||
@@ -1198,6 +1199,28 @@ export default {
|
||||
refreshInterval30s: '30 seconds',
|
||||
autoRefreshCountdown: 'Auto refresh: {seconds}s',
|
||||
syncFromCrs: 'Sync from CRS',
|
||||
dataExport: 'Export',
|
||||
dataExportSelected: 'Export Selected',
|
||||
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',
|
||||
dataExported: 'Data exported successfully',
|
||||
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 existing data does not conflict.',
|
||||
dataImportFile: 'Data file',
|
||||
dataImportButton: 'Start Import',
|
||||
dataImporting: 'Importing...',
|
||||
dataImportSelectFile: 'Please select a data file',
|
||||
dataImportParseFailed: 'Failed to parse data file',
|
||||
dataImportFailed: 'Data import failed',
|
||||
dataImportResult: 'Import Result',
|
||||
dataImportResultSummary: 'Proxies created {proxy_created}, reused {proxy_reused}, failed {proxy_failed}; Accounts created {account_created}, failed {account_failed}',
|
||||
dataImportErrors: 'Error Details',
|
||||
dataImportSuccess: 'Import completed: accounts {account_created}, failed {account_failed}',
|
||||
dataImportCompletedWithErrors: 'Import completed with errors: account failed {account_failed}, proxy failed {proxy_failed}',
|
||||
syncFromCrsTitle: 'Sync Accounts from CRS',
|
||||
syncFromCrsDesc:
|
||||
'Sync accounts from claude-relay-service (CRS) into this system (CRS is called server-to-server).',
|
||||
@@ -1889,6 +1912,27 @@ export default {
|
||||
createProxy: 'Create Proxy',
|
||||
editProxy: 'Edit Proxy',
|
||||
deleteProxy: 'Delete Proxy',
|
||||
dataImport: 'Import',
|
||||
dataExportSelected: 'Export Selected',
|
||||
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',
|
||||
dataExported: 'Data exported successfully',
|
||||
dataExportFailed: 'Failed to export data',
|
||||
searchProxies: 'Search proxies...',
|
||||
allProtocols: 'All Protocols',
|
||||
allStatus: 'All Status',
|
||||
|
||||
@@ -162,6 +162,7 @@ export default {
|
||||
selectedCount: '(已选 {count} 个)',
|
||||
refresh: '刷新',
|
||||
settings: '设置',
|
||||
chooseFile: '选择文件',
|
||||
notAvailable: '不可用',
|
||||
now: '现在',
|
||||
unknown: '未知',
|
||||
@@ -1283,6 +1284,28 @@ export default {
|
||||
refreshInterval30s: '30 秒',
|
||||
autoRefreshCountdown: '自动刷新:{seconds}s',
|
||||
syncFromCrs: '从 CRS 同步',
|
||||
dataExport: '导出',
|
||||
dataExportSelected: '导出选中',
|
||||
dataExportIncludeProxies: '导出代理(导出账号关联的代理)',
|
||||
dataImport: '导入',
|
||||
dataExportConfirmMessage: '导出的数据包含账号与代理的敏感信息,请妥善保存。',
|
||||
dataExportConfirm: '确认导出',
|
||||
dataExported: '数据导出成功',
|
||||
dataExportFailed: '数据导出失败',
|
||||
dataImportTitle: '导入数据',
|
||||
dataImportHint: '上传导出的 JSON 文件以批量导入账号与代理。',
|
||||
dataImportWarning: '导入将创建新账号与代理,分组需手工绑定;请确认已有数据不会冲突。',
|
||||
dataImportFile: '数据文件',
|
||||
dataImportButton: '开始导入',
|
||||
dataImporting: '导入中...',
|
||||
dataImportSelectFile: '请选择数据文件',
|
||||
dataImportParseFailed: '数据解析失败',
|
||||
dataImportFailed: '数据导入失败',
|
||||
dataImportResult: '导入结果',
|
||||
dataImportResultSummary: '代理创建 {proxy_created},复用 {proxy_reused},失败 {proxy_failed};账号创建 {account_created},失败 {account_failed}',
|
||||
dataImportErrors: '失败详情',
|
||||
dataImportSuccess: '导入完成:账号 {account_created},失败 {account_failed}',
|
||||
dataImportCompletedWithErrors: '导入完成但有错误:账号失败 {account_failed},代理失败 {proxy_failed}',
|
||||
syncFromCrsTitle: '从 CRS 同步账号',
|
||||
syncFromCrsDesc:
|
||||
'将 claude-relay-service(CRS)中的账号同步到当前系统(不会在浏览器侧直接请求 CRS)。',
|
||||
@@ -1998,6 +2021,27 @@ export default {
|
||||
deleteProxy: '删除代理',
|
||||
deleteConfirmMessage: "确定要删除代理 '{name}' 吗?",
|
||||
testProxy: '测试代理',
|
||||
dataImport: '导入',
|
||||
dataExportSelected: '导出选中',
|
||||
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: '确认导出',
|
||||
dataExported: '数据导出成功',
|
||||
dataExportFailed: '数据导出失败',
|
||||
columns: {
|
||||
name: '名称',
|
||||
protocol: '协议',
|
||||
|
||||
@@ -729,6 +729,56 @@ export interface UpdateProxyRequest {
|
||||
status?: 'active' | 'inactive'
|
||||
}
|
||||
|
||||
export interface AdminDataPayload {
|
||||
type?: string
|
||||
version?: number
|
||||
exported_at: string
|
||||
proxies: AdminDataProxy[]
|
||||
accounts: AdminDataAccount[]
|
||||
}
|
||||
|
||||
export interface AdminDataProxy {
|
||||
proxy_key: string
|
||||
name: string
|
||||
protocol: ProxyProtocol
|
||||
host: string
|
||||
port: number
|
||||
username?: string | null
|
||||
password?: string | null
|
||||
status: 'active' | 'inactive'
|
||||
}
|
||||
|
||||
export interface AdminDataAccount {
|
||||
name: string
|
||||
notes?: string | null
|
||||
platform: AccountPlatform
|
||||
type: AccountType
|
||||
credentials: Record<string, unknown>
|
||||
extra?: Record<string, unknown>
|
||||
proxy_key?: string | null
|
||||
concurrency: number
|
||||
priority: number
|
||||
rate_multiplier?: number | null
|
||||
expires_at?: number | null
|
||||
auto_pause_on_expired?: boolean
|
||||
}
|
||||
|
||||
export interface AdminDataImportError {
|
||||
kind: 'proxy' | 'account'
|
||||
name?: string
|
||||
proxy_key?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface AdminDataImportResult {
|
||||
proxy_created: number
|
||||
proxy_reused: number
|
||||
proxy_failed: number
|
||||
account_created: number
|
||||
account_failed: number
|
||||
errors?: AdminDataImportError[]
|
||||
}
|
||||
|
||||
// ==================== Usage & Redeem Types ====================
|
||||
|
||||
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
|
||||
|
||||
@@ -106,6 +106,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #beforeCreate>
|
||||
<button @click="showImportData = true" class="btn btn-secondary">
|
||||
{{ t('admin.accounts.dataImport') }}
|
||||
</button>
|
||||
<button @click="openExportDataDialog" class="btn btn-secondary">
|
||||
{{ selIds.length ? t('admin.accounts.dataExportSelected') : t('admin.accounts.dataExport') }}
|
||||
</button>
|
||||
</template>
|
||||
</AccountTableActions>
|
||||
</div>
|
||||
</template>
|
||||
@@ -120,6 +128,15 @@
|
||||
default-sort-order="asc"
|
||||
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
|
||||
>
|
||||
<template #header-select>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
:checked="allVisibleSelected"
|
||||
@click.stop
|
||||
@change="toggleSelectAllVisible($event)"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-select="{ row }">
|
||||
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
||||
</template>
|
||||
@@ -228,9 +245,16 @@
|
||||
<AccountStatsModal :show="showStats" :account="statsAcc" @close="closeStatsModal" />
|
||||
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @reauth="handleReAuth" @refresh-token="handleRefresh" @reset-status="handleResetStatus" @clear-rate-limit="handleClearRateLimit" />
|
||||
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
|
||||
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
|
||||
<BulkEditAccountModal :show="showBulkEdit" :account-ids="selIds" :proxies="proxies" :groups="groups" @close="showBulkEdit = false" @updated="handleBulkUpdated" />
|
||||
<TempUnschedStatusModal :show="showTempUnsched" :account="tempUnschedAcc" @close="showTempUnsched = false" @reset="handleTempUnschedReset" />
|
||||
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.accounts.deleteAccount')" :message="t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })" :confirm-text="t('common.delete')" :cancel-text="t('common.cancel')" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
|
||||
<ConfirmDialog :show="showExportDataDialog" :title="t('admin.accounts.dataExport')" :message="t('admin.accounts.dataExportConfirmMessage')" :confirm-text="t('admin.accounts.dataExportConfirm')" :cancel-text="t('common.cancel')" @confirm="handleExportData" @cancel="showExportDataDialog = false">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" v-model="includeProxyOnExport" />
|
||||
<span>{{ t('admin.accounts.dataExportIncludeProxies') }}</span>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
<ErrorPassthroughRulesModal :show="showErrorPassthrough" @close="showErrorPassthrough = false" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -253,6 +277,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'
|
||||
@@ -277,6 +302,9 @@ const selIds = ref<number[]>([])
|
||||
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)
|
||||
@@ -292,6 +320,7 @@ const testingAcc = ref<Account | null>(null)
|
||||
const statsAcc = ref<Account | null>(null)
|
||||
const togglingSchedulable = ref<number | null>(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)
|
||||
@@ -418,12 +447,15 @@ const isAnyModalOpen = computed(() => {
|
||||
showCreate.value ||
|
||||
showEdit.value ||
|
||||
showSync.value ||
|
||||
showImportData.value ||
|
||||
showExportDataDialog.value ||
|
||||
showBulkEdit.value ||
|
||||
showTempUnsched.value ||
|
||||
showDeleteDialog.value ||
|
||||
showReAuth.value ||
|
||||
showTest.value ||
|
||||
showStats.value
|
||||
showStats.value ||
|
||||
showErrorPassthrough.value
|
||||
)
|
||||
})
|
||||
|
||||
@@ -542,6 +574,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) => {
|
||||
@@ -646,6 +693,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 }
|
||||
|
||||
@@ -2,47 +2,9 @@
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #filters>
|
||||
<!-- Top Toolbar: Left (search + filters) / Right (actions) -->
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<!-- Left: Fuzzy search + filters (wrap to multiple lines) -->
|
||||
<div class="flex flex-1 flex-wrap items-center gap-3">
|
||||
<!-- Search -->
|
||||
<div class="relative w-full sm:w-64">
|
||||
<Icon
|
||||
name="search"
|
||||
size="md"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.proxies.searchProxies')"
|
||||
class="input pl-10"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="w-full sm:w-40">
|
||||
<Select
|
||||
v-model="filters.protocol"
|
||||
:options="protocolOptions"
|
||||
:placeholder="t('admin.proxies.allProtocols')"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full sm:w-36">
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.proxies.allStatus')"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Actions -->
|
||||
<div class="ml-auto flex flex-wrap items-center justify-end gap-3">
|
||||
<div class="space-y-3">
|
||||
<!-- Row 1: Actions -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
@click="loadProxies"
|
||||
:disabled="loading"
|
||||
@@ -69,11 +31,52 @@
|
||||
<Icon name="trash" size="md" class="mr-2" />
|
||||
{{ t('admin.proxies.batchDeleteAction') }}
|
||||
</button>
|
||||
<button @click="showImportData = true" class="btn btn-secondary">
|
||||
{{ t('admin.proxies.dataImport') }}
|
||||
</button>
|
||||
<button @click="showExportDataDialog = true" class="btn btn-secondary">
|
||||
{{ selectedCount > 0 ? t('admin.proxies.dataExportSelected') : t('admin.proxies.dataExport') }}
|
||||
</button>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<Icon name="plus" size="md" class="mr-2" />
|
||||
{{ t('admin.proxies.createProxy') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Search + Filters -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="relative w-full sm:w-64">
|
||||
<Icon
|
||||
name="search"
|
||||
size="md"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.proxies.searchProxies')"
|
||||
class="input pl-10"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:w-40">
|
||||
<Select
|
||||
v-model="filters.protocol"
|
||||
:options="protocolOptions"
|
||||
:placeholder="t('admin.proxies.allProtocols')"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full sm:w-36">
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.proxies.allStatus')"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -606,6 +609,21 @@
|
||||
@confirm="confirmBatchDelete"
|
||||
@cancel="showBatchDeleteDialog = false"
|
||||
/>
|
||||
<ConfirmDialog
|
||||
:show="showExportDataDialog"
|
||||
:title="t('admin.proxies.dataExport')"
|
||||
:message="t('admin.proxies.dataExportConfirmMessage')"
|
||||
:confirm-text="t('admin.proxies.dataExportConfirm')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
@confirm="handleExportData"
|
||||
@cancel="showExportDataDialog = false"
|
||||
/>
|
||||
|
||||
<ImportDataModal
|
||||
:show="showImportData"
|
||||
@close="showImportData = false"
|
||||
@imported="handleDataImported"
|
||||
/>
|
||||
|
||||
<!-- Proxy Accounts Dialog -->
|
||||
<BaseDialog
|
||||
@@ -668,6 +686,7 @@ import Pagination from '@/components/common/Pagination.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import ImportDataModal from '@/components/admin/proxy/ImportDataModal.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
@@ -731,10 +750,13 @@ const pagination = reactive({
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showImportData = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showBatchDeleteDialog = ref(false)
|
||||
const showExportDataDialog = ref(false)
|
||||
const showAccountsModal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const exportingData = ref(false)
|
||||
const testingProxyIds = ref<Set<number>>(new Set())
|
||||
const batchTesting = ref(false)
|
||||
const selectedProxyIds = ref<Set<number>>(new Set())
|
||||
@@ -888,6 +910,11 @@ const closeCreateModal = () => {
|
||||
batchParseResult.proxies = []
|
||||
}
|
||||
|
||||
const handleDataImported = () => {
|
||||
showImportData.value = false
|
||||
loadProxies()
|
||||
}
|
||||
|
||||
// Parse proxy URL: protocol://user:pass@host:port or protocol://host:port
|
||||
const parseProxyUrl = (
|
||||
line: string
|
||||
@@ -1228,6 +1255,45 @@ const handleBatchTest = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
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 handleExportData = async () => {
|
||||
if (exportingData.value) return
|
||||
exportingData.value = true
|
||||
try {
|
||||
const dataPayload = await adminAPI.proxies.exportData(
|
||||
selectedCount.value > 0
|
||||
? { ids: Array.from(selectedProxyIds.value) }
|
||||
: {
|
||||
filters: {
|
||||
protocol: filters.protocol || undefined,
|
||||
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
|
||||
search: searchQuery.value || undefined
|
||||
}
|
||||
}
|
||||
)
|
||||
const timestamp = formatExportTimestamp()
|
||||
const filename = `sub2api-proxy-${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.proxies.dataExported'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(error?.message || t('admin.proxies.dataExportFailed'))
|
||||
} finally {
|
||||
exportingData.value = false
|
||||
showExportDataDialog.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (proxy: Proxy) => {
|
||||
if ((proxy.account_count || 0) > 0) {
|
||||
appStore.showError(t('admin.proxies.deleteBlockedInUse'))
|
||||
|
||||
Reference in New Issue
Block a user