Compare commits

...

19 Commits

Author SHA1 Message Date
shaw
76d242e024 refactor(frontend): 复用 TokenUsageTrend 组件优化用户 Dashboard 图表
用户 Dashboard 的 Token 使用趋势图表现在显示 Input/Output/Cache 三种类型,
并在 Tooltip 中显示 Actual 和 Standard 价格,与管理员页面保持一致。
2026-02-06 20:18:38 +08:00
shaw
260c152166 fix(frontend): 修复重启后健康检查接口路径错误
将 /api/health 改为 /health,与后端实际注册的路由一致
2026-02-06 19:53:39 +08:00
shaw
9f4c1ef9f9 fix(ops): 添加 token 相关字段白名单避免误脱敏
在敏感字段检测中添加白名单,排除 API 参数和用量统计字段:
- max_tokens, max_completion_tokens, max_output_tokens
- completion_tokens, prompt_tokens, total_tokens
- input_tokens, output_tokens
- cache_creation_input_tokens, cache_read_input_tokens

这些字段名虽然包含 "token" 但只是数值参数,不应被脱敏处理。
2026-02-06 19:47:14 +08:00
shaw
bd7fdb5e6c refactor(frontend): 调整账号页面错误透传规则按钮位置
将错误透传规则按钮从自动刷新按钮前面移动到后面
2026-02-06 16:38:06 +08:00
Wesley Liddick
a381910e86 Merge pull request #489 from LLLLLLiulei/feat/import-export-bundle
feat: implement account & proxy import/export with migration-ready JSON bundles
2026-02-06 16:29:52 +08:00
shaw
d182ef0391 fix(gateway): 移除 PR #316 引入的工具名转换逻辑
移除响应阶段的工具名/schema/description 转换逻辑,修复第三方工具调用时
工具名被错误转换的问题(如 Task → task)。

移除内容:
- 工具名相关正则变量(toolPrefixRe, toolNameBoundaryRe 等)
- openCodeToolOverrides 和 claudeToolNameOverrides 映射表
- 工具名转换函数(normalizeToolNameForClaude, normalizeToolNameForOpenCode 等)
- 响应体工具名替换函数(replaceToolNamesInText, replaceToolNamesInResponseBody 等)
- 参数名转换函数(normalizeParamNameForOpenCode, rewriteParamKeysInValue)
- 工具描述清理函数(sanitizeToolDescription)
- 输入 schema 转换函数(normalizeToolInputSchema)
- 模型 ID 正则替换函数(replaceModelIDInText)

保留内容:
- 系统提示词清理(sanitizeSystemText)
- Claude Code 指纹 headers 处理
- 模型 ID 映射(通过 JSON 对象操作)
2026-02-06 16:09:58 +08:00
LLLLLLiulei
7319122e92 merge upstream/main 2026-02-06 11:33:45 +08:00
Wesley Liddick
4809fa4f19 Merge pull request #497 from mt21625457/main
fix(兼容): 将 Kimi cached_tokens 映射到 Claude 标准 cache_read_input_tokens
2026-02-06 11:20:42 +08:00
yangjianbo
ee01f80dc1 test(backend): 修复 usage 类型断言未检查 2026-02-06 09:54:29 +08:00
yangjianbo
98671a73f4 Merge branch 'main' of https://github.com/mt21625457/aicodex2api
# Conflicts:
#	backend/internal/service/gateway_cached_tokens_test.go
2026-02-06 09:35:46 +08:00
yangjianbo
f33a950103 fix(兼容): 将 Kimi cached_tokens 映射到 Claude 标准 cache_read_input_tokens
Kimi 等 Claude 兼容 API 返回缓存信息使用 OpenAI 风格的 cached_tokens 字段,
而非 Claude 标准的 cache_read_input_tokens,导致客户端收不到缓存命中信息且
内部计费缓存折扣为 0。

新增 reconcileCachedTokens 辅助函数,在 cache_read_input_tokens == 0 且
cached_tokens > 0 时自动填充,覆盖流式(message_start/message_delta)和
非流式两种响应路径。对 Claude 原生上游无影响。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 09:27:42 +08:00
程序猿MT
132bf34b69 Merge branch 'Wei-Shaw:main' into main 2026-02-06 08:53:52 +08:00
yangjianbo
c6a456c7c7 fix(兼容): 将 Kimi cached_tokens 映射到 Claude 标准 cache_read_input_tokens
Kimi 等 Claude 兼容 API 返回缓存信息使用 OpenAI 风格的 cached_tokens 字段,
而非 Claude 标准的 cache_read_input_tokens,导致客户端收不到缓存命中信息且
内部计费缓存折扣为 0。

新增 reconcileCachedTokens 辅助函数,在 cache_read_input_tokens == 0 且
cached_tokens > 0 时自动填充,覆盖流式(message_start/message_delta)和
非流式两种响应路径。对 Claude 原生上游无影响。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 08:42:55 +08:00
LLLLLLiulei
029994a83b fix: remove unused listAllAccounts 2026-02-05 19:13:00 +08:00
LLLLLLiulei
37047919ab fix: harden import/export flow 2026-02-05 18:59:30 +08:00
LLLLLLiulei
0b45d48e85 perf: batch fetch proxies for account export 2026-02-05 18:40:49 +08:00
LLLLLLiulei
0c660f8335 feat: refine proxy export and toolbar layout 2026-02-05 18:35:00 +08:00
LLLLLLiulei
ce9a247a9d feat: add proxy import flow 2026-02-05 18:23:49 +08:00
LLLLLLiulei
b4bd46d067 feat: add data import/export bundle 2026-02-05 17:46:08 +08:00
31 changed files with 2672 additions and 665 deletions

View 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
}
}

View 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)
}

View File

@@ -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,
})
}

View File

@@ -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
}

View 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
}

View 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)
}

View File

@@ -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).

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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")
}

View File

@@ -0,0 +1,288 @@
package service
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ---------- reconcileCachedTokens 单元测试 ----------
func TestReconcileCachedTokens_NilUsage(t *testing.T) {
assert.False(t, reconcileCachedTokens(nil))
}
func TestReconcileCachedTokens_AlreadyHasCacheRead(t *testing.T) {
// 已有标准字段,不应覆盖
usage := map[string]any{
"cache_read_input_tokens": float64(100),
"cached_tokens": float64(50),
}
assert.False(t, reconcileCachedTokens(usage))
assert.Equal(t, float64(100), usage["cache_read_input_tokens"])
}
func TestReconcileCachedTokens_KimiStyle(t *testing.T) {
// Kimi 风格cache_read_input_tokens=0cached_tokens>0
usage := map[string]any{
"input_tokens": float64(23),
"cache_creation_input_tokens": float64(0),
"cache_read_input_tokens": float64(0),
"cached_tokens": float64(23),
}
assert.True(t, reconcileCachedTokens(usage))
assert.Equal(t, float64(23), usage["cache_read_input_tokens"])
}
func TestReconcileCachedTokens_NoCachedTokens(t *testing.T) {
// 无 cached_tokens 字段(原生 Claude
usage := map[string]any{
"input_tokens": float64(100),
"cache_read_input_tokens": float64(0),
"cache_creation_input_tokens": float64(0),
}
assert.False(t, reconcileCachedTokens(usage))
assert.Equal(t, float64(0), usage["cache_read_input_tokens"])
}
func TestReconcileCachedTokens_CachedTokensZero(t *testing.T) {
// cached_tokens 为 0不应覆盖
usage := map[string]any{
"cache_read_input_tokens": float64(0),
"cached_tokens": float64(0),
}
assert.False(t, reconcileCachedTokens(usage))
assert.Equal(t, float64(0), usage["cache_read_input_tokens"])
}
func TestReconcileCachedTokens_MissingCacheReadField(t *testing.T) {
// cache_read_input_tokens 字段完全不存在cached_tokens > 0
usage := map[string]any{
"cached_tokens": float64(42),
}
assert.True(t, reconcileCachedTokens(usage))
assert.Equal(t, float64(42), usage["cache_read_input_tokens"])
}
// ---------- 流式 message_start 事件 reconcile 测试 ----------
func TestStreamingReconcile_MessageStart(t *testing.T) {
// 模拟 Kimi 返回的 message_start SSE 事件
eventJSON := `{
"type": "message_start",
"message": {
"id": "msg_123",
"type": "message",
"role": "assistant",
"model": "kimi",
"usage": {
"input_tokens": 23,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"cached_tokens": 23
}
}
}`
var event map[string]any
require.NoError(t, json.Unmarshal([]byte(eventJSON), &event))
eventType, _ := event["type"].(string)
require.Equal(t, "message_start", eventType)
// 模拟 processSSEEvent 中的 reconcile 逻辑
if msg, ok := event["message"].(map[string]any); ok {
if u, ok := msg["usage"].(map[string]any); ok {
reconcileCachedTokens(u)
}
}
// 验证 cache_read_input_tokens 已被填充
msg, ok := event["message"].(map[string]any)
require.True(t, ok)
usage, ok := msg["usage"].(map[string]any)
require.True(t, ok)
assert.Equal(t, float64(23), usage["cache_read_input_tokens"])
// 验证重新序列化后 JSON 也包含正确值
data, err := json.Marshal(event)
require.NoError(t, err)
assert.Equal(t, int64(23), gjson.GetBytes(data, "message.usage.cache_read_input_tokens").Int())
}
func TestStreamingReconcile_MessageStart_NativeClaude(t *testing.T) {
// 原生 Claude 不返回 cached_tokensreconcile 不应改变任何值
eventJSON := `{
"type": "message_start",
"message": {
"usage": {
"input_tokens": 100,
"cache_creation_input_tokens": 50,
"cache_read_input_tokens": 30
}
}
}`
var event map[string]any
require.NoError(t, json.Unmarshal([]byte(eventJSON), &event))
if msg, ok := event["message"].(map[string]any); ok {
if u, ok := msg["usage"].(map[string]any); ok {
reconcileCachedTokens(u)
}
}
msg, ok := event["message"].(map[string]any)
require.True(t, ok)
usage, ok := msg["usage"].(map[string]any)
require.True(t, ok)
assert.Equal(t, float64(30), usage["cache_read_input_tokens"])
}
// ---------- 流式 message_delta 事件 reconcile 测试 ----------
func TestStreamingReconcile_MessageDelta(t *testing.T) {
// 模拟 Kimi 返回的 message_delta SSE 事件
eventJSON := `{
"type": "message_delta",
"usage": {
"output_tokens": 7,
"cache_read_input_tokens": 0,
"cached_tokens": 15
}
}`
var event map[string]any
require.NoError(t, json.Unmarshal([]byte(eventJSON), &event))
eventType, _ := event["type"].(string)
require.Equal(t, "message_delta", eventType)
// 模拟 processSSEEvent 中的 reconcile 逻辑
usage, ok := event["usage"].(map[string]any)
require.True(t, ok)
reconcileCachedTokens(usage)
assert.Equal(t, float64(15), usage["cache_read_input_tokens"])
}
func TestStreamingReconcile_MessageDelta_NativeClaude(t *testing.T) {
// 原生 Claude 的 message_delta 通常没有 cached_tokens
eventJSON := `{
"type": "message_delta",
"usage": {
"output_tokens": 50
}
}`
var event map[string]any
require.NoError(t, json.Unmarshal([]byte(eventJSON), &event))
usage, ok := event["usage"].(map[string]any)
require.True(t, ok)
reconcileCachedTokens(usage)
_, hasCacheRead := usage["cache_read_input_tokens"]
assert.False(t, hasCacheRead, "不应为原生 Claude 响应注入 cache_read_input_tokens")
}
// ---------- 非流式响应 reconcile 测试 ----------
func TestNonStreamingReconcile_KimiResponse(t *testing.T) {
// 模拟 Kimi 非流式响应
body := []byte(`{
"id": "msg_123",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "hello"}],
"model": "kimi",
"usage": {
"input_tokens": 23,
"output_tokens": 7,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"cached_tokens": 23,
"prompt_tokens": 23,
"completion_tokens": 7
}
}`)
// 模拟 handleNonStreamingResponse 中的逻辑
var response struct {
Usage ClaudeUsage `json:"usage"`
}
require.NoError(t, json.Unmarshal(body, &response))
// reconcile
if response.Usage.CacheReadInputTokens == 0 {
cachedTokens := gjson.GetBytes(body, "usage.cached_tokens").Int()
if cachedTokens > 0 {
response.Usage.CacheReadInputTokens = int(cachedTokens)
if newBody, err := sjson.SetBytes(body, "usage.cache_read_input_tokens", cachedTokens); err == nil {
body = newBody
}
}
}
// 验证内部 usage计费用
assert.Equal(t, 23, response.Usage.CacheReadInputTokens)
assert.Equal(t, 23, response.Usage.InputTokens)
assert.Equal(t, 7, response.Usage.OutputTokens)
// 验证返回给客户端的 JSON body
assert.Equal(t, int64(23), gjson.GetBytes(body, "usage.cache_read_input_tokens").Int())
}
func TestNonStreamingReconcile_NativeClaude(t *testing.T) {
// 原生 Claude 响应cache_read_input_tokens 已有值
body := []byte(`{
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"cache_creation_input_tokens": 20,
"cache_read_input_tokens": 30
}
}`)
var response struct {
Usage ClaudeUsage `json:"usage"`
}
require.NoError(t, json.Unmarshal(body, &response))
// CacheReadInputTokens == 30条件不成立整个 reconcile 分支不会执行
assert.NotZero(t, response.Usage.CacheReadInputTokens)
assert.Equal(t, 30, response.Usage.CacheReadInputTokens)
}
func TestNonStreamingReconcile_NoCachedTokens(t *testing.T) {
// 没有 cached_tokens 字段
body := []byte(`{
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0
}
}`)
var response struct {
Usage ClaudeUsage `json:"usage"`
}
require.NoError(t, json.Unmarshal(body, &response))
if response.Usage.CacheReadInputTokens == 0 {
cachedTokens := gjson.GetBytes(body, "usage.cached_tokens").Int()
if cachedTokens > 0 {
response.Usage.CacheReadInputTokens = int(cachedTokens)
if newBody, err := sjson.SetBytes(body, "usage.cache_read_input_tokens", cachedTokens); err == nil {
body = newBody
}
}
}
// cache_read_input_tokens 应保持为 0
assert.Equal(t, 0, response.Usage.CacheReadInputTokens)
assert.Equal(t, int64(0), gjson.GetBytes(body, "usage.cache_read_input_tokens").Int())
}

View File

@@ -12,10 +12,3 @@ func TestSanitizeOpenCodeText_RewritesCanonicalSentence(t *testing.T) {
got := sanitizeSystemText(in)
require.Equal(t, strings.TrimSpace(claudeCodeSystemPrompt), got)
}
func TestSanitizeToolDescription_DoesNotRewriteKeywords(t *testing.T) {
in := "OpenCode and opencode are mentioned."
got := sanitizeToolDescription(in)
// We no longer rewrite tool descriptions; only redact obvious path leaks.
require.Equal(t, in, got)
}

View File

@@ -207,40 +207,6 @@ var (
sseDataRe = regexp.MustCompile(`^data:\s*`)
sessionIDRegex = regexp.MustCompile(`session_([a-f0-9-]{36})`)
claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`)
toolPrefixRe = regexp.MustCompile(`(?i)^(?:oc_|mcp_)`)
toolNameBoundaryRe = regexp.MustCompile(`[^a-zA-Z0-9]+`)
toolNameCamelRe = regexp.MustCompile(`([a-z0-9])([A-Z])`)
toolNameFieldRe = regexp.MustCompile(`"name"\s*:\s*"([^"]+)"`)
modelFieldRe = regexp.MustCompile(`"model"\s*:\s*"([^"]+)"`)
toolDescAbsPathRe = regexp.MustCompile(`/\/?(?:home|Users|tmp|var|opt|usr|etc)\/[^\s,\)"'\]]+`)
toolDescWinPathRe = regexp.MustCompile(`(?i)[A-Z]:\\[^\s,\)"'\]]+`)
claudeToolNameOverrides = map[string]string{
"bash": "Bash",
"read": "Read",
"edit": "Edit",
"write": "Write",
"task": "Task",
"glob": "Glob",
"grep": "Grep",
"webfetch": "WebFetch",
"websearch": "WebSearch",
"todowrite": "TodoWrite",
"question": "AskUserQuestion",
}
openCodeToolOverrides = map[string]string{
"Bash": "bash",
"Read": "read",
"Edit": "edit",
"Write": "write",
"Task": "task",
"Glob": "glob",
"Grep": "grep",
"WebFetch": "webfetch",
"WebSearch": "websearch",
"TodoWrite": "todowrite",
"AskUserQuestion": "question",
}
// claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表
// 支持多种变体标准版、Agent SDK 版、Explore Agent 版、Compact 版等
@@ -616,71 +582,6 @@ type claudeOAuthNormalizeOptions struct {
stripSystemCacheControl bool
}
func stripToolPrefix(value string) string {
if value == "" {
return value
}
return toolPrefixRe.ReplaceAllString(value, "")
}
func toSnakeCase(value string) string {
if value == "" {
return value
}
output := toolNameCamelRe.ReplaceAllString(value, "$1_$2")
output = toolNameBoundaryRe.ReplaceAllString(output, "_")
output = strings.Trim(output, "_")
return strings.ToLower(output)
}
func normalizeToolNameForClaude(name string, cache map[string]string) string {
if name == "" {
return name
}
stripped := stripToolPrefix(name)
// 只对已知的工具名进行映射,未知工具名保持原样
// 避免破坏 Anthropic 特殊工具(如 text_editor_20250728
mapped, ok := claudeToolNameOverrides[strings.ToLower(stripped)]
if !ok {
return stripped
}
if cache != nil && mapped != stripped {
cache[mapped] = stripped
}
return mapped
}
func normalizeToolNameForOpenCode(name string, cache map[string]string) string {
if name == "" {
return name
}
stripped := stripToolPrefix(name)
// 优先从请求时建立的映射中查找
if cache != nil {
if mapped, ok := cache[stripped]; ok {
return mapped
}
}
// 已知工具名的硬编码映射
if mapped, ok := openCodeToolOverrides[stripped]; ok {
return mapped
}
// 未知工具名保持原样,避免破坏 Anthropic 特殊工具
return stripped
}
func normalizeParamNameForOpenCode(name string, cache map[string]string) string {
if name == "" {
return name
}
if cache != nil {
if mapped, ok := cache[name]; ok {
return mapped
}
}
return name
}
// sanitizeSystemText rewrites only the fixed OpenCode identity sentence (if present).
// We intentionally avoid broad keyword replacement in system prompts to prevent
// accidentally changing user-provided instructions.
@@ -699,55 +600,6 @@ func sanitizeSystemText(text string) string {
return text
}
func sanitizeToolDescription(description string) string {
if description == "" {
return description
}
description = toolDescAbsPathRe.ReplaceAllString(description, "[path]")
description = toolDescWinPathRe.ReplaceAllString(description, "[path]")
// Intentionally do NOT rewrite tool descriptions (OpenCode/Claude strings).
// Tool names/skill names may rely on exact wording, and rewriting can be misleading.
return description
}
func normalizeToolInputSchema(inputSchema any, cache map[string]string) {
schema, ok := inputSchema.(map[string]any)
if !ok {
return
}
properties, ok := schema["properties"].(map[string]any)
if !ok {
return
}
newProperties := make(map[string]any, len(properties))
for key, value := range properties {
snakeKey := toSnakeCase(key)
newProperties[snakeKey] = value
if snakeKey != key && cache != nil {
cache[snakeKey] = key
}
}
schema["properties"] = newProperties
if required, ok := schema["required"].([]any); ok {
newRequired := make([]any, 0, len(required))
for _, item := range required {
name, ok := item.(string)
if !ok {
newRequired = append(newRequired, item)
continue
}
snakeName := toSnakeCase(name)
newRequired = append(newRequired, snakeName)
if snakeName != name && cache != nil {
cache[snakeName] = name
}
}
schema["required"] = newRequired
}
}
func stripCacheControlFromSystemBlocks(system any) bool {
blocks, ok := system.([]any)
if !ok {
@@ -768,24 +620,17 @@ func stripCacheControlFromSystemBlocks(system any) bool {
return changed
}
func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAuthNormalizeOptions) ([]byte, string, map[string]string) {
func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAuthNormalizeOptions) ([]byte, string) {
if len(body) == 0 {
return body, modelID, nil
return body, modelID
}
// 使用 json.RawMessage 保留 messages 的原始字节,避免 thinking 块被修改
var reqRaw map[string]json.RawMessage
if err := json.Unmarshal(body, &reqRaw); err != nil {
return body, modelID, nil
}
// 同时解析为 map[string]any 用于修改非 messages 字段
// 解析为 map[string]any 用于修改字段
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
return body, modelID, nil
return body, modelID
}
toolNameMap := make(map[string]string)
modified := false
if system, ok := req["system"]; ok {
@@ -827,115 +672,12 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu
}
}
if rawTools, exists := req["tools"]; exists {
switch tools := rawTools.(type) {
case []any:
for idx, tool := range tools {
toolMap, ok := tool.(map[string]any)
if !ok {
continue
}
if name, ok := toolMap["name"].(string); ok {
normalized := normalizeToolNameForClaude(name, toolNameMap)
if normalized != "" && normalized != name {
toolMap["name"] = normalized
modified = true
}
}
if desc, ok := toolMap["description"].(string); ok {
sanitized := sanitizeToolDescription(desc)
if sanitized != desc {
toolMap["description"] = sanitized
modified = true
}
}
if schema, ok := toolMap["input_schema"]; ok {
normalizeToolInputSchema(schema, toolNameMap)
modified = true
}
tools[idx] = toolMap
}
req["tools"] = tools
case map[string]any:
normalizedTools := make(map[string]any, len(tools))
for name, value := range tools {
normalized := normalizeToolNameForClaude(name, toolNameMap)
if normalized == "" {
normalized = name
}
if toolMap, ok := value.(map[string]any); ok {
toolMap["name"] = normalized
if desc, ok := toolMap["description"].(string); ok {
sanitized := sanitizeToolDescription(desc)
if sanitized != desc {
toolMap["description"] = sanitized
}
}
if schema, ok := toolMap["input_schema"]; ok {
normalizeToolInputSchema(schema, toolNameMap)
}
normalizedTools[normalized] = toolMap
continue
}
normalizedTools[normalized] = value
}
req["tools"] = normalizedTools
modified = true
}
} else {
// 确保 tools 字段存在(即使为空数组)
if _, exists := req["tools"]; !exists {
req["tools"] = []any{}
modified = true
}
// 处理 messages 中的 tool_use 块,但保留包含 thinking 块的消息的原始字节
messagesModified := false
if messages, ok := req["messages"].([]any); ok {
for _, msg := range messages {
msgMap, ok := msg.(map[string]any)
if !ok {
continue
}
content, ok := msgMap["content"].([]any)
if !ok {
continue
}
// 检查此消息是否包含 thinking 块
hasThinking := false
for _, block := range content {
blockMap, ok := block.(map[string]any)
if !ok {
continue
}
blockType, _ := blockMap["type"].(string)
if blockType == "thinking" || blockType == "redacted_thinking" {
hasThinking = true
break
}
}
// 如果包含 thinking 块,跳过此消息的修改
if hasThinking {
continue
}
// 只修改不包含 thinking 块的消息中的 tool_use
for _, block := range content {
blockMap, ok := block.(map[string]any)
if !ok {
continue
}
if blockType, _ := blockMap["type"].(string); blockType != "tool_use" {
continue
}
if name, ok := blockMap["name"].(string); ok {
normalized := normalizeToolNameForClaude(name, toolNameMap)
if normalized != "" && normalized != name {
blockMap["name"] = normalized
messagesModified = true
}
}
}
}
}
if opts.stripSystemCacheControl {
if system, ok := req["system"]; ok {
_ = stripCacheControlFromSystemBlocks(system)
@@ -964,38 +706,15 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu
modified = true
}
if !modified && !messagesModified {
return body, modelID, toolNameMap
if !modified {
return body, modelID
}
// 如果 messages 没有被修改,保留原始 messages 字节
if !messagesModified {
// 序列化非 messages 字段
newBody, err := json.Marshal(req)
if err != nil {
return body, modelID, toolNameMap
}
// 替换回原始的 messages
var newReq map[string]json.RawMessage
if err := json.Unmarshal(newBody, &newReq); err != nil {
return newBody, modelID, toolNameMap
}
if origMessages, ok := reqRaw["messages"]; ok {
newReq["messages"] = origMessages
}
finalBody, err := json.Marshal(newReq)
if err != nil {
return newBody, modelID, toolNameMap
}
return finalBody, modelID, toolNameMap
}
// messages 被修改了,需要完整序列化
newBody, err := json.Marshal(req)
if err != nil {
return body, modelID, toolNameMap
return body, modelID
}
return newBody, modelID, toolNameMap
return newBody, modelID
}
func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account *Account, fp *Fingerprint) string {
@@ -2960,7 +2679,6 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
reqModel := parsed.Model
reqStream := parsed.Stream
originalModel := reqModel
var toolNameMap map[string]string
isClaudeCode := isClaudeCodeRequest(ctx, c, parsed)
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
@@ -2984,7 +2702,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
}
body, reqModel, toolNameMap = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts)
body, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts)
}
// 强制执行 cache_control 块数量限制(最多 4 个)
@@ -3371,7 +3089,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
var firstTokenMs *int
var clientDisconnect bool
if reqStream {
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel, toolNameMap, shouldMimicClaudeCode)
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel, shouldMimicClaudeCode)
if err != nil {
if err.Error() == "have error in stream" {
return nil, &UpstreamFailoverError{
@@ -3384,7 +3102,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
firstTokenMs = streamResult.firstTokenMs
clientDisconnect = streamResult.clientDisconnect
} else {
usage, err = s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, reqModel, toolNameMap, shouldMimicClaudeCode)
usage, err = s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, reqModel)
if err != nil {
return nil, err
}
@@ -3998,7 +3716,7 @@ type streamingResult struct {
clientDisconnect bool // 客户端是否在流式传输过程中断开
}
func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string, toolNameMap map[string]string, mimicClaudeCode bool) (*streamingResult, error) {
func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string, mimicClaudeCode bool) (*streamingResult, error) {
// 更新5h窗口状态
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
@@ -4094,33 +3812,6 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
clientDisconnected := false // 客户端断开标志断开后继续读取上游以获取完整usage
pendingEventLines := make([]string, 0, 4)
var toolInputBuffers map[int]string
if mimicClaudeCode {
toolInputBuffers = make(map[int]string)
}
transformToolInputJSON := func(raw string) string {
if !mimicClaudeCode {
return raw
}
raw = strings.TrimSpace(raw)
if raw == "" {
return raw
}
var parsed any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return replaceToolNamesInText(raw, toolNameMap)
}
rewritten, changed := rewriteParamKeysInValue(parsed, toolNameMap)
if changed {
if bytes, err := json.Marshal(rewritten); err == nil {
return string(bytes)
}
}
return raw
}
processSSEEvent := func(lines []string) ([]string, string, error) {
if len(lines) == 0 {
@@ -4159,16 +3850,13 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
var event map[string]any
if err := json.Unmarshal([]byte(dataLine), &event); err != nil {
replaced := dataLine
if mimicClaudeCode {
replaced = replaceToolNamesInText(dataLine, toolNameMap)
}
// JSON 解析失败,直接透传原始数据
block := ""
if eventName != "" {
block = "event: " + eventName + "\n"
}
block += "data: " + replaced + "\n\n"
return []string{block}, replaced, nil
block += "data: " + dataLine + "\n\n"
return []string{block}, dataLine, nil
}
eventType, _ := event["type"].(string)
@@ -4176,6 +3864,20 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
eventName = eventType
}
// 兼容 Kimi cached_tokens → cache_read_input_tokens
if eventType == "message_start" {
if msg, ok := event["message"].(map[string]any); ok {
if u, ok := msg["usage"].(map[string]any); ok {
reconcileCachedTokens(u)
}
}
}
if eventType == "message_delta" {
if u, ok := event["usage"].(map[string]any); ok {
reconcileCachedTokens(u)
}
}
if needModelReplace {
if msg, ok := event["message"].(map[string]any); ok {
if model, ok := msg["model"].(string); ok && model == mappedModel {
@@ -4184,70 +3886,15 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
}
}
if mimicClaudeCode && eventType == "content_block_delta" {
if delta, ok := event["delta"].(map[string]any); ok {
if deltaType, _ := delta["type"].(string); deltaType == "input_json_delta" {
if indexVal, ok := event["index"].(float64); ok {
index := int(indexVal)
if partial, ok := delta["partial_json"].(string); ok {
toolInputBuffers[index] += partial
}
}
return nil, dataLine, nil
}
}
}
if mimicClaudeCode && eventType == "content_block_stop" {
if indexVal, ok := event["index"].(float64); ok {
index := int(indexVal)
if buffered := toolInputBuffers[index]; buffered != "" {
delete(toolInputBuffers, index)
transformed := transformToolInputJSON(buffered)
synthetic := map[string]any{
"type": "content_block_delta",
"index": index,
"delta": map[string]any{
"type": "input_json_delta",
"partial_json": transformed,
},
}
synthBytes, synthErr := json.Marshal(synthetic)
if synthErr == nil {
synthBlock := "event: content_block_delta\n" + "data: " + string(synthBytes) + "\n\n"
rewriteToolNamesInValue(event, toolNameMap)
stopBytes, stopErr := json.Marshal(event)
if stopErr == nil {
stopBlock := ""
if eventName != "" {
stopBlock = "event: " + eventName + "\n"
}
stopBlock += "data: " + string(stopBytes) + "\n\n"
return []string{synthBlock, stopBlock}, string(stopBytes), nil
}
}
}
}
}
if mimicClaudeCode {
rewriteToolNamesInValue(event, toolNameMap)
}
newData, err := json.Marshal(event)
if err != nil {
replaced := dataLine
if mimicClaudeCode {
replaced = replaceToolNamesInText(dataLine, toolNameMap)
}
// 序列化失败,直接透传原始数据
block := ""
if eventName != "" {
block = "event: " + eventName + "\n"
}
block += "data: " + replaced + "\n\n"
return []string{block}, replaced, nil
block += "data: " + dataLine + "\n\n"
return []string{block}, dataLine, nil
}
block := ""
@@ -4346,126 +3993,6 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
}
func rewriteParamKeysInValue(value any, cache map[string]string) (any, bool) {
switch v := value.(type) {
case map[string]any:
changed := false
rewritten := make(map[string]any, len(v))
for key, item := range v {
newKey := normalizeParamNameForOpenCode(key, cache)
newItem, childChanged := rewriteParamKeysInValue(item, cache)
if childChanged {
changed = true
}
if newKey != key {
changed = true
}
rewritten[newKey] = newItem
}
if !changed {
return value, false
}
return rewritten, true
case []any:
changed := false
rewritten := make([]any, len(v))
for idx, item := range v {
newItem, childChanged := rewriteParamKeysInValue(item, cache)
if childChanged {
changed = true
}
rewritten[idx] = newItem
}
if !changed {
return value, false
}
return rewritten, true
default:
return value, false
}
}
func rewriteToolNamesInValue(value any, toolNameMap map[string]string) bool {
switch v := value.(type) {
case map[string]any:
changed := false
if blockType, _ := v["type"].(string); blockType == "tool_use" {
if name, ok := v["name"].(string); ok {
mapped := normalizeToolNameForOpenCode(name, toolNameMap)
if mapped != name {
v["name"] = mapped
changed = true
}
}
if input, ok := v["input"].(map[string]any); ok {
rewrittenInput, inputChanged := rewriteParamKeysInValue(input, toolNameMap)
if inputChanged {
if m, ok := rewrittenInput.(map[string]any); ok {
v["input"] = m
changed = true
}
}
}
}
for _, item := range v {
if rewriteToolNamesInValue(item, toolNameMap) {
changed = true
}
}
return changed
case []any:
changed := false
for _, item := range v {
if rewriteToolNamesInValue(item, toolNameMap) {
changed = true
}
}
return changed
default:
return false
}
}
func replaceToolNamesInText(text string, toolNameMap map[string]string) string {
if text == "" {
return text
}
output := toolNameFieldRe.ReplaceAllStringFunc(text, func(match string) string {
submatches := toolNameFieldRe.FindStringSubmatch(match)
if len(submatches) < 2 {
return match
}
name := submatches[1]
mapped := normalizeToolNameForOpenCode(name, toolNameMap)
if mapped == name {
return match
}
return strings.Replace(match, name, mapped, 1)
})
output = modelFieldRe.ReplaceAllStringFunc(output, func(match string) string {
submatches := modelFieldRe.FindStringSubmatch(match)
if len(submatches) < 2 {
return match
}
model := submatches[1]
mapped := claude.DenormalizeModelID(model)
if mapped == model {
return match
}
return strings.Replace(match, model, mapped, 1)
})
for mapped, original := range toolNameMap {
if mapped == "" || original == "" || mapped == original {
continue
}
output = strings.ReplaceAll(output, "\""+mapped+"\":", "\""+original+"\":")
output = strings.ReplaceAll(output, "\\\""+mapped+"\\\":", "\\\""+original+"\\\":")
}
return output
}
func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) {
// 解析message_start获取input tokens标准Claude API格式
var msgStart struct {
@@ -4509,7 +4036,7 @@ func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) {
}
}
func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string, toolNameMap map[string]string, mimicClaudeCode bool) (*ClaudeUsage, error) {
func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*ClaudeUsage, error) {
// 更新5h窗口状态
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
@@ -4526,13 +4053,21 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h
return nil, fmt.Errorf("parse response: %w", err)
}
// 兼容 Kimi cached_tokens → cache_read_input_tokens
if response.Usage.CacheReadInputTokens == 0 {
cachedTokens := gjson.GetBytes(body, "usage.cached_tokens").Int()
if cachedTokens > 0 {
response.Usage.CacheReadInputTokens = int(cachedTokens)
if newBody, err := sjson.SetBytes(body, "usage.cache_read_input_tokens", cachedTokens); err == nil {
body = newBody
}
}
}
// 如果有模型映射替换响应中的model字段
if originalModel != mappedModel {
body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
}
if mimicClaudeCode {
body = s.replaceToolNamesInResponseBody(body, toolNameMap)
}
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.cfg.Security.ResponseHeaders)
@@ -4570,28 +4105,6 @@ func (s *GatewayService) replaceModelInResponseBody(body []byte, fromModel, toMo
return newBody
}
func (s *GatewayService) replaceToolNamesInResponseBody(body []byte, toolNameMap map[string]string) []byte {
if len(body) == 0 {
return body
}
var resp map[string]any
if err := json.Unmarshal(body, &resp); err != nil {
replaced := replaceToolNamesInText(string(body), toolNameMap)
if replaced == string(body) {
return body
}
return []byte(replaced)
}
if !rewriteToolNamesInValue(resp, toolNameMap) {
return body
}
newBody, err := json.Marshal(resp)
if err != nil {
return body
}
return newBody
}
// RecordUsageInput 记录使用量的输入参数
type RecordUsageInput struct {
Result *ForwardResult
@@ -4952,7 +4465,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
if shouldMimicClaudeCode {
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true}
body, reqModel, _ = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts)
body, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts)
}
// Antigravity 账户不支持 count_tokens 转发,直接返回空值
@@ -5311,3 +4824,21 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64,
return models
}
// reconcileCachedTokens 兼容 Kimi 等上游:
// 将 OpenAI 风格的 cached_tokens 映射到 Claude 标准的 cache_read_input_tokens
func reconcileCachedTokens(usage map[string]any) bool {
if usage == nil {
return false
}
cacheRead, _ := usage["cache_read_input_tokens"].(float64)
if cacheRead > 0 {
return false // 已有标准字段,无需处理
}
cached, _ := usage["cached_tokens"].(float64)
if cached <= 0 {
return false
}
usage["cache_read_input_tokens"] = cached
return true
}

View File

@@ -424,6 +424,16 @@ func isSensitiveKey(key string) bool {
return false
}
// Whitelist: known non-sensitive fields that contain sensitive substrings
// (e.g., "max_tokens" contains "token" but is just an API parameter).
switch k {
case "max_tokens", "max_completion_tokens", "max_output_tokens",
"completion_tokens", "prompt_tokens", "total_tokens",
"input_tokens", "output_tokens",
"cache_creation_input_tokens", "cache_read_input_tokens":
return false
}
// Exact matches (common credential fields).
switch k {
case "authorization",

View File

@@ -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

View 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')
})
})

View 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')
})
})

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -491,7 +491,7 @@ async function checkServiceAndReload() {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch('/api/health', {
const response = await fetch('/health', {
method: 'GET',
cache: 'no-cache'
})

View File

@@ -55,16 +55,7 @@
</div>
<!-- Token Usage Trend Chart -->
<div class="card relative overflow-hidden p-4">
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50">
<LoadingSpinner size="md" />
</div>
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('dashboard.tokenUsageTrend') }}</h3>
<div class="h-48">
<Line v-if="trendData" :data="trendData" :options="lineOptions" />
<div v-else class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('dashboard.noDataAvailable') }}</div>
</div>
</div>
<TokenUsageTrend :trend-data="trend" :loading="loading" />
</div>
</div>
</template>
@@ -75,7 +66,8 @@ import { useI18n } from 'vue-i18n'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import Select from '@/components/common/Select.vue'
import { Line, Doughnut } from 'vue-chartjs'
import { Doughnut } from 'vue-chartjs'
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
import type { TrendDataPoint, ModelStat } from '@/types'
import { formatCostFixed as formatCost, formatNumberLocaleString as formatNumber, formatTokensK as formatTokens } from '@/utils/format'
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Title, Tooltip, Legend, Filler } from 'chart.js'
@@ -93,28 +85,6 @@ const modelData = computed(() => !props.models?.length ? null : {
}]
})
const trendData = computed(() => !props.trend?.length ? null : {
labels: props.trend.map((d: TrendDataPoint) => d.date),
datasets: [
{
label: t('dashboard.input'),
data: props.trend.map((d: TrendDataPoint) => d.input_tokens),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.3,
fill: true
},
{
label: t('dashboard.output'),
data: props.trend.map((d: TrendDataPoint) => d.output_tokens),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.3,
fill: true
}
]
})
const doughnutOptions = {
responsive: true,
maintainAspectRatio: false,
@@ -127,25 +97,4 @@ const doughnutOptions = {
}
}
}
const lineOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: 'top' as const },
tooltip: {
callbacks: {
label: (context: any) => `${context.dataset.label}: ${formatTokens(context.parsed.y)} tokens`
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: (value: any) => formatTokens(value)
}
}
}
}
</script>

View File

@@ -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',

View File

@@ -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-serviceCRS中的账号同步到当前系统不会在浏览器侧直接请求 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: '协议',

View File

@@ -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'

View File

@@ -16,16 +16,6 @@
@sync="showSync = true"
@create="showCreate = true"
>
<template #before>
<button
@click="showErrorPassthrough = true"
class="btn btn-secondary"
:title="t('admin.errorPassthrough.title')"
>
<Icon name="shield" size="md" class="mr-1.5" />
<span class="hidden md:inline">{{ t('admin.errorPassthrough.title') }}</span>
</button>
</template>
<template #after>
<!-- Auto Refresh Dropdown -->
<div class="relative" ref="autoRefreshDropdownRef">
@@ -72,6 +62,16 @@
</div>
</div>
<!-- Error Passthrough Rules -->
<button
@click="showErrorPassthrough = true"
class="btn btn-secondary"
:title="t('admin.errorPassthrough.title')"
>
<Icon name="shield" size="md" class="mr-1.5" />
<span class="hidden md:inline">{{ t('admin.errorPassthrough.title') }}</span>
</button>
<!-- Column Settings Dropdown -->
<div class="relative" ref="columnDropdownRef">
<button
@@ -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 }

View File

@@ -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'))