feat: add data import/export bundle
This commit is contained in:
510
backend/internal/handler/admin/account_data.go
Normal file
510
backend/internal/handler/admin/account_data.go
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dataType = "sub2api-data"
|
||||||
|
legacyDataType = "sub2api-bundle"
|
||||||
|
dataVersion = 1
|
||||||
|
dataPageCap = 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
type DataPayload struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
ExportedAt string `json:"exported_at"`
|
||||||
|
Proxies []DataProxy `json:"proxies"`
|
||||||
|
Accounts []DataAccount `json:"accounts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataProxy struct {
|
||||||
|
ProxyKey string `json:"proxy_key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataAccount struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Notes *string `json:"notes,omitempty"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Credentials map[string]any `json:"credentials"`
|
||||||
|
Extra map[string]any `json:"extra,omitempty"`
|
||||||
|
ProxyKey *string `json:"proxy_key,omitempty"`
|
||||||
|
Concurrency int `json:"concurrency"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
RateMultiplier *float64 `json:"rate_multiplier,omitempty"`
|
||||||
|
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
||||||
|
AutoPauseOnExpired *bool `json:"auto_pause_on_expired,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataImportRequest struct {
|
||||||
|
Data DataPayload `json:"data"`
|
||||||
|
SkipDefaultGroupBind *bool `json:"skip_default_group_bind"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataImportResult struct {
|
||||||
|
ProxyCreated int `json:"proxy_created"`
|
||||||
|
ProxyReused int `json:"proxy_reused"`
|
||||||
|
ProxyFailed int `json:"proxy_failed"`
|
||||||
|
AccountCreated int `json:"account_created"`
|
||||||
|
AccountFailed int `json:"account_failed"`
|
||||||
|
Errors []DataImportError `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataImportError struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
ProxyKey string `json:"proxy_key,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildProxyKey(protocol, host string, port int, username, password string) string {
|
||||||
|
return fmt.Sprintf("%s|%s|%d|%s|%s", strings.TrimSpace(protocol), strings.TrimSpace(host), port, strings.TrimSpace(username), strings.TrimSpace(password))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AccountHandler) ExportData(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
selectedIDs, err := parseAccountIDs(c)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, err := h.resolveExportAccounts(ctx, selectedIDs, c)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
includeProxies, err := parseIncludeProxies(c)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var proxies []service.Proxy
|
||||||
|
if includeProxies {
|
||||||
|
proxies, err = h.resolveExportProxies(ctx, accounts)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
proxies = []service.Proxy{}
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyKeyByID := make(map[int64]string, len(proxies))
|
||||||
|
dataProxies := make([]DataProxy, 0, len(proxies))
|
||||||
|
for i := range proxies {
|
||||||
|
p := proxies[i]
|
||||||
|
key := buildProxyKey(p.Protocol, p.Host, p.Port, p.Username, p.Password)
|
||||||
|
proxyKeyByID[p.ID] = key
|
||||||
|
dataProxies = append(dataProxies, DataProxy{
|
||||||
|
ProxyKey: key,
|
||||||
|
Name: p.Name,
|
||||||
|
Protocol: p.Protocol,
|
||||||
|
Host: p.Host,
|
||||||
|
Port: p.Port,
|
||||||
|
Username: p.Username,
|
||||||
|
Password: p.Password,
|
||||||
|
Status: p.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dataAccounts := make([]DataAccount, 0, len(accounts))
|
||||||
|
for i := range accounts {
|
||||||
|
acc := accounts[i]
|
||||||
|
var proxyKey *string
|
||||||
|
if acc.ProxyID != nil {
|
||||||
|
if key, ok := proxyKeyByID[*acc.ProxyID]; ok {
|
||||||
|
proxyKey = &key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var expiresAt *int64
|
||||||
|
if acc.ExpiresAt != nil {
|
||||||
|
v := acc.ExpiresAt.Unix()
|
||||||
|
expiresAt = &v
|
||||||
|
}
|
||||||
|
dataAccounts = append(dataAccounts, DataAccount{
|
||||||
|
Name: acc.Name,
|
||||||
|
Notes: acc.Notes,
|
||||||
|
Platform: acc.Platform,
|
||||||
|
Type: acc.Type,
|
||||||
|
Credentials: acc.Credentials,
|
||||||
|
Extra: acc.Extra,
|
||||||
|
ProxyKey: proxyKey,
|
||||||
|
Concurrency: acc.Concurrency,
|
||||||
|
Priority: acc.Priority,
|
||||||
|
RateMultiplier: acc.RateMultiplier,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
AutoPauseOnExpired: &acc.AutoPauseOnExpired,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := DataPayload{
|
||||||
|
Type: dataType,
|
||||||
|
Version: dataVersion,
|
||||||
|
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Proxies: dataProxies,
|
||||||
|
Accounts: dataAccounts,
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AccountHandler) ImportData(c *gin.Context) {
|
||||||
|
var req DataImportRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dataPayload := req.Data
|
||||||
|
if err := validateDataHeader(dataPayload); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
skipDefaultGroupBind := true
|
||||||
|
if req.SkipDefaultGroupBind != nil {
|
||||||
|
skipDefaultGroupBind = *req.SkipDefaultGroupBind
|
||||||
|
}
|
||||||
|
|
||||||
|
result := DataImportResult{}
|
||||||
|
existingProxies, err := h.listAllProxies(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyKeyToID := make(map[string]int64, len(existingProxies))
|
||||||
|
for i := range existingProxies {
|
||||||
|
p := existingProxies[i]
|
||||||
|
key := buildProxyKey(p.Protocol, p.Host, p.Port, p.Username, p.Password)
|
||||||
|
proxyKeyToID[key] = p.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range dataPayload.Proxies {
|
||||||
|
item := dataPayload.Proxies[i]
|
||||||
|
key := item.ProxyKey
|
||||||
|
if key == "" {
|
||||||
|
key = buildProxyKey(item.Protocol, item.Host, item.Port, item.Username, item.Password)
|
||||||
|
}
|
||||||
|
if err := validateDataProxy(item); err != nil {
|
||||||
|
result.ProxyFailed++
|
||||||
|
result.Errors = append(result.Errors, DataImportError{
|
||||||
|
Kind: "proxy",
|
||||||
|
Name: item.Name,
|
||||||
|
ProxyKey: key,
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existingID, ok := proxyKeyToID[key]; ok {
|
||||||
|
proxyKeyToID[key] = existingID
|
||||||
|
result.ProxyReused++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := h.adminService.CreateProxy(c.Request.Context(), &service.CreateProxyInput{
|
||||||
|
Name: defaultProxyName(item.Name),
|
||||||
|
Protocol: item.Protocol,
|
||||||
|
Host: item.Host,
|
||||||
|
Port: item.Port,
|
||||||
|
Username: item.Username,
|
||||||
|
Password: item.Password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
result.ProxyFailed++
|
||||||
|
result.Errors = append(result.Errors, DataImportError{
|
||||||
|
Kind: "proxy",
|
||||||
|
Name: item.Name,
|
||||||
|
ProxyKey: key,
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
proxyKeyToID[key] = created.ID
|
||||||
|
result.ProxyCreated++
|
||||||
|
|
||||||
|
if item.Status != "" && item.Status != created.Status {
|
||||||
|
_, _ = h.adminService.UpdateProxy(c.Request.Context(), created.ID, &service.UpdateProxyInput{
|
||||||
|
Status: item.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range dataPayload.Accounts {
|
||||||
|
item := dataPayload.Accounts[i]
|
||||||
|
if err := validateDataAccount(item); err != nil {
|
||||||
|
result.AccountFailed++
|
||||||
|
result.Errors = append(result.Errors, DataImportError{
|
||||||
|
Kind: "account",
|
||||||
|
Name: item.Name,
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var proxyID *int64
|
||||||
|
if item.ProxyKey != nil && *item.ProxyKey != "" {
|
||||||
|
if id, ok := proxyKeyToID[*item.ProxyKey]; ok {
|
||||||
|
proxyID = &id
|
||||||
|
} else {
|
||||||
|
result.AccountFailed++
|
||||||
|
result.Errors = append(result.Errors, DataImportError{
|
||||||
|
Kind: "account",
|
||||||
|
Name: item.Name,
|
||||||
|
ProxyKey: *item.ProxyKey,
|
||||||
|
Message: "proxy_key not found",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accountInput := &service.CreateAccountInput{
|
||||||
|
Name: item.Name,
|
||||||
|
Notes: item.Notes,
|
||||||
|
Platform: item.Platform,
|
||||||
|
Type: item.Type,
|
||||||
|
Credentials: item.Credentials,
|
||||||
|
Extra: item.Extra,
|
||||||
|
ProxyID: proxyID,
|
||||||
|
Concurrency: item.Concurrency,
|
||||||
|
Priority: item.Priority,
|
||||||
|
RateMultiplier: item.RateMultiplier,
|
||||||
|
GroupIDs: nil,
|
||||||
|
ExpiresAt: item.ExpiresAt,
|
||||||
|
AutoPauseOnExpired: item.AutoPauseOnExpired,
|
||||||
|
SkipDefaultGroupBind: skipDefaultGroupBind,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.adminService.CreateAccount(c.Request.Context(), accountInput); err != nil {
|
||||||
|
result.AccountFailed++
|
||||||
|
result.Errors = append(result.Errors, DataImportError{
|
||||||
|
Kind: "account",
|
||||||
|
Name: item.Name,
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.AccountCreated++
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AccountHandler) listAllAccounts(ctx context.Context) ([]service.Account, error) {
|
||||||
|
page := 1
|
||||||
|
pageSize := dataPageCap
|
||||||
|
var out []service.Account
|
||||||
|
for {
|
||||||
|
items, total, err := h.adminService.ListAccounts(ctx, page, pageSize, "", "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, items...)
|
||||||
|
if len(out) >= int(total) || len(items) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AccountHandler) listAllProxies(ctx context.Context) ([]service.Proxy, error) {
|
||||||
|
page := 1
|
||||||
|
pageSize := dataPageCap
|
||||||
|
var out []service.Proxy
|
||||||
|
for {
|
||||||
|
items, total, err := h.adminService.ListProxies(ctx, page, pageSize, "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, items...)
|
||||||
|
if len(out) >= int(total) || len(items) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AccountHandler) listAccountsFiltered(ctx context.Context, platform, accountType, status, search string) ([]service.Account, error) {
|
||||||
|
page := 1
|
||||||
|
pageSize := dataPageCap
|
||||||
|
var out []service.Account
|
||||||
|
for {
|
||||||
|
items, total, err := h.adminService.ListAccounts(ctx, page, pageSize, platform, accountType, status, search)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, items...)
|
||||||
|
if len(out) >= int(total) || len(items) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AccountHandler) resolveExportAccounts(ctx context.Context, ids []int64, c *gin.Context) ([]service.Account, error) {
|
||||||
|
if len(ids) > 0 {
|
||||||
|
accounts, err := h.adminService.GetAccountsByIDs(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]service.Account, 0, len(accounts))
|
||||||
|
for _, acc := range accounts {
|
||||||
|
if acc == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, *acc)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
platform := c.Query("platform")
|
||||||
|
accountType := c.Query("type")
|
||||||
|
status := c.Query("status")
|
||||||
|
search := strings.TrimSpace(c.Query("search"))
|
||||||
|
if len(search) > 100 {
|
||||||
|
search = search[:100]
|
||||||
|
}
|
||||||
|
return h.listAccountsFiltered(ctx, platform, accountType, status, search)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AccountHandler) resolveExportProxies(ctx context.Context, accounts []service.Account) ([]service.Proxy, error) {
|
||||||
|
_ = accounts
|
||||||
|
return h.listAllProxies(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAccountIDs(c *gin.Context) ([]int64, error) {
|
||||||
|
values := c.QueryArray("ids")
|
||||||
|
if len(values) == 0 {
|
||||||
|
raw := strings.TrimSpace(c.Query("ids"))
|
||||||
|
if raw != "" {
|
||||||
|
values = []string{raw}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]int64, 0, len(values))
|
||||||
|
for _, item := range values {
|
||||||
|
for _, part := range strings.Split(item, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(part, 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid account id: %s", part)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIncludeProxies(c *gin.Context) (bool, error) {
|
||||||
|
raw := strings.TrimSpace(strings.ToLower(c.Query("include_proxies")))
|
||||||
|
if raw == "" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
switch raw {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true, nil
|
||||||
|
case "0", "false", "no", "off":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return true, fmt.Errorf("invalid include_proxies value: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDataHeader(payload DataPayload) error {
|
||||||
|
if payload.Type == "" {
|
||||||
|
return errors.New("data type is required")
|
||||||
|
}
|
||||||
|
if payload.Type != dataType && payload.Type != legacyDataType {
|
||||||
|
return fmt.Errorf("unsupported data type: %s", payload.Type)
|
||||||
|
}
|
||||||
|
if payload.Version != dataVersion {
|
||||||
|
return fmt.Errorf("unsupported data version: %d", payload.Version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDataProxy(item DataProxy) error {
|
||||||
|
if strings.TrimSpace(item.Protocol) == "" {
|
||||||
|
return errors.New("proxy protocol is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(item.Host) == "" {
|
||||||
|
return errors.New("proxy host is required")
|
||||||
|
}
|
||||||
|
if item.Port <= 0 || item.Port > 65535 {
|
||||||
|
return errors.New("proxy port is invalid")
|
||||||
|
}
|
||||||
|
switch item.Protocol {
|
||||||
|
case "http", "https", "socks5", "socks5h":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("proxy protocol is invalid: %s", item.Protocol)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDataAccount(item DataAccount) error {
|
||||||
|
if strings.TrimSpace(item.Name) == "" {
|
||||||
|
return errors.New("account name is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(item.Platform) == "" {
|
||||||
|
return errors.New("account platform is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(item.Type) == "" {
|
||||||
|
return errors.New("account type is required")
|
||||||
|
}
|
||||||
|
if len(item.Credentials) == 0 {
|
||||||
|
return errors.New("account credentials is required")
|
||||||
|
}
|
||||||
|
switch item.Type {
|
||||||
|
case service.AccountTypeOAuth, service.AccountTypeSetupToken, service.AccountTypeAPIKey, service.AccountTypeUpstream:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("account type is invalid: %s", item.Type)
|
||||||
|
}
|
||||||
|
if item.RateMultiplier != nil && *item.RateMultiplier < 0 {
|
||||||
|
return errors.New("rate_multiplier must be >= 0")
|
||||||
|
}
|
||||||
|
if item.Concurrency < 0 {
|
||||||
|
return errors.New("concurrency must be >= 0")
|
||||||
|
}
|
||||||
|
if item.Priority < 0 {
|
||||||
|
return errors.New("priority must be >= 0")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultProxyName(name string) string {
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return "imported-proxy"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
230
backend/internal/handler/admin/account_data_handler_test.go
Normal file
230
backend/internal/handler/admin/account_data_handler_test.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dataResponse struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data dataPayload `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dataPayload struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
Proxies []dataProxy `json:"proxies"`
|
||||||
|
Accounts []dataAccount `json:"accounts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dataProxy struct {
|
||||||
|
ProxyKey string `json:"proxy_key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dataAccount struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Credentials map[string]any `json:"credentials"`
|
||||||
|
Extra map[string]any `json:"extra"`
|
||||||
|
ProxyKey *string `json:"proxy_key"`
|
||||||
|
Concurrency int `json:"concurrency"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupAccountDataRouter() (*gin.Engine, *stubAdminService) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
adminSvc := newStubAdminService()
|
||||||
|
|
||||||
|
h := NewAccountHandler(
|
||||||
|
adminSvc,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
router.GET("/api/v1/admin/accounts/data", h.ExportData)
|
||||||
|
router.POST("/api/v1/admin/accounts/data", h.ImportData)
|
||||||
|
return router, adminSvc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportDataIncludesSecrets(t *testing.T) {
|
||||||
|
router, adminSvc := setupAccountDataRouter()
|
||||||
|
|
||||||
|
proxyID := int64(11)
|
||||||
|
adminSvc.proxies = []service.Proxy{
|
||||||
|
{
|
||||||
|
ID: proxyID,
|
||||||
|
Name: "proxy",
|
||||||
|
Protocol: "http",
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
Port: 8080,
|
||||||
|
Username: "user",
|
||||||
|
Password: "pass",
|
||||||
|
Status: service.StatusActive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 12,
|
||||||
|
Name: "orphan",
|
||||||
|
Protocol: "https",
|
||||||
|
Host: "10.0.0.1",
|
||||||
|
Port: 443,
|
||||||
|
Username: "o",
|
||||||
|
Password: "p",
|
||||||
|
Status: service.StatusActive,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
adminSvc.accounts = []service.Account{
|
||||||
|
{
|
||||||
|
ID: 21,
|
||||||
|
Name: "account",
|
||||||
|
Platform: service.PlatformOpenAI,
|
||||||
|
Type: service.AccountTypeOAuth,
|
||||||
|
Credentials: map[string]any{"token": "secret"},
|
||||||
|
Extra: map[string]any{"note": "x"},
|
||||||
|
ProxyID: &proxyID,
|
||||||
|
Concurrency: 3,
|
||||||
|
Priority: 50,
|
||||||
|
Status: service.StatusDisabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/data", nil)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
|
var resp dataResponse
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
require.Equal(t, 0, resp.Code)
|
||||||
|
require.Equal(t, dataType, resp.Data.Type)
|
||||||
|
require.Len(t, resp.Data.Proxies, 2)
|
||||||
|
require.Equal(t, "pass", resp.Data.Proxies[0].Password)
|
||||||
|
require.Len(t, resp.Data.Accounts, 1)
|
||||||
|
require.Equal(t, "secret", resp.Data.Accounts[0].Credentials["token"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportDataWithoutProxies(t *testing.T) {
|
||||||
|
router, adminSvc := setupAccountDataRouter()
|
||||||
|
|
||||||
|
proxyID := int64(11)
|
||||||
|
adminSvc.proxies = []service.Proxy{
|
||||||
|
{
|
||||||
|
ID: proxyID,
|
||||||
|
Name: "proxy",
|
||||||
|
Protocol: "http",
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
Port: 8080,
|
||||||
|
Username: "user",
|
||||||
|
Password: "pass",
|
||||||
|
Status: service.StatusActive,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
adminSvc.accounts = []service.Account{
|
||||||
|
{
|
||||||
|
ID: 21,
|
||||||
|
Name: "account",
|
||||||
|
Platform: service.PlatformOpenAI,
|
||||||
|
Type: service.AccountTypeOAuth,
|
||||||
|
Credentials: map[string]any{"token": "secret"},
|
||||||
|
ProxyID: &proxyID,
|
||||||
|
Concurrency: 3,
|
||||||
|
Priority: 50,
|
||||||
|
Status: service.StatusDisabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/data?include_proxies=false", nil)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
|
var resp dataResponse
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
require.Equal(t, 0, resp.Code)
|
||||||
|
require.Len(t, resp.Data.Proxies, 0)
|
||||||
|
require.Len(t, resp.Data.Accounts, 1)
|
||||||
|
require.Nil(t, resp.Data.Accounts[0].ProxyKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportDataReusesProxyAndSkipsDefaultGroup(t *testing.T) {
|
||||||
|
router, adminSvc := setupAccountDataRouter()
|
||||||
|
|
||||||
|
adminSvc.proxies = []service.Proxy{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Name: "proxy",
|
||||||
|
Protocol: "socks5",
|
||||||
|
Host: "1.2.3.4",
|
||||||
|
Port: 1080,
|
||||||
|
Username: "u",
|
||||||
|
Password: "p",
|
||||||
|
Status: service.StatusActive,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
dataPayload := map[string]any{
|
||||||
|
"data": map[string]any{
|
||||||
|
"type": dataType,
|
||||||
|
"version": dataVersion,
|
||||||
|
"proxies": []map[string]any{
|
||||||
|
{
|
||||||
|
"proxy_key": "socks5|1.2.3.4|1080|u|p",
|
||||||
|
"name": "proxy",
|
||||||
|
"protocol": "socks5",
|
||||||
|
"host": "1.2.3.4",
|
||||||
|
"port": 1080,
|
||||||
|
"username": "u",
|
||||||
|
"password": "p",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"accounts": []map[string]any{
|
||||||
|
{
|
||||||
|
"name": "acc",
|
||||||
|
"platform": service.PlatformOpenAI,
|
||||||
|
"type": service.AccountTypeOAuth,
|
||||||
|
"credentials": map[string]any{"token": "x"},
|
||||||
|
"proxy_key": "socks5|1.2.3.4|1080|u|p",
|
||||||
|
"concurrency": 3,
|
||||||
|
"priority": 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"skip_default_group_bind": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(dataPayload)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/data", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
|
require.Len(t, adminSvc.createdProxies, 0)
|
||||||
|
require.Len(t, adminSvc.createdAccounts, 1)
|
||||||
|
require.True(t, adminSvc.createdAccounts[0].SkipDefaultGroupBind)
|
||||||
|
}
|
||||||
@@ -696,11 +696,61 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
|||||||
return
|
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{
|
response.Success(c, gin.H{
|
||||||
"success": len(req.Accounts),
|
"success": success,
|
||||||
"failed": 0,
|
"failed": failed,
|
||||||
"results": []gin.H{},
|
"results": results,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,22 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stubAdminService struct {
|
type stubAdminService struct {
|
||||||
users []service.User
|
users []service.User
|
||||||
apiKeys []service.APIKey
|
apiKeys []service.APIKey
|
||||||
groups []service.Group
|
groups []service.Group
|
||||||
accounts []service.Account
|
accounts []service.Account
|
||||||
proxies []service.Proxy
|
proxies []service.Proxy
|
||||||
proxyCounts []service.ProxyWithAccountCount
|
proxyCounts []service.ProxyWithAccountCount
|
||||||
redeems []service.RedeemCode
|
redeems []service.RedeemCode
|
||||||
|
createdAccounts []*service.CreateAccountInput
|
||||||
|
createdProxies []*service.CreateProxyInput
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStubAdminService() *stubAdminService {
|
func newStubAdminService() *stubAdminService {
|
||||||
@@ -177,6 +180,7 @@ func (s *stubAdminService) GetAccountsByIDs(ctx context.Context, ids []int64) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) {
|
func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) {
|
||||||
|
s.createdAccounts = append(s.createdAccounts, input)
|
||||||
account := service.Account{ID: 300, Name: input.Name, Status: service.StatusActive}
|
account := service.Account{ID: 300, Name: input.Name, Status: service.StatusActive}
|
||||||
return &account, nil
|
return &account, nil
|
||||||
}
|
}
|
||||||
@@ -214,7 +218,25 @@ func (s *stubAdminService) BulkUpdateAccounts(ctx context.Context, input *servic
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubAdminService) ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]service.Proxy, int64, error) {
|
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) {
|
func (s *stubAdminService) ListProxiesWithAccountCount(ctx context.Context, page, pageSize int, protocol, status, search string) ([]service.ProxyWithAccountCount, int64, error) {
|
||||||
@@ -235,6 +257,7 @@ func (s *stubAdminService) GetProxy(ctx context.Context, id int64) (*service.Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubAdminService) CreateProxy(ctx context.Context, input *service.CreateProxyInput) (*service.Proxy, error) {
|
func (s *stubAdminService) CreateProxy(ctx context.Context, input *service.CreateProxyInput) (*service.Proxy, error) {
|
||||||
|
s.createdProxies = append(s.createdProxies, input)
|
||||||
proxy := service.Proxy{ID: 400, Name: input.Name, Status: service.StatusActive}
|
proxy := service.Proxy{ID: 400, Name: input.Name, Status: service.StatusActive}
|
||||||
return &proxy, nil
|
return &proxy, nil
|
||||||
}
|
}
|
||||||
|
|||||||
73
backend/internal/handler/admin/proxy_data.go
Normal file
73
backend/internal/handler/admin/proxy_data.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExportData exports proxy-only data for migration.
|
||||||
|
func (h *ProxyHandler) ExportData(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
protocol := c.Query("protocol")
|
||||||
|
status := c.Query("status")
|
||||||
|
search := strings.TrimSpace(c.Query("search"))
|
||||||
|
if len(search) > 100 {
|
||||||
|
search = search[:100]
|
||||||
|
}
|
||||||
|
|
||||||
|
proxies, err := h.listProxiesFiltered(ctx, protocol, status, search)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dataProxies := make([]DataProxy, 0, len(proxies))
|
||||||
|
for i := range proxies {
|
||||||
|
p := proxies[i]
|
||||||
|
key := buildProxyKey(p.Protocol, p.Host, p.Port, p.Username, p.Password)
|
||||||
|
dataProxies = append(dataProxies, DataProxy{
|
||||||
|
ProxyKey: key,
|
||||||
|
Name: p.Name,
|
||||||
|
Protocol: p.Protocol,
|
||||||
|
Host: p.Host,
|
||||||
|
Port: p.Port,
|
||||||
|
Username: p.Username,
|
||||||
|
Password: p.Password,
|
||||||
|
Status: p.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := DataPayload{
|
||||||
|
Type: dataType,
|
||||||
|
Version: dataVersion,
|
||||||
|
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Proxies: dataProxies,
|
||||||
|
Accounts: []DataAccount{},
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProxyHandler) listProxiesFiltered(ctx context.Context, protocol, status, search string) ([]service.Proxy, error) {
|
||||||
|
page := 1
|
||||||
|
pageSize := dataPageCap
|
||||||
|
var out []service.Proxy
|
||||||
|
for {
|
||||||
|
items, total, err := h.adminService.ListProxies(ctx, page, pageSize, protocol, status, search)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, items...)
|
||||||
|
if len(out) >= int(total) || len(items) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
68
backend/internal/handler/admin/proxy_data_handler_test.go
Normal file
68
backend/internal/handler/admin/proxy_data_handler_test.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type proxyDataResponse struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data DataPayload `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupProxyDataRouter() (*gin.Engine, *stubAdminService) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
adminSvc := newStubAdminService()
|
||||||
|
|
||||||
|
h := NewProxyHandler(adminSvc)
|
||||||
|
router.GET("/api/v1/admin/proxies/data", h.ExportData)
|
||||||
|
|
||||||
|
return router, adminSvc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyExportDataRespectsFilters(t *testing.T) {
|
||||||
|
router, adminSvc := setupProxyDataRouter()
|
||||||
|
|
||||||
|
adminSvc.proxies = []service.Proxy{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Name: "proxy-a",
|
||||||
|
Protocol: "http",
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
Port: 8080,
|
||||||
|
Username: "user",
|
||||||
|
Password: "pass",
|
||||||
|
Status: service.StatusActive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Name: "proxy-b",
|
||||||
|
Protocol: "https",
|
||||||
|
Host: "10.0.0.2",
|
||||||
|
Port: 443,
|
||||||
|
Username: "u",
|
||||||
|
Password: "p",
|
||||||
|
Status: service.StatusDisabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/proxies/data?protocol=https", nil)
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
|
var resp proxyDataResponse
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
require.Equal(t, 0, resp.Code)
|
||||||
|
require.Equal(t, dataType, resp.Data.Type)
|
||||||
|
require.Len(t, resp.Data.Proxies, 1)
|
||||||
|
require.Len(t, resp.Data.Accounts, 0)
|
||||||
|
require.Equal(t, "https", resp.Data.Proxies[0].Protocol)
|
||||||
|
}
|
||||||
@@ -219,6 +219,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
|
accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
|
||||||
accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels)
|
accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels)
|
||||||
accounts.POST("/batch", h.Admin.Account.BatchCreate)
|
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-update-credentials", h.Admin.Account.BatchUpdateCredentials)
|
||||||
accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier)
|
accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier)
|
||||||
accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate)
|
accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate)
|
||||||
@@ -278,6 +280,7 @@ func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
{
|
{
|
||||||
proxies.GET("", h.Admin.Proxy.List)
|
proxies.GET("", h.Admin.Proxy.List)
|
||||||
proxies.GET("/all", h.Admin.Proxy.GetAll)
|
proxies.GET("/all", h.Admin.Proxy.GetAll)
|
||||||
|
proxies.GET("/data", h.Admin.Proxy.ExportData)
|
||||||
proxies.GET("/:id", h.Admin.Proxy.GetByID)
|
proxies.GET("/:id", h.Admin.Proxy.GetByID)
|
||||||
proxies.POST("", h.Admin.Proxy.Create)
|
proxies.POST("", h.Admin.Proxy.Create)
|
||||||
proxies.PUT("/:id", h.Admin.Proxy.Update)
|
proxies.PUT("/:id", h.Admin.Proxy.Update)
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ type CreateAccountInput struct {
|
|||||||
GroupIDs []int64
|
GroupIDs []int64
|
||||||
ExpiresAt *int64
|
ExpiresAt *int64
|
||||||
AutoPauseOnExpired *bool
|
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.
|
// SkipMixedChannelCheck skips the mixed channel risk check when binding groups.
|
||||||
// This should only be set when the caller has explicitly confirmed the risk.
|
// This should only be set when the caller has explicitly confirmed the risk.
|
||||||
SkipMixedChannelCheck bool
|
SkipMixedChannelCheck bool
|
||||||
@@ -1004,7 +1006,7 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
|
|||||||
// 绑定分组
|
// 绑定分组
|
||||||
groupIDs := input.GroupIDs
|
groupIDs := input.GroupIDs
|
||||||
// 如果没有指定分组,自动绑定对应平台的默认分组
|
// 如果没有指定分组,自动绑定对应平台的默认分组
|
||||||
if len(groupIDs) == 0 {
|
if len(groupIDs) == 0 && !input.SkipDefaultGroupBind {
|
||||||
defaultGroupName := input.Platform + "-default"
|
defaultGroupName := input.Platform + "-default"
|
||||||
groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform)
|
groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -13,7 +13,9 @@ import type {
|
|||||||
WindowStats,
|
WindowStats,
|
||||||
ClaudeModel,
|
ClaudeModel,
|
||||||
AccountUsageStatsResponse,
|
AccountUsageStatsResponse,
|
||||||
TempUnschedulableStatus
|
TempUnschedulableStatus,
|
||||||
|
AdminDataPayload,
|
||||||
|
AdminDataImportResult
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -347,6 +349,44 @@ export async function syncFromCrs(params: {
|
|||||||
return data
|
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 = {
|
export const accountsAPI = {
|
||||||
list,
|
list,
|
||||||
getById,
|
getById,
|
||||||
@@ -370,7 +410,9 @@ export const accountsAPI = {
|
|||||||
batchCreate,
|
batchCreate,
|
||||||
batchUpdateCredentials,
|
batchUpdateCredentials,
|
||||||
bulkUpdate,
|
bulkUpdate,
|
||||||
syncFromCrs
|
syncFromCrs,
|
||||||
|
exportData,
|
||||||
|
importData
|
||||||
}
|
}
|
||||||
|
|
||||||
export default accountsAPI
|
export default accountsAPI
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import type {
|
|||||||
ProxyAccountSummary,
|
ProxyAccountSummary,
|
||||||
CreateProxyRequest,
|
CreateProxyRequest,
|
||||||
UpdateProxyRequest,
|
UpdateProxyRequest,
|
||||||
PaginatedResponse
|
PaginatedResponse,
|
||||||
|
AdminDataPayload
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -208,6 +209,17 @@ export async function batchDelete(ids: number[]): Promise<{
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function exportData(filters?: {
|
||||||
|
protocol?: string
|
||||||
|
status?: 'active' | 'inactive'
|
||||||
|
search?: string
|
||||||
|
}): Promise<AdminDataPayload> {
|
||||||
|
const { data } = await apiClient.get<AdminDataPayload>('/admin/proxies/data', {
|
||||||
|
params: filters
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export const proxiesAPI = {
|
export const proxiesAPI = {
|
||||||
list,
|
list,
|
||||||
getAll,
|
getAll,
|
||||||
@@ -221,7 +233,8 @@ export const proxiesAPI = {
|
|||||||
getStats,
|
getStats,
|
||||||
getProxyAccounts,
|
getProxyAccounts,
|
||||||
batchCreate,
|
batchCreate,
|
||||||
batchDelete
|
batchDelete,
|
||||||
|
exportData
|
||||||
}
|
}
|
||||||
|
|
||||||
export default proxiesAPI
|
export default proxiesAPI
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<slot name="after"></slot>
|
<slot name="after"></slot>
|
||||||
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
|
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
|
||||||
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
|
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
|
||||||
|
<slot name="afterCreate"></slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
168
frontend/src/components/admin/account/ImportDataModal.vue
Normal file
168
frontend/src/components/admin/account/ImportDataModal.vue
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<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>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="input"
|
||||||
|
accept="application/json,.json"
|
||||||
|
@change="handleFileChange"
|
||||||
|
/>
|
||||||
|
<p v-if="fileName" class="mt-2 text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
{{ fileName }}
|
||||||
|
</p>
|
||||||
|
</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 fileName = computed(() => file.value?.name || '')
|
||||||
|
|
||||||
|
const errorItems = computed(() => result.value?.errors || [])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
file.value = null
|
||||||
|
result.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<BaseDialog :show="show" :title="title" width="narrow" @close="handleCancel">
|
<BaseDialog :show="show" :title="title" width="narrow" @close="handleCancel">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ message }}</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">{{ message }}</p>
|
||||||
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|||||||
@@ -1188,6 +1188,28 @@ export default {
|
|||||||
refreshInterval30s: '30 seconds',
|
refreshInterval30s: '30 seconds',
|
||||||
autoRefreshCountdown: 'Auto refresh: {seconds}s',
|
autoRefreshCountdown: 'Auto refresh: {seconds}s',
|
||||||
syncFromCrs: 'Sync from CRS',
|
syncFromCrs: 'Sync from CRS',
|
||||||
|
dataExport: 'Export',
|
||||||
|
dataExportSelected: 'Export Selected',
|
||||||
|
dataExportIncludeProxies: 'Include proxies (unchecked = no proxy linkage on import)',
|
||||||
|
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 no conflicts in the target instance.',
|
||||||
|
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',
|
syncFromCrsTitle: 'Sync Accounts from CRS',
|
||||||
syncFromCrsDesc:
|
syncFromCrsDesc:
|
||||||
'Sync accounts from claude-relay-service (CRS) into this system (CRS is called server-to-server).',
|
'Sync accounts from claude-relay-service (CRS) into this system (CRS is called server-to-server).',
|
||||||
@@ -1879,6 +1901,11 @@ export default {
|
|||||||
createProxy: 'Create Proxy',
|
createProxy: 'Create Proxy',
|
||||||
editProxy: 'Edit Proxy',
|
editProxy: 'Edit Proxy',
|
||||||
deleteProxy: 'Delete Proxy',
|
deleteProxy: 'Delete Proxy',
|
||||||
|
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...',
|
searchProxies: 'Search proxies...',
|
||||||
allProtocols: 'All Protocols',
|
allProtocols: 'All Protocols',
|
||||||
allStatus: 'All Status',
|
allStatus: 'All Status',
|
||||||
|
|||||||
@@ -1273,6 +1273,28 @@ export default {
|
|||||||
refreshInterval30s: '30 秒',
|
refreshInterval30s: '30 秒',
|
||||||
autoRefreshCountdown: '自动刷新:{seconds}s',
|
autoRefreshCountdown: '自动刷新:{seconds}s',
|
||||||
syncFromCrs: '从 CRS 同步',
|
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 同步账号',
|
syncFromCrsTitle: '从 CRS 同步账号',
|
||||||
syncFromCrsDesc:
|
syncFromCrsDesc:
|
||||||
'将 claude-relay-service(CRS)中的账号同步到当前系统(不会在浏览器侧直接请求 CRS)。',
|
'将 claude-relay-service(CRS)中的账号同步到当前系统(不会在浏览器侧直接请求 CRS)。',
|
||||||
@@ -1988,6 +2010,11 @@ export default {
|
|||||||
deleteProxy: '删除代理',
|
deleteProxy: '删除代理',
|
||||||
deleteConfirmMessage: "确定要删除代理 '{name}' 吗?",
|
deleteConfirmMessage: "确定要删除代理 '{name}' 吗?",
|
||||||
testProxy: '测试代理',
|
testProxy: '测试代理',
|
||||||
|
dataExport: '导出',
|
||||||
|
dataExportConfirmMessage: '导出的数据包含代理的敏感信息,请妥善保存。',
|
||||||
|
dataExportConfirm: '确认导出',
|
||||||
|
dataExported: '数据导出成功',
|
||||||
|
dataExportFailed: '数据导出失败',
|
||||||
columns: {
|
columns: {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
protocol: '协议',
|
protocol: '协议',
|
||||||
|
|||||||
@@ -727,6 +727,56 @@ export interface UpdateProxyRequest {
|
|||||||
status?: 'active' | 'inactive'
|
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 ====================
|
// ==================== Usage & Redeem Types ====================
|
||||||
|
|
||||||
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
|
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation'
|
||||||
|
|||||||
@@ -96,6 +96,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template #afterCreate>
|
||||||
|
<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>
|
</AccountTableActions>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -218,9 +226,16 @@
|
|||||||
<AccountStatsModal :show="showStats" :account="statsAcc" @close="closeStatsModal" />
|
<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" />
|
<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" />
|
<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" />
|
<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" />
|
<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="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>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -242,6 +257,7 @@ import AccountTableActions from '@/components/admin/account/AccountTableActions.
|
|||||||
import AccountTableFilters from '@/components/admin/account/AccountTableFilters.vue'
|
import AccountTableFilters from '@/components/admin/account/AccountTableFilters.vue'
|
||||||
import AccountBulkActionsBar from '@/components/admin/account/AccountBulkActionsBar.vue'
|
import AccountBulkActionsBar from '@/components/admin/account/AccountBulkActionsBar.vue'
|
||||||
import AccountActionMenu from '@/components/admin/account/AccountActionMenu.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 ReAuthAccountModal from '@/components/admin/account/ReAuthAccountModal.vue'
|
||||||
import AccountTestModal from '@/components/admin/account/AccountTestModal.vue'
|
import AccountTestModal from '@/components/admin/account/AccountTestModal.vue'
|
||||||
import AccountStatsModal from '@/components/admin/account/AccountStatsModal.vue'
|
import AccountStatsModal from '@/components/admin/account/AccountStatsModal.vue'
|
||||||
@@ -265,6 +281,9 @@ const selIds = ref<number[]>([])
|
|||||||
const showCreate = ref(false)
|
const showCreate = ref(false)
|
||||||
const showEdit = ref(false)
|
const showEdit = ref(false)
|
||||||
const showSync = ref(false)
|
const showSync = ref(false)
|
||||||
|
const showImportData = ref(false)
|
||||||
|
const showExportDataDialog = ref(false)
|
||||||
|
const includeProxyOnExport = ref(true)
|
||||||
const showBulkEdit = ref(false)
|
const showBulkEdit = ref(false)
|
||||||
const showTempUnsched = ref(false)
|
const showTempUnsched = ref(false)
|
||||||
const showDeleteDialog = ref(false)
|
const showDeleteDialog = ref(false)
|
||||||
@@ -279,6 +298,7 @@ const testingAcc = ref<Account | null>(null)
|
|||||||
const statsAcc = ref<Account | null>(null)
|
const statsAcc = ref<Account | null>(null)
|
||||||
const togglingSchedulable = ref<number | 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 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
|
// Column settings
|
||||||
const showColumnDropdown = ref(false)
|
const showColumnDropdown = ref(false)
|
||||||
@@ -405,6 +425,8 @@ const isAnyModalOpen = computed(() => {
|
|||||||
showCreate.value ||
|
showCreate.value ||
|
||||||
showEdit.value ||
|
showEdit.value ||
|
||||||
showSync.value ||
|
showSync.value ||
|
||||||
|
showImportData.value ||
|
||||||
|
showExportDataDialog.value ||
|
||||||
showBulkEdit.value ||
|
showBulkEdit.value ||
|
||||||
showTempUnsched.value ||
|
showTempUnsched.value ||
|
||||||
showDeleteDialog.value ||
|
showDeleteDialog.value ||
|
||||||
@@ -633,6 +655,50 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; reload() }
|
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 closeTestModal = () => { showTest.value = false; testingAcc.value = null }
|
||||||
const closeStatsModal = () => { showStats.value = false; statsAcc.value = null }
|
const closeStatsModal = () => { showStats.value = false; statsAcc.value = null }
|
||||||
const closeReAuthModal = () => { showReAuth.value = false; reAuthAcc.value = null }
|
const closeReAuthModal = () => { showReAuth.value = false; reAuthAcc.value = null }
|
||||||
|
|||||||
@@ -69,6 +69,9 @@
|
|||||||
<Icon name="trash" size="md" class="mr-2" />
|
<Icon name="trash" size="md" class="mr-2" />
|
||||||
{{ t('admin.proxies.batchDeleteAction') }}
|
{{ t('admin.proxies.batchDeleteAction') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="showExportDataDialog = true" class="btn btn-secondary">
|
||||||
|
{{ t('admin.proxies.dataExport') }}
|
||||||
|
</button>
|
||||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||||
<Icon name="plus" size="md" class="mr-2" />
|
<Icon name="plus" size="md" class="mr-2" />
|
||||||
{{ t('admin.proxies.createProxy') }}
|
{{ t('admin.proxies.createProxy') }}
|
||||||
@@ -606,6 +609,15 @@
|
|||||||
@confirm="confirmBatchDelete"
|
@confirm="confirmBatchDelete"
|
||||||
@cancel="showBatchDeleteDialog = false"
|
@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"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Proxy Accounts Dialog -->
|
<!-- Proxy Accounts Dialog -->
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
@@ -733,8 +745,10 @@ const showCreateModal = ref(false)
|
|||||||
const showEditModal = ref(false)
|
const showEditModal = ref(false)
|
||||||
const showDeleteDialog = ref(false)
|
const showDeleteDialog = ref(false)
|
||||||
const showBatchDeleteDialog = ref(false)
|
const showBatchDeleteDialog = ref(false)
|
||||||
|
const showExportDataDialog = ref(false)
|
||||||
const showAccountsModal = ref(false)
|
const showAccountsModal = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const exportingData = ref(false)
|
||||||
const testingProxyIds = ref<Set<number>>(new Set())
|
const testingProxyIds = ref<Set<number>>(new Set())
|
||||||
const batchTesting = ref(false)
|
const batchTesting = ref(false)
|
||||||
const selectedProxyIds = ref<Set<number>>(new Set())
|
const selectedProxyIds = ref<Set<number>>(new Set())
|
||||||
@@ -1228,6 +1242,39 @@ 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({
|
||||||
|
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) => {
|
const handleDelete = (proxy: Proxy) => {
|
||||||
if ((proxy.account_count || 0) > 0) {
|
if ((proxy.account_count || 0) > 0) {
|
||||||
appStore.showError(t('admin.proxies.deleteBlockedInUse'))
|
appStore.showError(t('admin.proxies.deleteBlockedInUse'))
|
||||||
|
|||||||
Reference in New Issue
Block a user