mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 00:15:28 +08:00
Merge pull request #241 from tanaer/feat/proxy-ip-management-dev
feat: 增加 SOCKS5/SOCKS5H 代理管理与账号代理路由
This commit is contained in:
23
AGENTS.md
Normal file
23
AGENTS.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# AGENTS.md
|
||||
|
||||
These rules apply to all agent-made changes in this repository.
|
||||
|
||||
## PR Gate
|
||||
|
||||
- Before opening or updating a PR, run the same local gates as `.github/workflows/quality-gates.yml`.
|
||||
- Required commands:
|
||||
- `./scripts/lint.sh`
|
||||
- `./tests/scripts/check-refactor-line-gate.sh`
|
||||
- `./tests/scripts/run-unit-all.sh`
|
||||
- `npm run build --prefix webui`
|
||||
|
||||
## Go Lint Rules
|
||||
|
||||
- Run `gofmt -w` on every changed Go file before commit or push.
|
||||
- Do not ignore error returns from I/O-style cleanup calls such as `Close`, `Flush`, `Sync`, or similar methods.
|
||||
- If a cleanup error cannot be returned, log it explicitly.
|
||||
|
||||
## Change Scope
|
||||
|
||||
- Keep changes additive and tightly scoped to the requested feature or bugfix.
|
||||
- Do not mix unrelated refactors into feature PRs unless they are required to make the change pass gates.
|
||||
@@ -26,9 +26,15 @@ func RegisterRoutes(r chi.Router, h *Handler) {
|
||||
pr.Get("/config/export", h.configExport)
|
||||
pr.Post("/keys", h.addKey)
|
||||
pr.Delete("/keys/{key}", h.deleteKey)
|
||||
pr.Get("/proxies", h.listProxies)
|
||||
pr.Post("/proxies", h.addProxy)
|
||||
pr.Put("/proxies/{proxyID}", h.updateProxy)
|
||||
pr.Delete("/proxies/{proxyID}", h.deleteProxy)
|
||||
pr.Post("/proxies/test", h.testProxy)
|
||||
pr.Get("/accounts", h.listAccounts)
|
||||
pr.Post("/accounts", h.addAccount)
|
||||
pr.Delete("/accounts/{identifier}", h.deleteAccount)
|
||||
pr.Put("/accounts/{identifier}/proxy", h.updateAccountProxy)
|
||||
pr.Get("/queue/status", h.queueStatus)
|
||||
pr.Post("/accounts/test", h.testSingleAccount)
|
||||
pr.Post("/accounts/test-all", h.testAllAccounts)
|
||||
|
||||
@@ -68,6 +68,7 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
"identifier": acc.Identifier(),
|
||||
"email": acc.Email,
|
||||
"mobile": acc.Mobile,
|
||||
"proxy_id": acc.ProxyID,
|
||||
"has_password": acc.Password != "",
|
||||
"has_token": token != "",
|
||||
"token_preview": preview,
|
||||
@@ -86,6 +87,11 @@ func (h *Handler) addAccount(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
if acc.ProxyID != "" {
|
||||
if _, ok := findProxyByID(*c, acc.ProxyID); !ok {
|
||||
return fmt.Errorf("代理不存在")
|
||||
}
|
||||
}
|
||||
mobileKey := config.CanonicalMobileKey(acc.Mobile)
|
||||
for _, a := range c.Accounts {
|
||||
if acc.Email != "" && a.Email == acc.Email {
|
||||
|
||||
@@ -115,10 +115,11 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
|
||||
result["message"] = "登录成功但写入运行时 token 失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token}
|
||||
sessionID, err := h.DS.CreateSession(ctx, authCtx, 1)
|
||||
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token, AccountID: identifier, Account: acc}
|
||||
proxyCtx := authn.WithAuth(ctx, authCtx)
|
||||
sessionID, err := h.DS.CreateSession(proxyCtx, authCtx, 1)
|
||||
if err != nil {
|
||||
newToken, loginErr := h.DS.Login(ctx, acc)
|
||||
newToken, loginErr := h.DS.Login(proxyCtx, acc)
|
||||
if loginErr != nil {
|
||||
result["message"] = "创建会话失败: " + err.Error()
|
||||
return result
|
||||
@@ -129,7 +130,7 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
|
||||
result["message"] = "刷新 token 成功但写入运行时 token 失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
sessionID, err = h.DS.CreateSession(ctx, authCtx, 1)
|
||||
sessionID, err = h.DS.CreateSession(proxyCtx, authCtx, 1)
|
||||
if err != nil {
|
||||
result["message"] = "创建会话失败: " + err.Error()
|
||||
return result
|
||||
@@ -137,7 +138,7 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
|
||||
}
|
||||
|
||||
// 获取会话数量
|
||||
sessionStats, sessionErr := h.DS.GetSessionCountForToken(ctx, token)
|
||||
sessionStats, sessionErr := h.DS.GetSessionCountForToken(proxyCtx, token)
|
||||
if sessionErr == nil && sessionStats != nil {
|
||||
result["session_count"] = sessionStats.FirstPageCount
|
||||
}
|
||||
@@ -153,13 +154,13 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
|
||||
thinking, search = false, false
|
||||
}
|
||||
_ = search
|
||||
pow, err := h.DS.GetPow(ctx, authCtx, 1)
|
||||
pow, err := h.DS.GetPow(proxyCtx, authCtx, 1)
|
||||
if err != nil {
|
||||
result["message"] = "获取 PoW 失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
payload := map[string]any{"chat_session_id": sessionID, "prompt": deepseek.MessagesPrepare([]map[string]any{{"role": "user", "content": message}}), "ref_file_ids": []any{}, "thinking_enabled": thinking, "search_enabled": search}
|
||||
resp, err := h.DS.CallCompletion(ctx, authCtx, payload, pow, 1)
|
||||
resp, err := h.DS.CallCompletion(proxyCtx, authCtx, payload, pow, 1)
|
||||
if err != nil {
|
||||
result["message"] = "请求失败: " + err.Error()
|
||||
return result
|
||||
@@ -244,25 +245,29 @@ func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 每次先登录刷新一次 token,避免使用过期 token。
|
||||
token, err := h.DS.Login(r.Context(), acc)
|
||||
authCtx := &authn.RequestAuth{UseConfigToken: false, AccountID: acc.Identifier(), Account: acc}
|
||||
proxyCtx := authn.WithAuth(r.Context(), authCtx)
|
||||
token, err := h.DS.Login(proxyCtx, acc)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
|
||||
authCtx.DeepSeekToken = token
|
||||
|
||||
// 删除所有会话
|
||||
err = h.DS.DeleteAllSessionsForToken(r.Context(), token)
|
||||
err = h.DS.DeleteAllSessionsForToken(proxyCtx, token)
|
||||
if err != nil {
|
||||
// token 可能过期,尝试重新登录并重试一次
|
||||
newToken, loginErr := h.DS.Login(r.Context(), acc)
|
||||
newToken, loginErr := h.DS.Login(proxyCtx, acc)
|
||||
if loginErr != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
token = newToken
|
||||
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
|
||||
if retryErr := h.DS.DeleteAllSessionsForToken(r.Context(), token); retryErr != nil {
|
||||
authCtx.DeepSeekToken = token
|
||||
if retryErr := h.DS.DeleteAllSessionsForToken(proxyCtx, token); retryErr != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + retryErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package admin
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -10,6 +12,7 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
safe := map[string]any{
|
||||
"keys": snap.Keys,
|
||||
"accounts": []map[string]any{},
|
||||
"proxies": []map[string]any{},
|
||||
"env_backed": h.Store.IsEnvBacked(),
|
||||
"env_source_present": h.Store.HasEnvConfigSource(),
|
||||
"env_writeback_enabled": h.Store.IsEnvWritebackEnabled(),
|
||||
@@ -36,12 +39,27 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
"identifier": acc.Identifier(),
|
||||
"email": acc.Email,
|
||||
"mobile": acc.Mobile,
|
||||
"proxy_id": acc.ProxyID,
|
||||
"has_password": strings.TrimSpace(acc.Password) != "",
|
||||
"has_token": token != "",
|
||||
"token_preview": preview,
|
||||
})
|
||||
}
|
||||
safe["accounts"] = accounts
|
||||
proxies := make([]map[string]any, 0, len(snap.Proxies))
|
||||
for _, proxy := range snap.Proxies {
|
||||
proxy = config.NormalizeProxy(proxy)
|
||||
proxies = append(proxies, map[string]any{
|
||||
"id": proxy.ID,
|
||||
"name": proxy.Name,
|
||||
"type": proxy.Type,
|
||||
"host": proxy.Host,
|
||||
"port": proxy.Port,
|
||||
"username": proxy.Username,
|
||||
"has_password": strings.TrimSpace(proxy.Password) != "",
|
||||
})
|
||||
}
|
||||
safe["proxies"] = proxies
|
||||
writeJSON(w, http.StatusOK, safe)
|
||||
}
|
||||
|
||||
|
||||
189
internal/admin/handler_proxies.go
Normal file
189
internal/admin/handler_proxies.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"ds2api/internal/config"
|
||||
"ds2api/internal/deepseek"
|
||||
)
|
||||
|
||||
var proxyConnectivityTester = func(ctx context.Context, proxy config.Proxy) map[string]any {
|
||||
return deepseek.TestProxyConnectivity(ctx, proxy)
|
||||
}
|
||||
|
||||
func validateProxyMutation(cfg *config.Config) error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
if err := config.ValidateProxyConfig(cfg.Proxies); err != nil {
|
||||
return err
|
||||
}
|
||||
return config.ValidateAccountProxyReferences(cfg.Accounts, cfg.Proxies)
|
||||
}
|
||||
|
||||
func (h *Handler) listProxies(w http.ResponseWriter, _ *http.Request) {
|
||||
proxies := h.Store.Snapshot().Proxies
|
||||
items := make([]map[string]any, 0, len(proxies))
|
||||
for _, proxy := range proxies {
|
||||
proxy = config.NormalizeProxy(proxy)
|
||||
items = append(items, map[string]any{
|
||||
"id": proxy.ID,
|
||||
"name": proxy.Name,
|
||||
"type": proxy.Type,
|
||||
"host": proxy.Host,
|
||||
"port": proxy.Port,
|
||||
"username": proxy.Username,
|
||||
"has_password": strings.TrimSpace(proxy.Password) != "",
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
func (h *Handler) addProxy(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
proxy := toProxy(req)
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
c.Proxies = append(c.Proxies, proxy)
|
||||
return validateProxyMutation(c)
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "proxy": proxy})
|
||||
}
|
||||
|
||||
func (h *Handler) updateProxy(w http.ResponseWriter, r *http.Request) {
|
||||
proxyID := chi.URLParam(r, "proxyID")
|
||||
if decoded, err := url.PathUnescape(proxyID); err == nil {
|
||||
proxyID = decoded
|
||||
}
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
proxy := toProxy(req)
|
||||
proxy.ID = strings.TrimSpace(proxyID)
|
||||
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
for i, existing := range c.Proxies {
|
||||
existing = config.NormalizeProxy(existing)
|
||||
if existing.ID != proxy.ID {
|
||||
continue
|
||||
}
|
||||
if proxy.Password == "" {
|
||||
proxy.Password = existing.Password
|
||||
}
|
||||
c.Proxies[i] = proxy
|
||||
return validateProxyMutation(c)
|
||||
}
|
||||
return newRequestError("代理不存在")
|
||||
})
|
||||
if err != nil {
|
||||
if detail, ok := requestErrorDetail(err); ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": detail})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "proxy": proxy})
|
||||
}
|
||||
|
||||
func (h *Handler) deleteProxy(w http.ResponseWriter, r *http.Request) {
|
||||
proxyID := chi.URLParam(r, "proxyID")
|
||||
if decoded, err := url.PathUnescape(proxyID); err == nil {
|
||||
proxyID = decoded
|
||||
}
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
idx := -1
|
||||
for i, existing := range c.Proxies {
|
||||
existing = config.NormalizeProxy(existing)
|
||||
if existing.ID == strings.TrimSpace(proxyID) {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return newRequestError("代理不存在")
|
||||
}
|
||||
c.Proxies = append(c.Proxies[:idx], c.Proxies[idx+1:]...)
|
||||
for i := range c.Accounts {
|
||||
if strings.TrimSpace(c.Accounts[i].ProxyID) == strings.TrimSpace(proxyID) {
|
||||
c.Accounts[i].ProxyID = ""
|
||||
}
|
||||
}
|
||||
return validateProxyMutation(c)
|
||||
})
|
||||
if err != nil {
|
||||
if detail, ok := requestErrorDetail(err); ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": detail})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true})
|
||||
}
|
||||
|
||||
func (h *Handler) testProxy(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
proxyID := fieldString(req, "proxy_id")
|
||||
|
||||
var proxy config.Proxy
|
||||
if proxyID != "" {
|
||||
var ok bool
|
||||
proxy, ok = findProxyByID(h.Store.Snapshot(), proxyID)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "代理不存在"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
proxy = toProxy(req)
|
||||
}
|
||||
|
||||
result := proxyConnectivityTester(r.Context(), proxy)
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) updateAccountProxy(w http.ResponseWriter, r *http.Request) {
|
||||
identifier := chi.URLParam(r, "identifier")
|
||||
if decoded, err := url.PathUnescape(identifier); err == nil {
|
||||
identifier = decoded
|
||||
}
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
proxyID := fieldString(req, "proxy_id")
|
||||
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
if proxyID != "" {
|
||||
if _, ok := findProxyByID(*c, proxyID); !ok {
|
||||
return newRequestError("代理不存在")
|
||||
}
|
||||
}
|
||||
for i, acc := range c.Accounts {
|
||||
if !accountMatchesIdentifier(acc, identifier) {
|
||||
continue
|
||||
}
|
||||
c.Accounts[i].ProxyID = proxyID
|
||||
return validateProxyMutation(c)
|
||||
}
|
||||
return newRequestError("账号不存在")
|
||||
})
|
||||
if err != nil {
|
||||
if detail, ok := requestErrorDetail(err); ok {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": detail})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
h.Pool.Reset()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "proxy_id": proxyID})
|
||||
}
|
||||
193
internal/admin/handler_proxies_test.go
Normal file
193
internal/admin/handler_proxies_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"ds2api/internal/account"
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func newAdminProxyTestHandler(t *testing.T, raw string) *Handler {
|
||||
t.Helper()
|
||||
t.Setenv("DS2API_CONFIG_JSON", raw)
|
||||
store := config.LoadStore()
|
||||
return &Handler{
|
||||
Store: store,
|
||||
Pool: account.NewPool(store),
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddProxyPersistsNormalizedProxy(t *testing.T) {
|
||||
h := newAdminProxyTestHandler(t, `{"accounts":[]}`)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/admin/proxies", h.addProxy)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/proxies", bytes.NewBufferString(`{
|
||||
"name":" HK Exit ",
|
||||
"type":" SOCKS5H ",
|
||||
"host":" 127.0.0.1 ",
|
||||
"port":1081,
|
||||
"username":" user ",
|
||||
"password":" pass "
|
||||
}`))
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
proxies := h.Store.Snapshot().Proxies
|
||||
if len(proxies) != 1 {
|
||||
t.Fatalf("expected 1 proxy, got %d", len(proxies))
|
||||
}
|
||||
if proxies[0].Name != "HK Exit" {
|
||||
t.Fatalf("unexpected proxy name: %#v", proxies[0])
|
||||
}
|
||||
if proxies[0].Type != "socks5h" {
|
||||
t.Fatalf("unexpected proxy type: %#v", proxies[0])
|
||||
}
|
||||
if proxies[0].Username != "user" || proxies[0].Password != "pass" {
|
||||
t.Fatalf("expected trimmed credentials, got %#v", proxies[0])
|
||||
}
|
||||
if proxies[0].ID == "" {
|
||||
t.Fatalf("expected generated proxy id, got %#v", proxies[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddProxyDoesNotFailOnUnrelatedInvalidRuntimeConfig(t *testing.T) {
|
||||
router := newHTTPAdminHarness(t, `{
|
||||
"keys":["k1"],
|
||||
"runtime":{
|
||||
"account_max_inflight":8,
|
||||
"global_max_inflight":4
|
||||
}
|
||||
}`, &testingDSMock{})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, adminReq(http.MethodPost, "/proxies", []byte(`{
|
||||
"name":"HK Exit",
|
||||
"type":"socks5h",
|
||||
"host":"127.0.0.1",
|
||||
"port":1080
|
||||
}`)))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected add proxy success despite unrelated runtime issue, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
readRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(readRec, adminReq(http.MethodGet, "/config", nil))
|
||||
if readRec.Code != http.StatusOK {
|
||||
t.Fatalf("config read status=%d body=%s", readRec.Code, readRec.Body.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(readRec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode config response: %v", err)
|
||||
}
|
||||
proxies, _ := payload["proxies"].([]any)
|
||||
if len(proxies) != 1 {
|
||||
t.Fatalf("expected proxy to be persisted, got %#v", payload["proxies"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProxyClearsAssignedAccountProxyID(t *testing.T) {
|
||||
h := newAdminProxyTestHandler(t, `{
|
||||
"proxies":[{"id":"proxy-1","name":"Node 1","type":"socks5","host":"127.0.0.1","port":1080}],
|
||||
"accounts":[{"email":"u@example.com","password":"pwd","proxy_id":"proxy-1"}]
|
||||
}`)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Delete("/admin/proxies/{proxyID}", h.deleteProxy)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/admin/proxies/proxy-1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
snap := h.Store.Snapshot()
|
||||
if len(snap.Proxies) != 0 {
|
||||
t.Fatalf("expected proxy removed, got %#v", snap.Proxies)
|
||||
}
|
||||
if len(snap.Accounts) != 1 {
|
||||
t.Fatalf("expected account kept, got %#v", snap.Accounts)
|
||||
}
|
||||
if snap.Accounts[0].ProxyID != "" {
|
||||
t.Fatalf("expected proxy assignment cleared, got %#v", snap.Accounts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAccountProxyAssignsProxyID(t *testing.T) {
|
||||
h := newAdminProxyTestHandler(t, `{
|
||||
"proxies":[{"id":"proxy-1","name":"Node 1","type":"socks5h","host":"127.0.0.1","port":1080}],
|
||||
"accounts":[{"email":"u@example.com","password":"pwd"}]
|
||||
}`)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Put("/admin/accounts/{identifier}/proxy", h.updateAccountProxy)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/accounts/u@example.com/proxy", bytes.NewBufferString(`{"proxy_id":"proxy-1"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
acc, ok := h.Store.FindAccount("u@example.com")
|
||||
if !ok {
|
||||
t.Fatal("expected account")
|
||||
}
|
||||
if acc.ProxyID != "proxy-1" {
|
||||
t.Fatalf("expected proxy assigned, got %#v", acc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestProxyUsesStoredProxy(t *testing.T) {
|
||||
h := newAdminProxyTestHandler(t, `{
|
||||
"proxies":[{"id":"proxy-1","name":"Node 1","type":"socks5h","host":"127.0.0.1","port":1080}]
|
||||
}`)
|
||||
|
||||
original := proxyConnectivityTester
|
||||
defer func() { proxyConnectivityTester = original }()
|
||||
|
||||
var got config.Proxy
|
||||
proxyConnectivityTester = func(_ context.Context, proxy config.Proxy) map[string]any {
|
||||
got = proxy
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"proxy_id": proxy.ID,
|
||||
"proxy_type": proxy.Type,
|
||||
"response_time": 12,
|
||||
}
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/admin/proxies/test", h.testProxy)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/proxies/test", bytes.NewBufferString(`{"proxy_id":"proxy-1"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.ID != "proxy-1" || got.Type != "socks5h" {
|
||||
t.Fatalf("expected stored proxy passed to tester, got %#v", got)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if ok, _ := payload["success"].(bool); !ok {
|
||||
t.Fatalf("expected success payload, got %#v", payload)
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ func toAccount(m map[string]any) config.Account {
|
||||
Email: email,
|
||||
Mobile: mobile,
|
||||
Password: fieldString(m, "password"),
|
||||
ProxyID: fieldString(m, "proxy_id"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,9 +101,36 @@ func accountMatchesIdentifier(acc config.Account, identifier string) bool {
|
||||
func normalizeAccountForStorage(acc config.Account) config.Account {
|
||||
acc.Email = strings.TrimSpace(acc.Email)
|
||||
acc.Mobile = config.NormalizeMobileForStorage(acc.Mobile)
|
||||
acc.ProxyID = strings.TrimSpace(acc.ProxyID)
|
||||
return acc
|
||||
}
|
||||
|
||||
func toProxy(m map[string]any) config.Proxy {
|
||||
return config.NormalizeProxy(config.Proxy{
|
||||
ID: fieldString(m, "id"),
|
||||
Name: fieldString(m, "name"),
|
||||
Type: fieldString(m, "type"),
|
||||
Host: fieldString(m, "host"),
|
||||
Port: intFrom(m["port"]),
|
||||
Username: fieldString(m, "username"),
|
||||
Password: fieldString(m, "password"),
|
||||
})
|
||||
}
|
||||
|
||||
func findProxyByID(c config.Config, proxyID string) (config.Proxy, bool) {
|
||||
id := strings.TrimSpace(proxyID)
|
||||
if id == "" {
|
||||
return config.Proxy{}, false
|
||||
}
|
||||
for _, proxy := range c.Proxies {
|
||||
proxy = config.NormalizeProxy(proxy)
|
||||
if proxy.ID == id {
|
||||
return proxy, true
|
||||
}
|
||||
}
|
||||
return config.Proxy{}, false
|
||||
}
|
||||
|
||||
func accountDedupeKey(acc config.Account) string {
|
||||
if email := strings.TrimSpace(acc.Email); email != "" {
|
||||
return "email:" + email
|
||||
|
||||
@@ -20,6 +20,9 @@ func (c Config) MarshalJSON() ([]byte, error) {
|
||||
if len(c.Accounts) > 0 {
|
||||
m["accounts"] = c.Accounts
|
||||
}
|
||||
if len(c.Proxies) > 0 {
|
||||
m["proxies"] = c.Proxies
|
||||
}
|
||||
if len(c.ClaudeMapping) > 0 {
|
||||
m["claude_mapping"] = c.ClaudeMapping
|
||||
}
|
||||
@@ -70,6 +73,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
|
||||
if err := json.Unmarshal(v, &c.Accounts); err != nil {
|
||||
return fmt.Errorf("invalid field %q: %w", k, err)
|
||||
}
|
||||
case "proxies":
|
||||
if err := json.Unmarshal(v, &c.Proxies); err != nil {
|
||||
return fmt.Errorf("invalid field %q: %w", k, err)
|
||||
}
|
||||
case "claude_mapping":
|
||||
if err := json.Unmarshal(v, &c.ClaudeMapping); err != nil {
|
||||
return fmt.Errorf("invalid field %q: %w", k, err)
|
||||
@@ -130,6 +137,7 @@ func (c Config) Clone() Config {
|
||||
clone := Config{
|
||||
Keys: slices.Clone(c.Keys),
|
||||
Accounts: slices.Clone(c.Accounts),
|
||||
Proxies: slices.Clone(c.Proxies),
|
||||
ClaudeMapping: cloneStringMap(c.ClaudeMapping),
|
||||
ClaudeModelMap: cloneStringMap(c.ClaudeModelMap),
|
||||
ModelAliases: cloneStringMap(c.ModelAliases),
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
Accounts []Account `json:"accounts,omitempty"`
|
||||
Proxies []Proxy `json:"proxies,omitempty"`
|
||||
ClaudeMapping map[string]string `json:"claude_mapping,omitempty"`
|
||||
ClaudeModelMap map[string]string `json:"claude_model_mapping,omitempty"`
|
||||
ModelAliases map[string]string `json:"model_aliases,omitempty"`
|
||||
@@ -22,6 +30,38 @@ type Account struct {
|
||||
Mobile string `json:"mobile,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
ProxyID string `json:"proxy_id,omitempty"`
|
||||
}
|
||||
|
||||
type Proxy struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
func NormalizeProxy(p Proxy) Proxy {
|
||||
p.ID = strings.TrimSpace(p.ID)
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
p.Type = strings.ToLower(strings.TrimSpace(p.Type))
|
||||
p.Host = strings.TrimSpace(p.Host)
|
||||
p.Username = strings.TrimSpace(p.Username)
|
||||
p.Password = strings.TrimSpace(p.Password)
|
||||
if p.ID == "" {
|
||||
p.ID = StableProxyID(p)
|
||||
}
|
||||
if p.Name == "" && p.Host != "" && p.Port > 0 {
|
||||
p.Name = fmt.Sprintf("%s:%d", p.Host, p.Port)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func StableProxyID(p Proxy) string {
|
||||
sum := sha1.Sum([]byte(strings.ToLower(strings.TrimSpace(p.Type)) + "|" + strings.ToLower(strings.TrimSpace(p.Host)) + "|" + fmt.Sprintf("%d", p.Port) + "|" + strings.TrimSpace(p.Username)))
|
||||
return "proxy_" + hex.EncodeToString(sum[:6])
|
||||
}
|
||||
|
||||
func (c *Config) ClearAccountTokens() {
|
||||
|
||||
@@ -32,6 +32,47 @@ func TestLoadStoreClearsTokensFromConfigInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStorePreservesProxiesAndAccountProxyAssignment(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{
|
||||
"proxies":[
|
||||
{
|
||||
"id":"proxy-sh-1",
|
||||
"name":"Shanghai Exit",
|
||||
"type":"socks5h",
|
||||
"host":"127.0.0.1",
|
||||
"port":1080,
|
||||
"username":"demo",
|
||||
"password":"secret"
|
||||
}
|
||||
],
|
||||
"accounts":[
|
||||
{
|
||||
"email":"u@example.com",
|
||||
"password":"p",
|
||||
"proxy_id":"proxy-sh-1"
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
store := LoadStore()
|
||||
snap := store.Snapshot()
|
||||
if len(snap.Proxies) != 1 {
|
||||
t.Fatalf("expected 1 proxy, got %d", len(snap.Proxies))
|
||||
}
|
||||
if snap.Proxies[0].ID != "proxy-sh-1" {
|
||||
t.Fatalf("unexpected proxy id: %#v", snap.Proxies[0])
|
||||
}
|
||||
if snap.Proxies[0].Type != "socks5h" {
|
||||
t.Fatalf("unexpected proxy type: %#v", snap.Proxies[0])
|
||||
}
|
||||
if len(snap.Accounts) != 1 {
|
||||
t.Fatalf("expected 1 account, got %d", len(snap.Accounts))
|
||||
}
|
||||
if snap.Accounts[0].ProxyID != "proxy-sh-1" {
|
||||
t.Fatalf("expected account proxy assignment preserved, got %#v", snap.Accounts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStoreDropsLegacyTokenOnlyAccounts(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{
|
||||
"accounts":[
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
)
|
||||
|
||||
func ValidateConfig(c Config) error {
|
||||
if err := ValidateProxyConfig(c.Proxies); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateAdminConfig(c.Admin); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -21,6 +24,55 @@ func ValidateConfig(c Config) error {
|
||||
if err := ValidateAutoDeleteConfig(c.AutoDelete); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateAccountProxyReferences(c.Accounts, c.Proxies); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateProxyConfig(proxies []Proxy) error {
|
||||
seen := make(map[string]struct{}, len(proxies))
|
||||
for _, proxy := range proxies {
|
||||
proxy = NormalizeProxy(proxy)
|
||||
if err := ValidateTrimmedString("proxies.id", proxy.ID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
switch proxy.Type {
|
||||
case "socks5", "socks5h":
|
||||
default:
|
||||
return fmt.Errorf("proxies.type must be one of socks5, socks5h")
|
||||
}
|
||||
if err := ValidateTrimmedString("proxies.host", proxy.Host, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateIntRange("proxies.port", proxy.Port, 1, 65535, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := seen[proxy.ID]; ok {
|
||||
return fmt.Errorf("duplicate proxy id: %s", proxy.ID)
|
||||
}
|
||||
seen[proxy.ID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateAccountProxyReferences(accounts []Account, proxies []Proxy) error {
|
||||
if len(accounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make(map[string]struct{}, len(proxies))
|
||||
for _, proxy := range proxies {
|
||||
ids[NormalizeProxy(proxy).ID] = struct{}{}
|
||||
}
|
||||
for _, acc := range accounts {
|
||||
proxyID := strings.TrimSpace(acc.ProxyID)
|
||||
if proxyID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := ids[proxyID]; !ok {
|
||||
return fmt.Errorf("account proxy_id references unknown proxy: %s", proxyID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func (c *Client) Login(ctx context.Context, acc config.Account) (string, error) {
|
||||
clients := c.requestClientsForAccount(acc)
|
||||
payload := map[string]any{
|
||||
"password": strings.TrimSpace(acc.Password),
|
||||
"device_id": "deepseek_to_api",
|
||||
@@ -27,7 +28,7 @@ func (c *Client) Login(ctx context.Context, acc config.Account) (string, error)
|
||||
} else {
|
||||
return "", errors.New("missing email/mobile")
|
||||
}
|
||||
resp, err := c.postJSON(ctx, c.regular, DeepSeekLoginURL, BaseHeaders, payload)
|
||||
resp, err := c.postJSON(ctx, clients.regular, DeepSeekLoginURL, BaseHeaders, payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -52,11 +53,12 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = c.maxRetries
|
||||
}
|
||||
clients := c.requestClientsForAuth(ctx, a)
|
||||
attempts := 0
|
||||
refreshed := false
|
||||
for attempts < maxAttempts {
|
||||
headers := c.authHeaders(a.DeepSeekToken)
|
||||
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekCreateSessionURL, headers, map[string]any{"agent": "chat"})
|
||||
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, DeepSeekCreateSessionURL, headers, map[string]any{"agent": "chat"})
|
||||
if err != nil {
|
||||
config.Logger.Warn("[create_session] request error", "error", err, "account", a.AccountID)
|
||||
attempts++
|
||||
@@ -94,11 +96,12 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = c.maxRetries
|
||||
}
|
||||
clients := c.requestClientsForAuth(ctx, a)
|
||||
attempts := 0
|
||||
refreshed := false
|
||||
for attempts < maxAttempts {
|
||||
headers := c.authHeaders(a.DeepSeekToken)
|
||||
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekCreatePowURL, headers, map[string]any{"target_path": "/api/v0/chat/completion"})
|
||||
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, DeepSeekCreatePowURL, headers, map[string]any{"target_path": "/api/v0/chat/completion"})
|
||||
if err != nil {
|
||||
config.Logger.Warn("[get_pow] request error", "error", err, "account", a.AccountID)
|
||||
attempts++
|
||||
|
||||
@@ -10,18 +10,20 @@ import (
|
||||
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
trans "ds2api/internal/deepseek/transport"
|
||||
)
|
||||
|
||||
func (c *Client) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) {
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = c.maxRetries
|
||||
}
|
||||
clients := c.requestClientsForAuth(ctx, a)
|
||||
headers := c.authHeaders(a.DeepSeekToken)
|
||||
headers["x-ds-pow-response"] = powResp
|
||||
captureSession := c.capture.Start("deepseek_completion", DeepSeekCompletionURL, a.AccountID, payload)
|
||||
attempts := 0
|
||||
for attempts < maxAttempts {
|
||||
resp, err := c.streamPost(ctx, DeepSeekCompletionURL, headers, payload)
|
||||
resp, err := c.streamPost(ctx, clients.stream, DeepSeekCompletionURL, headers, payload)
|
||||
if err != nil {
|
||||
attempts++
|
||||
time.Sleep(time.Second)
|
||||
@@ -44,11 +46,12 @@ func (c *Client) CallCompletion(ctx context.Context, a *auth.RequestAuth, payloa
|
||||
return nil, errors.New("completion failed")
|
||||
}
|
||||
|
||||
func (c *Client) streamPost(ctx context.Context, url string, headers map[string]string, payload any) (*http.Response, error) {
|
||||
func (c *Client) streamPost(ctx context.Context, doer trans.Doer, url string, headers map[string]string, payload any) (*http.Response, error) {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clients := c.requestClientsFromContext(ctx)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -56,7 +59,7 @@ func (c *Client) streamPost(ctx context.Context, url string, headers map[string]
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := c.stream.Do(req)
|
||||
resp, err := doer.Do(req)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[deepseek] fingerprint stream request failed, fallback to std transport", "url", url, "error", err)
|
||||
req2, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
|
||||
@@ -66,7 +69,7 @@ func (c *Client) streamPost(ctx context.Context, url string, headers map[string]
|
||||
for k, v := range headers {
|
||||
req2.Header.Set(k, v)
|
||||
}
|
||||
return c.fallbackS.Do(req2)
|
||||
return clients.fallbackS.Do(req2)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ func (c *Client) callContinue(ctx context.Context, a *auth.RequestAuth, sessionI
|
||||
if strings.TrimSpace(sessionID) == "" || responseMessageID <= 0 {
|
||||
return nil, errors.New("missing continue identifiers")
|
||||
}
|
||||
clients := c.requestClientsForAuth(ctx, a)
|
||||
headers := c.authHeaders(a.DeepSeekToken)
|
||||
headers["x-ds-pow-response"] = powResp
|
||||
payload := map[string]any{
|
||||
@@ -60,7 +61,7 @@ func (c *Client) callContinue(ctx context.Context, a *auth.RequestAuth, sessionI
|
||||
}
|
||||
config.Logger.Info("[auto_continue] calling continue", "session_id", sessionID, "message_id", responseMessageID)
|
||||
captureSession := c.capture.Start("deepseek_continue", DeepSeekContinueURL, a.AccountID, payload)
|
||||
resp, err := c.streamPost(ctx, DeepSeekContinueURL, headers, payload)
|
||||
resp, err := c.streamPost(ctx, clients.stream, DeepSeekContinueURL, headers, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package deepseek
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ds2api/internal/auth"
|
||||
@@ -24,18 +25,22 @@ type Client struct {
|
||||
fallback *http.Client
|
||||
fallbackS *http.Client
|
||||
maxRetries int
|
||||
|
||||
proxyClientsMu sync.RWMutex
|
||||
proxyClients map[string]requestClients
|
||||
}
|
||||
|
||||
func NewClient(store *config.Store, resolver *auth.Resolver) *Client {
|
||||
return &Client{
|
||||
Store: store,
|
||||
Auth: resolver,
|
||||
capture: devcapture.Global(),
|
||||
regular: trans.New(60 * time.Second),
|
||||
stream: trans.New(0),
|
||||
fallback: &http.Client{Timeout: 60 * time.Second},
|
||||
fallbackS: &http.Client{Timeout: 0},
|
||||
maxRetries: 3,
|
||||
Store: store,
|
||||
Auth: resolver,
|
||||
capture: devcapture.Global(),
|
||||
regular: trans.New(60 * time.Second),
|
||||
stream: trans.New(0),
|
||||
fallback: &http.Client{Timeout: 60 * time.Second},
|
||||
fallbackS: &http.Client{Timeout: 0},
|
||||
maxRetries: 3,
|
||||
proxyClients: map[string]requestClients{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url st
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
clients := c.requestClientsFromContext(ctx)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -44,7 +45,7 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url st
|
||||
for k, v := range headers {
|
||||
req2.Header.Set(k, v)
|
||||
}
|
||||
resp, err = c.fallback.Do(req2)
|
||||
resp, err = clients.fallback.Do(req2)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -64,6 +65,7 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url st
|
||||
}
|
||||
|
||||
func (c *Client) getJSONWithStatus(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, int, error) {
|
||||
clients := c.requestClientsFromContext(ctx)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -81,7 +83,7 @@ func (c *Client) getJSONWithStatus(ctx context.Context, doer trans.Doer, url str
|
||||
for k, v := range headers {
|
||||
req2.Header.Set(k, v)
|
||||
}
|
||||
resp, err = c.fallback.Do(req2)
|
||||
resp, err = clients.fallback.Do(req2)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = c.maxRetries
|
||||
}
|
||||
clients := c.requestClientsForAuth(ctx, a)
|
||||
|
||||
stats := &SessionStats{
|
||||
AccountID: a.AccountID,
|
||||
@@ -50,7 +51,7 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt
|
||||
// 构建请求 URL
|
||||
reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
|
||||
|
||||
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
|
||||
resp, status, err := c.getJSONWithStatus(ctx, clients.regular, reqURL, headers)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[get_session_count] request error", "error", err, "account", a.AccountID)
|
||||
attempts++
|
||||
@@ -106,10 +107,11 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt
|
||||
|
||||
// GetSessionCountForToken 直接使用 token 获取会话数量(直通模式)
|
||||
func (c *Client) GetSessionCountForToken(ctx context.Context, token string) (*SessionStats, error) {
|
||||
clients := c.requestClientsFromContext(ctx)
|
||||
headers := c.authHeaders(token)
|
||||
reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
|
||||
|
||||
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
|
||||
resp, status, err := c.getJSONWithStatus(ctx, clients.regular, reqURL, headers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -160,7 +162,7 @@ func (c *Client) GetSessionCountAll(ctx context.Context) []*SessionStats {
|
||||
// 如果没有 token,尝试登录获取
|
||||
if token == "" {
|
||||
var err error
|
||||
token, err = c.Login(ctx, acc)
|
||||
token, err = c.Login(auth.WithAuth(ctx, &auth.RequestAuth{AccountID: acc.Identifier(), Account: acc}), acc)
|
||||
if err != nil {
|
||||
results = append(results, &SessionStats{
|
||||
AccountID: accountID,
|
||||
@@ -171,7 +173,8 @@ func (c *Client) GetSessionCountAll(ctx context.Context) []*SessionStats {
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := c.GetSessionCountForToken(ctx, token)
|
||||
ctxWithAuth := auth.WithAuth(ctx, &auth.RequestAuth{AccountID: acc.Identifier(), Account: acc, DeepSeekToken: token})
|
||||
stats, err := c.GetSessionCountForToken(ctxWithAuth, token)
|
||||
if err != nil {
|
||||
results = append(results, &SessionStats{
|
||||
AccountID: accountID,
|
||||
@@ -190,6 +193,7 @@ func (c *Client) GetSessionCountAll(ctx context.Context) []*SessionStats {
|
||||
|
||||
// FetchSessionPage 获取会话列表(支持分页)
|
||||
func (c *Client) FetchSessionPage(ctx context.Context, a *auth.RequestAuth, cursor string) ([]SessionInfo, bool, error) {
|
||||
clients := c.requestClientsForAuth(ctx, a)
|
||||
headers := c.authHeaders(a.DeepSeekToken)
|
||||
|
||||
// 构建请求 URL
|
||||
@@ -200,7 +204,7 @@ func (c *Client) FetchSessionPage(ctx context.Context, a *auth.RequestAuth, curs
|
||||
}
|
||||
reqURL := DeepSeekFetchSessionURL + "?" + params.Encode()
|
||||
|
||||
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
|
||||
resp, status, err := c.getJSONWithStatus(ctx, clients.regular, reqURL, headers)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, session
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = c.maxRetries
|
||||
}
|
||||
clients := c.requestClientsForAuth(ctx, a)
|
||||
|
||||
result := &DeleteSessionResult{
|
||||
SessionID: sessionID,
|
||||
@@ -42,7 +43,7 @@ func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, session
|
||||
"chat_session_id": sessionID,
|
||||
}
|
||||
|
||||
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
|
||||
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, DeepSeekDeleteSessionURL, headers, payload)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[delete_session] request error", "error", err, "session_id", sessionID)
|
||||
attempts++
|
||||
@@ -81,6 +82,7 @@ func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, session
|
||||
|
||||
// DeleteSessionForToken 直接使用 token 删除会话(直通模式)
|
||||
func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*DeleteSessionResult, error) {
|
||||
clients := c.requestClientsFromContext(ctx)
|
||||
result := &DeleteSessionResult{
|
||||
SessionID: sessionID,
|
||||
}
|
||||
@@ -95,7 +97,7 @@ func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessio
|
||||
"chat_session_id": sessionID,
|
||||
}
|
||||
|
||||
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
|
||||
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, DeepSeekDeleteSessionURL, headers, payload)
|
||||
if err != nil {
|
||||
result.ErrorMessage = err.Error()
|
||||
return result, err
|
||||
@@ -114,10 +116,11 @@ func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessio
|
||||
|
||||
// DeleteAllSessions 删除所有会话(谨慎使用)
|
||||
func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) error {
|
||||
clients := c.requestClientsForAuth(ctx, a)
|
||||
headers := c.authHeaders(a.DeepSeekToken)
|
||||
payload := map[string]any{}
|
||||
|
||||
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
|
||||
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[delete_all_sessions] request error", "error", err)
|
||||
return err
|
||||
@@ -135,10 +138,11 @@ func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) err
|
||||
|
||||
// DeleteAllSessionsForToken 直接使用 token 删除所有会话(直通模式)
|
||||
func (c *Client) DeleteAllSessionsForToken(ctx context.Context, token string) error {
|
||||
clients := c.requestClientsFromContext(ctx)
|
||||
headers := c.authHeaders(token)
|
||||
payload := map[string]any{}
|
||||
|
||||
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
|
||||
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[delete_all_sessions_for_token] request error", "error", err)
|
||||
return err
|
||||
|
||||
239
internal/deepseek/proxy.go
Normal file
239
internal/deepseek/proxy.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package deepseek
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
trans "ds2api/internal/deepseek/transport"
|
||||
)
|
||||
|
||||
type requestClients struct {
|
||||
regular trans.Doer
|
||||
stream trans.Doer
|
||||
fallback *http.Client
|
||||
fallbackS *http.Client
|
||||
}
|
||||
|
||||
type hostLookupFunc func(ctx context.Context, network, host string) ([]string, error)
|
||||
|
||||
var proxyConnectivityTestURL = "https://chat.deepseek.com/"
|
||||
|
||||
var defaultHostLookup hostLookupFunc = func(ctx context.Context, _ string, host string) ([]string, error) {
|
||||
return net.DefaultResolver.LookupHost(ctx, host)
|
||||
}
|
||||
|
||||
func proxyDialAddress(ctx context.Context, proxyType, address string, lookup hostLookupFunc) (string, error) {
|
||||
proxyType = strings.ToLower(strings.TrimSpace(proxyType))
|
||||
if proxyType != "socks5" {
|
||||
return address, nil
|
||||
}
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if net.ParseIP(host) != nil {
|
||||
return address, nil
|
||||
}
|
||||
if lookup == nil {
|
||||
lookup = defaultHostLookup
|
||||
}
|
||||
addrs, err := lookup(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return "", fmt.Errorf("no ip address resolved for %s", host)
|
||||
}
|
||||
return net.JoinHostPort(addrs[0], port), nil
|
||||
}
|
||||
|
||||
func proxyCacheKey(proxyCfg config.Proxy) string {
|
||||
proxyCfg = config.NormalizeProxy(proxyCfg)
|
||||
return strings.Join([]string{
|
||||
proxyCfg.ID,
|
||||
proxyCfg.Type,
|
||||
strings.ToLower(proxyCfg.Host),
|
||||
strconv.Itoa(proxyCfg.Port),
|
||||
proxyCfg.Username,
|
||||
proxyCfg.Password,
|
||||
}, "|")
|
||||
}
|
||||
|
||||
func proxyDialContext(proxyCfg config.Proxy) (trans.DialContextFunc, error) {
|
||||
proxyCfg = config.NormalizeProxy(proxyCfg)
|
||||
var authCfg *proxy.Auth
|
||||
if proxyCfg.Username != "" || proxyCfg.Password != "" {
|
||||
authCfg = &proxy.Auth{User: proxyCfg.Username, Password: proxyCfg.Password}
|
||||
}
|
||||
forward := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
|
||||
dialer, err := proxy.SOCKS5("tcp", net.JoinHostPort(proxyCfg.Host, strconv.Itoa(proxyCfg.Port)), authCfg, forward)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
target, err := proxyDialAddress(ctx, proxyCfg.Type, address, defaultHostLookup)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ctxDialer, ok := dialer.(proxy.ContextDialer); ok {
|
||||
return ctxDialer.DialContext(ctx, network, target)
|
||||
}
|
||||
return dialer.Dial(network, target)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) defaultRequestClients() requestClients {
|
||||
return requestClients{
|
||||
regular: c.regular,
|
||||
stream: c.stream,
|
||||
fallback: c.fallback,
|
||||
fallbackS: c.fallbackS,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) resolveProxyForAccount(acc config.Account) (config.Proxy, bool) {
|
||||
if c == nil || c.Store == nil {
|
||||
return config.Proxy{}, false
|
||||
}
|
||||
proxyID := strings.TrimSpace(acc.ProxyID)
|
||||
if proxyID == "" {
|
||||
return config.Proxy{}, false
|
||||
}
|
||||
snap := c.Store.Snapshot()
|
||||
for _, proxyCfg := range snap.Proxies {
|
||||
proxyCfg = config.NormalizeProxy(proxyCfg)
|
||||
if proxyCfg.ID == proxyID {
|
||||
return proxyCfg, true
|
||||
}
|
||||
}
|
||||
return config.Proxy{}, false
|
||||
}
|
||||
|
||||
func (c *Client) requestClientsFromContext(ctx context.Context) requestClients {
|
||||
if a, ok := auth.FromContext(ctx); ok {
|
||||
return c.requestClientsForAccount(a.Account)
|
||||
}
|
||||
return c.defaultRequestClients()
|
||||
}
|
||||
|
||||
func (c *Client) requestClientsForAuth(ctx context.Context, a *auth.RequestAuth) requestClients {
|
||||
if a != nil {
|
||||
return c.requestClientsForAccount(a.Account)
|
||||
}
|
||||
return c.requestClientsFromContext(ctx)
|
||||
}
|
||||
|
||||
func (c *Client) requestClientsForAccount(acc config.Account) requestClients {
|
||||
proxyCfg, ok := c.resolveProxyForAccount(acc)
|
||||
if !ok {
|
||||
return c.defaultRequestClients()
|
||||
}
|
||||
|
||||
key := proxyCacheKey(proxyCfg)
|
||||
c.proxyClientsMu.RLock()
|
||||
cached, ok := c.proxyClients[key]
|
||||
c.proxyClientsMu.RUnlock()
|
||||
if ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
dialContext, err := proxyDialContext(proxyCfg)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[proxy] build dialer failed", "proxy_id", proxyCfg.ID, "error", err)
|
||||
return c.defaultRequestClients()
|
||||
}
|
||||
|
||||
bundle := requestClients{
|
||||
regular: trans.NewWithDialContext(60*time.Second, dialContext),
|
||||
stream: trans.NewWithDialContext(0, dialContext),
|
||||
fallback: trans.NewFallbackClient(60*time.Second, dialContext),
|
||||
fallbackS: trans.NewFallbackClient(0, dialContext),
|
||||
}
|
||||
|
||||
c.proxyClientsMu.Lock()
|
||||
if c.proxyClients == nil {
|
||||
c.proxyClients = make(map[string]requestClients)
|
||||
}
|
||||
c.proxyClients[key] = bundle
|
||||
c.proxyClientsMu.Unlock()
|
||||
return bundle
|
||||
}
|
||||
|
||||
func applyProxyConnectivityHeaders(req *http.Request) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
for key, value := range BaseHeaders {
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
if key == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func proxyConnectivityStatus(statusCode int) (bool, string) {
|
||||
switch {
|
||||
case statusCode >= 200 && statusCode < 300:
|
||||
return true, fmt.Sprintf("代理可达,目标返回 HTTP %d", statusCode)
|
||||
case statusCode >= 300 && statusCode < 500:
|
||||
return true, fmt.Sprintf("代理可达,但目标返回 HTTP %d(可能是风控或挑战)", statusCode)
|
||||
default:
|
||||
return false, fmt.Sprintf("目标返回 HTTP %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyConnectivity(ctx context.Context, proxyCfg config.Proxy) map[string]any {
|
||||
start := time.Now()
|
||||
proxyCfg = config.NormalizeProxy(proxyCfg)
|
||||
result := map[string]any{
|
||||
"success": false,
|
||||
"proxy_id": proxyCfg.ID,
|
||||
"proxy_type": proxyCfg.Type,
|
||||
"response_time": 0,
|
||||
}
|
||||
|
||||
if err := config.ValidateProxyConfig([]config.Proxy{proxyCfg}); err != nil {
|
||||
result["message"] = "代理配置无效: " + err.Error()
|
||||
return result
|
||||
}
|
||||
dialContext, err := proxyDialContext(proxyCfg)
|
||||
if err != nil {
|
||||
result["message"] = "代理拨号器初始化失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
client := trans.NewFallbackClient(15*time.Second, dialContext)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, proxyConnectivityTestURL, nil)
|
||||
if err != nil {
|
||||
result["message"] = err.Error()
|
||||
return result
|
||||
}
|
||||
applyProxyConnectivityHeaders(req)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
if err != nil {
|
||||
result["message"] = err.Error()
|
||||
return result
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := resp.Body.Close(); closeErr != nil {
|
||||
config.Logger.Warn("[proxy] close response body failed", "proxy_id", proxyCfg.ID, "error", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
result["status_code"] = resp.StatusCode
|
||||
result["success"], result["message"] = proxyConnectivityStatus(resp.StatusCode)
|
||||
return result
|
||||
}
|
||||
85
internal/deepseek/proxy_test.go
Normal file
85
internal/deepseek/proxy_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package deepseek
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProxyDialAddressUsesLocalResolutionForSocks5(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
resolved, err := proxyDialAddress(ctx, "socks5", "example.com:443", func(_ context.Context, network, host string) ([]string, error) {
|
||||
if network != "ip" {
|
||||
t.Fatalf("unexpected lookup network: %q", network)
|
||||
}
|
||||
if host != "example.com" {
|
||||
t.Fatalf("unexpected lookup host: %q", host)
|
||||
}
|
||||
return []string{"203.0.113.10"}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("proxyDialAddress returned error: %v", err)
|
||||
}
|
||||
if resolved != "203.0.113.10:443" {
|
||||
t.Fatalf("expected locally resolved address, got %q", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyDialAddressKeepsHostnameForSocks5h(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
lookups := 0
|
||||
resolved, err := proxyDialAddress(ctx, "socks5h", "example.com:443", func(_ context.Context, network, host string) ([]string, error) {
|
||||
lookups++
|
||||
return []string{"203.0.113.10"}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("proxyDialAddress returned error: %v", err)
|
||||
}
|
||||
if resolved != "example.com:443" {
|
||||
t.Fatalf("expected hostname preserved for remote DNS, got %q", resolved)
|
||||
}
|
||||
if lookups != 0 {
|
||||
t.Fatalf("expected no local DNS lookup for socks5h, got %d", lookups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProxyConnectivityHeadersUsesBaseHeaders(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "https://chat.deepseek.com/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest returned error: %v", err)
|
||||
}
|
||||
|
||||
applyProxyConnectivityHeaders(req)
|
||||
|
||||
for key, want := range BaseHeaders {
|
||||
if got := req.Header.Get(key); got != want {
|
||||
t.Fatalf("expected header %q=%q, got %q", key, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyConnectivityStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
success bool
|
||||
wantText string
|
||||
}{
|
||||
{name: "ok", statusCode: 200, success: true, wantText: "HTTP 200"},
|
||||
{name: "challenge", statusCode: 403, success: true, wantText: "风控或挑战"},
|
||||
{name: "upstream error", statusCode: 502, success: false, wantText: "HTTP 502"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
success, message := proxyConnectivityStatus(tc.statusCode)
|
||||
if success != tc.success {
|
||||
t.Fatalf("expected success=%v, got %v", tc.success, success)
|
||||
}
|
||||
if message == "" || !strings.Contains(message, tc.wantText) {
|
||||
t.Fatalf("expected message to contain %q, got %q", tc.wantText, message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,21 +15,33 @@ type Doer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
type DialContextFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(timeout time.Duration) *Client {
|
||||
return NewWithDialContext(timeout, nil)
|
||||
}
|
||||
|
||||
func NewWithDialContext(timeout time.Duration, dialContext DialContextFunc) *Client {
|
||||
useEnvProxy := dialContext == nil
|
||||
if dialContext == nil {
|
||||
dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
||||
}
|
||||
base := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
ForceAttemptHTTP2: false,
|
||||
MaxIdleConns: 200,
|
||||
MaxIdleConnsPerHost: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
DialContext: (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
DialTLSContext: safariTLSDialer(),
|
||||
DialContext: dialContext,
|
||||
DialTLSContext: safariTLSDialer(dialContext),
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
}
|
||||
if useEnvProxy {
|
||||
base.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
return &Client{http: &http.Client{Timeout: timeout, Transport: base}}
|
||||
}
|
||||
|
||||
@@ -37,10 +49,31 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
return c.http.Do(req)
|
||||
}
|
||||
|
||||
func safariTLSDialer() func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var dialer net.Dialer
|
||||
func NewFallbackClient(timeout time.Duration, dialContext DialContextFunc) *http.Client {
|
||||
useEnvProxy := dialContext == nil
|
||||
if dialContext == nil {
|
||||
dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
||||
}
|
||||
base := &http.Transport{
|
||||
ForceAttemptHTTP2: false,
|
||||
MaxIdleConns: 200,
|
||||
MaxIdleConnsPerHost: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
DialContext: dialContext,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
}
|
||||
if useEnvProxy {
|
||||
base.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
return &http.Client{Timeout: timeout, Transport: base}
|
||||
}
|
||||
|
||||
func safariTLSDialer(dialContext DialContextFunc) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if dialContext == nil {
|
||||
dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
||||
}
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
plainConn, err := dialer.DialContext(ctx, network, addr)
|
||||
plainConn, err := dialContext(ctx, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function AppRoutes() {
|
||||
{!isProduction && (
|
||||
<Route path="/" element={<LandingPage onEnter={() => navigate('/admin')} />} />
|
||||
)}
|
||||
<Route path={isProduction ? "/" : "/admin"} element={
|
||||
<Route path={isProduction ? "/*" : "/admin/*"} element={
|
||||
token ? (
|
||||
<DashboardShell
|
||||
token={token}
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
|
||||
batchProgress,
|
||||
sessionCounts,
|
||||
deletingSessions,
|
||||
updatingProxy,
|
||||
addKey,
|
||||
deleteKey,
|
||||
addAccount,
|
||||
@@ -52,6 +53,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
|
||||
testAccount,
|
||||
testAllAccounts,
|
||||
deleteAllSessions,
|
||||
updateAccountProxy,
|
||||
} = useAccountActions({
|
||||
apiFetch,
|
||||
t,
|
||||
@@ -107,16 +109,19 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
|
||||
batchProgress={batchProgress}
|
||||
sessionCounts={sessionCounts}
|
||||
deletingSessions={deletingSessions}
|
||||
updatingProxy={updatingProxy}
|
||||
totalAccounts={totalAccounts}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalPages={totalPages}
|
||||
resolveAccountIdentifier={resolveAccountIdentifier}
|
||||
proxies={config?.proxies || []}
|
||||
onTestAll={testAllAccounts}
|
||||
onShowAddAccount={() => setShowAddAccount(true)}
|
||||
onTestAccount={testAccount}
|
||||
onDeleteAccount={deleteAccount}
|
||||
onDeleteAllSessions={deleteAllSessions}
|
||||
onUpdateAccountProxy={updateAccountProxy}
|
||||
onPrevPage={() => fetchAccounts(page - 1)}
|
||||
onNextPage={() => fetchAccounts(page + 1)}
|
||||
onPageSizeChange={changePageSize}
|
||||
|
||||
@@ -11,16 +11,19 @@ export default function AccountsTable({
|
||||
batchProgress,
|
||||
sessionCounts,
|
||||
deletingSessions,
|
||||
updatingProxy,
|
||||
totalAccounts,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
resolveAccountIdentifier,
|
||||
proxies,
|
||||
onTestAll,
|
||||
onShowAddAccount,
|
||||
onTestAccount,
|
||||
onDeleteAccount,
|
||||
onDeleteAllSessions,
|
||||
onUpdateAccountProxy,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
onPageSizeChange,
|
||||
@@ -102,6 +105,7 @@ export default function AccountsTable({
|
||||
) : accounts.length > 0 ? (
|
||||
accounts.map((acc, i) => {
|
||||
const id = resolveAccountIdentifier(acc)
|
||||
const assignedProxy = proxies.find(proxy => proxy.id === acc.proxy_id)
|
||||
const runtimeUnknown = envBacked && !acc.test_status
|
||||
const isActive = acc.test_status === 'ok' || acc.has_token
|
||||
return (
|
||||
@@ -150,10 +154,28 @@ export default function AccountsTable({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{acc.proxy_id && (
|
||||
<span className="font-mono bg-amber-500/10 text-amber-500 px-1.5 py-0.5 rounded text-[10px]">
|
||||
{t('accountManager.proxyBadge', { name: assignedProxy ? (assignedProxy.name || `${assignedProxy.host}:${assignedProxy.port}`) : acc.proxy_id })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start lg:self-auto ml-5 lg:ml-0">
|
||||
<select
|
||||
value={acc.proxy_id || ''}
|
||||
onChange={e => onUpdateAccountProxy(id, e.target.value)}
|
||||
disabled={updatingProxy?.[id]}
|
||||
className="max-w-[180px] px-2.5 py-1.5 text-[10px] lg:text-xs bg-secondary border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
>
|
||||
<option value="">{t('accountManager.proxyNone')}</option>
|
||||
{proxies.map(proxy => (
|
||||
<option key={proxy.id} value={proxy.id}>
|
||||
{proxy.name || `${proxy.host}:${proxy.port}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => onTestAccount(id)}
|
||||
disabled={testing[id]}
|
||||
|
||||
@@ -12,6 +12,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
||||
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] })
|
||||
const [sessionCounts, setSessionCounts] = useState({})
|
||||
const [deletingSessions, setDeletingSessions] = useState({})
|
||||
const [updatingProxy, setUpdatingProxy] = useState({})
|
||||
|
||||
const addKey = async () => {
|
||||
if (!newKey.trim()) return
|
||||
@@ -213,6 +214,34 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
||||
}
|
||||
}
|
||||
|
||||
const updateAccountProxy = async (identifier, proxyID) => {
|
||||
const accountID = String(identifier || '').trim()
|
||||
if (!accountID) {
|
||||
onMessage('error', t('accountManager.invalidIdentifier'))
|
||||
return
|
||||
}
|
||||
setUpdatingProxy(prev => ({ ...prev, [accountID]: true }))
|
||||
try {
|
||||
const res = await apiFetch(`/admin/accounts/${encodeURIComponent(accountID)}/proxy`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ proxy_id: proxyID || '' }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
onMessage('error', data.detail || t('messages.requestFailed'))
|
||||
return
|
||||
}
|
||||
onMessage('success', t('accountManager.proxyUpdateSuccess'))
|
||||
fetchAccounts()
|
||||
onRefresh()
|
||||
} catch (_err) {
|
||||
onMessage('error', t('messages.networkError'))
|
||||
} finally {
|
||||
setUpdatingProxy(prev => ({ ...prev, [accountID]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showAddKey,
|
||||
setShowAddKey,
|
||||
@@ -230,6 +259,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
||||
batchProgress,
|
||||
sessionCounts,
|
||||
deletingSessions,
|
||||
updatingProxy,
|
||||
addKey,
|
||||
deleteKey,
|
||||
addAccount,
|
||||
@@ -237,5 +267,6 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
||||
testAccount,
|
||||
testAllAccounts,
|
||||
deleteAllSessions,
|
||||
updateAccountProxy,
|
||||
}
|
||||
}
|
||||
|
||||
464
webui/src/features/proxy/ProxyManagerContainer.jsx
Normal file
464
webui/src/features/proxy/ProxyManagerContainer.jsx
Normal file
@@ -0,0 +1,464 @@
|
||||
import { useState } from 'react'
|
||||
import { Pencil, Play, Plus, Shield, Trash2, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { useI18n } from '../../i18n'
|
||||
|
||||
async function readApiResponse(res, nonJsonMessage) {
|
||||
const contentType = String(res.headers.get('content-type') || '').toLowerCase()
|
||||
const raw = await res.text()
|
||||
const trimmed = raw.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch (_err) {
|
||||
if (!res.ok) {
|
||||
return { detail: trimmed }
|
||||
}
|
||||
throw new Error(nonJsonMessage)
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return { detail: trimmed }
|
||||
}
|
||||
|
||||
throw new Error(nonJsonMessage)
|
||||
}
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: '',
|
||||
type: 'socks5h',
|
||||
host: '',
|
||||
port: 1080,
|
||||
username: '',
|
||||
password: '',
|
||||
}
|
||||
|
||||
function createEmptyProxyForm() {
|
||||
return { ...EMPTY_FORM }
|
||||
}
|
||||
|
||||
function ProxyStatusBadge({ t, result, testing = false }) {
|
||||
if (testing) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full border border-border bg-muted/40 px-2 py-1 text-[10px] font-medium text-muted-foreground">
|
||||
{t('proxyManager.testing')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (!result) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full border border-border bg-muted/20 px-2 py-1 text-[10px] font-medium text-muted-foreground">
|
||||
{t('proxyManager.untested')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center rounded-full border px-2 py-1 text-[10px] font-medium',
|
||||
result.success
|
||||
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-500'
|
||||
: 'border-destructive/20 bg-destructive/10 text-destructive'
|
||||
)}
|
||||
>
|
||||
{result.success
|
||||
? t('proxyManager.testSuccessShort', { time: result.response_time ?? 0 })
|
||||
: t('proxyManager.testFailedShort')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProxiesTable({
|
||||
t,
|
||||
proxies,
|
||||
testing,
|
||||
testResults,
|
||||
onCreate,
|
||||
onTest,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t('proxyManager.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('proxyManager.desc')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('proxyManager.addProxy')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{proxies.length === 0 ? (
|
||||
<div className="p-10 text-center text-muted-foreground">{t('proxyManager.noProxies')}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{proxies.map((proxy) => {
|
||||
const result = testResults[proxy.id]
|
||||
return (
|
||||
<div key={proxy.id} className="p-4 md:p-5 flex flex-col lg:flex-row lg:items-center justify-between gap-4 hover:bg-muted/40 transition-colors">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="font-medium text-foreground">{proxy.name || `${proxy.host}:${proxy.port}`}</div>
|
||||
<span className="inline-flex items-center rounded-full border border-primary/20 bg-primary/10 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-primary">
|
||||
{proxy.type}
|
||||
</span>
|
||||
{proxy.username && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/20 px-2 py-1 text-[10px] font-medium text-muted-foreground">
|
||||
<Shield className="w-3 h-3" />
|
||||
{proxy.username}
|
||||
</span>
|
||||
)}
|
||||
<ProxyStatusBadge t={t} result={result} testing={testing[proxy.id]} />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono bg-muted/30 px-2 py-1 rounded border border-border">
|
||||
{proxy.host}:{proxy.port}
|
||||
</span>
|
||||
{proxy.has_password && (
|
||||
<span className="rounded-full border border-border bg-muted/20 px-2 py-1 text-[10px]">
|
||||
{t('proxyManager.authEnabled')}
|
||||
</span>
|
||||
)}
|
||||
{result?.message && (
|
||||
<span className="truncate max-w-full">{result.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 self-start lg:self-auto">
|
||||
<button
|
||||
onClick={() => onTest(proxy)}
|
||||
disabled={testing[proxy.id]}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md border border-border hover:bg-secondary transition-colors text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
{t('proxyManager.testAction')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEdit(proxy)}
|
||||
className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors"
|
||||
title={t('proxyManager.editProxy')}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(proxy)}
|
||||
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
|
||||
title={t('proxyManager.deleteProxy')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProxyFormModal({
|
||||
show,
|
||||
t,
|
||||
form,
|
||||
setForm,
|
||||
editingProxy,
|
||||
loading,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) {
|
||||
if (!show) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isEditing = Boolean(editingProxy?.id)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in">
|
||||
<div className="bg-card w-full max-w-lg rounded-xl border border-border shadow-2xl overflow-hidden animate-in zoom-in-95">
|
||||
<div className="p-4 border-b border-border flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="font-semibold">
|
||||
{isEditing ? t('proxyManager.modalEditTitle') : t('proxyManager.modalAddTitle')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('proxyManager.modalDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">{t('proxyManager.nameLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder={t('proxyManager.namePlaceholder')}
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">{t('proxyManager.typeLabel')}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.type}
|
||||
onChange={e => setForm({ ...form, type: e.target.value })}
|
||||
>
|
||||
<option value="socks5">socks5</option>
|
||||
<option value="socks5h">socks5h</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-[1fr_128px] gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">{t('proxyManager.hostLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder={t('proxyManager.hostPlaceholder')}
|
||||
value={form.host}
|
||||
onChange={e => setForm({ ...form, host: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">{t('proxyManager.portLabel')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
className="input-field"
|
||||
value={form.port}
|
||||
onChange={e => setForm({ ...form, port: Number(e.target.value) || '' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">{t('proxyManager.usernameLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder={t('proxyManager.usernamePlaceholder')}
|
||||
value={form.username}
|
||||
onChange={e => setForm({ ...form, username: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">{t('proxyManager.passwordLabel')}</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field bg-[#09090b]"
|
||||
placeholder={t('proxyManager.passwordPlaceholder')}
|
||||
value={form.password}
|
||||
onChange={e => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
{isEditing && (
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">{t('proxyManager.passwordKeepHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('proxyManager.typeHelp')}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg border border-border hover:bg-secondary transition-colors text-sm font-medium"
|
||||
>
|
||||
{t('actions.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? t('proxyManager.saving')
|
||||
: (isEditing ? t('proxyManager.saveEdit') : t('proxyManager.saveAdd'))}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProxyManagerContainer({ config, onRefresh, onMessage, authFetch }) {
|
||||
const { t } = useI18n()
|
||||
const apiFetch = authFetch || fetch
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingProxy, setEditingProxy] = useState(null)
|
||||
const [form, setForm] = useState(createEmptyProxyForm())
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState({})
|
||||
const [testResults, setTestResults] = useState({})
|
||||
|
||||
const proxies = config?.proxies || []
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingProxy(null)
|
||||
setForm(createEmptyProxyForm())
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const openEdit = (proxy) => {
|
||||
setEditingProxy(proxy)
|
||||
setForm({
|
||||
name: proxy.name || '',
|
||||
type: proxy.type || 'socks5h',
|
||||
host: proxy.host || '',
|
||||
port: proxy.port || 1080,
|
||||
username: proxy.username || '',
|
||||
password: '',
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false)
|
||||
setEditingProxy(null)
|
||||
setForm(createEmptyProxyForm())
|
||||
}
|
||||
|
||||
const saveProxy = async () => {
|
||||
if (!form.host || !form.port) {
|
||||
onMessage('error', t('proxyManager.requiredFields'))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const url = editingProxy?.id
|
||||
? `/admin/proxies/${encodeURIComponent(editingProxy.id)}`
|
||||
: '/admin/proxies'
|
||||
const method = editingProxy?.id ? 'PUT' : 'POST'
|
||||
const res = await apiFetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: form.name,
|
||||
type: form.type,
|
||||
host: form.host,
|
||||
port: Number(form.port),
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
}),
|
||||
})
|
||||
const data = await readApiResponse(res, t('settings.nonJsonResponse', { status: res.status }))
|
||||
if (!res.ok) {
|
||||
onMessage('error', data.detail || t('messages.requestFailed'))
|
||||
return
|
||||
}
|
||||
await onRefresh?.()
|
||||
onMessage('success', editingProxy?.id ? t('proxyManager.updateSuccess') : t('proxyManager.addSuccess'))
|
||||
closeModal()
|
||||
} catch (err) {
|
||||
onMessage('error', err?.message || t('messages.networkError'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProxy = async (proxy) => {
|
||||
if (!confirm(t('proxyManager.deleteConfirm', { name: proxy.name || `${proxy.host}:${proxy.port}` }))) return
|
||||
try {
|
||||
const res = await apiFetch(`/admin/proxies/${encodeURIComponent(proxy.id)}`, { method: 'DELETE' })
|
||||
const data = await readApiResponse(res, t('settings.nonJsonResponse', { status: res.status }))
|
||||
if (!res.ok) {
|
||||
onMessage('error', data.detail || t('messages.deleteFailed'))
|
||||
return
|
||||
}
|
||||
await onRefresh?.()
|
||||
onMessage('success', t('messages.deleted'))
|
||||
setTestResults(prev => {
|
||||
const next = { ...prev }
|
||||
delete next[proxy.id]
|
||||
return next
|
||||
})
|
||||
} catch (err) {
|
||||
onMessage('error', err?.message || t('messages.networkError'))
|
||||
}
|
||||
}
|
||||
|
||||
const testProxy = async (proxy) => {
|
||||
setTesting(prev => ({ ...prev, [proxy.id]: true }))
|
||||
try {
|
||||
const res = await apiFetch('/admin/proxies/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ proxy_id: proxy.id }),
|
||||
})
|
||||
const data = await readApiResponse(res, t('settings.nonJsonResponse', { status: res.status }))
|
||||
setTestResults(prev => ({ ...prev, [proxy.id]: data }))
|
||||
onMessage(data.success ? 'success' : 'error', data.message || t('messages.requestFailed'))
|
||||
} catch (err) {
|
||||
onMessage('error', err?.message || t('messages.networkError'))
|
||||
} finally {
|
||||
setTesting(prev => ({ ...prev, [proxy.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
|
||||
<div className="text-[10px] text-muted-foreground font-bold uppercase tracking-wider">{t('proxyManager.totalProxies')}</div>
|
||||
<div className="mt-2 text-2xl font-bold">{proxies.length}</div>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
|
||||
<div className="text-[10px] text-muted-foreground font-bold uppercase tracking-wider">{t('proxyManager.socks5hCount')}</div>
|
||||
<div className="mt-2 text-2xl font-bold">{proxies.filter(proxy => proxy.type === 'socks5h').length}</div>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-5 shadow-sm">
|
||||
<div className="text-[10px] text-muted-foreground font-bold uppercase tracking-wider">{t('proxyManager.authProxyCount')}</div>
|
||||
<div className="mt-2 text-2xl font-bold">{proxies.filter(proxy => proxy.username || proxy.has_password).length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProxiesTable
|
||||
t={t}
|
||||
proxies={proxies}
|
||||
testing={testing}
|
||||
testResults={testResults}
|
||||
onCreate={openCreate}
|
||||
onTest={testProxy}
|
||||
onEdit={openEdit}
|
||||
onDelete={deleteProxy}
|
||||
/>
|
||||
|
||||
<ProxyFormModal
|
||||
show={showModal}
|
||||
t={t}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
editingProxy={editingProxy}
|
||||
loading={saving}
|
||||
onClose={closeModal}
|
||||
onSubmit={saveProxy}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Upload,
|
||||
@@ -8,7 +9,8 @@ import {
|
||||
Menu,
|
||||
X,
|
||||
Server,
|
||||
Users
|
||||
Users,
|
||||
Globe
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@@ -17,22 +19,40 @@ import ApiTesterContainer from '../features/apiTester/ApiTesterContainer'
|
||||
import BatchImport from '../components/BatchImport'
|
||||
import VercelSyncContainer from '../features/vercel/VercelSyncContainer'
|
||||
import SettingsContainer from '../features/settings/SettingsContainer'
|
||||
import ProxyManagerContainer from '../features/proxy/ProxyManagerContainer'
|
||||
import LanguageToggle from '../components/LanguageToggle'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
export default function DashboardShell({ token, onLogout, config, fetchConfig, showMessage, message, onForceLogout, isVercel }) {
|
||||
const { t } = useI18n()
|
||||
const [activeTab, setActiveTab] = useState('accounts')
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
const navItems = [
|
||||
{ id: 'accounts', label: t('nav.accounts.label'), icon: Users, description: t('nav.accounts.desc') },
|
||||
{ id: 'proxies', label: t('nav.proxies.label'), icon: Globe, description: t('nav.proxies.desc') },
|
||||
{ id: 'test', label: t('nav.test.label'), icon: Server, description: t('nav.test.desc') },
|
||||
{ id: 'import', label: t('nav.import.label'), icon: Upload, description: t('nav.import.desc') },
|
||||
{ id: 'vercel', label: t('nav.vercel.label'), icon: Cloud, description: t('nav.vercel.desc') },
|
||||
{ id: 'settings', label: t('nav.settings.label'), icon: SettingsIcon, description: t('nav.settings.desc') },
|
||||
]
|
||||
|
||||
const tabIds = new Set(navItems.map(item => item.id))
|
||||
const pathSegments = location.pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean)
|
||||
const routeSegments = pathSegments[0] === 'admin' ? pathSegments.slice(1) : pathSegments
|
||||
const pathTab = routeSegments[0] || ''
|
||||
const activeTab = tabIds.has(pathTab) ? pathTab : 'accounts'
|
||||
const adminBasePath = pathSegments[0] === 'admin' ? '/admin' : ''
|
||||
|
||||
const navigateToTab = useCallback((tabID) => {
|
||||
const nextPath = tabID === 'accounts'
|
||||
? `${adminBasePath || ''}/`
|
||||
: `${adminBasePath}/${tabID}`
|
||||
navigate(nextPath)
|
||||
setSidebarOpen(false)
|
||||
}, [adminBasePath, navigate])
|
||||
|
||||
const authFetch = useCallback(async (url, options = {}) => {
|
||||
const headers = {
|
||||
...options.headers,
|
||||
@@ -74,6 +94,8 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s
|
||||
switch (activeTab) {
|
||||
case 'accounts':
|
||||
return <AccountManagerContainer config={config} onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
|
||||
case 'proxies':
|
||||
return <ProxyManagerContainer config={config} onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
|
||||
case 'test':
|
||||
return <ApiTesterContainer config={config} onMessage={showMessage} authFetch={authFetch} />
|
||||
case 'import':
|
||||
@@ -121,8 +143,7 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActiveTab(item.id)
|
||||
setSidebarOpen(false)
|
||||
navigateToTab(item.id)
|
||||
}}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group border",
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
"label": "Account Management",
|
||||
"desc": "Manage the DeepSeek account pool"
|
||||
},
|
||||
"proxies": {
|
||||
"label": "Proxy IPs",
|
||||
"desc": "Manage outbound proxy nodes for accounts"
|
||||
},
|
||||
"test": {
|
||||
"label": "API Test",
|
||||
"desc": "Test API connectivity and responses"
|
||||
@@ -140,12 +144,55 @@
|
||||
"deleteAllSessions": "Delete all sessions",
|
||||
"deleteAllSessionsConfirm": "Are you sure you want to delete all sessions for this account? This action cannot be undone.",
|
||||
"deleteAllSessionsSuccess": "Successfully deleted all sessions",
|
||||
"accountProxyLabel": "Account proxy",
|
||||
"proxyNone": "Direct connection",
|
||||
"proxyBadge": "Proxy: {name}",
|
||||
"proxyUpdateSuccess": "Account proxy updated.",
|
||||
"envModeRiskTitle": "Environment-variable config mode detected (persistence risk)",
|
||||
"envModeRiskDesc": "Detected DS2API_CONFIG_JSON. If DS2API_ENV_WRITEBACK is not enabled, Admin UI edits are in-memory only and may be lost after restart.",
|
||||
"envModeWritebackPendingTitle": "Env mode + auto-persistence enabled (pending file handoff)",
|
||||
"envModeWritebackActiveTitle": "Env mode + auto-persistence active",
|
||||
"envModeWritebackDesc": "The app will auto-create/write the config file and transition to file-backed mode. Current persistence path: {path}"
|
||||
},
|
||||
"proxyManager": {
|
||||
"title": "Proxy IPs",
|
||||
"desc": "Manage SOCKS egress nodes for accounts and test outbound connectivity to DeepSeek.",
|
||||
"addProxy": "Add proxy",
|
||||
"editProxy": "Edit proxy",
|
||||
"deleteProxy": "Delete proxy",
|
||||
"modalAddTitle": "Add proxy node",
|
||||
"modalEditTitle": "Edit proxy node",
|
||||
"modalDesc": "Supports socks5 and socks5h. Accounts will use the bound node as their outbound route.",
|
||||
"nameLabel": "Proxy name",
|
||||
"namePlaceholder": "Example: Hong Kong Exit A",
|
||||
"typeLabel": "Proxy type",
|
||||
"hostLabel": "Proxy host",
|
||||
"hostPlaceholder": "127.0.0.1 or proxy hostname",
|
||||
"portLabel": "Port",
|
||||
"usernameLabel": "Username (optional)",
|
||||
"usernamePlaceholder": "Proxy auth username",
|
||||
"passwordLabel": "Password (optional)",
|
||||
"passwordPlaceholder": "Proxy auth password",
|
||||
"passwordKeepHint": "Leave blank to keep the currently stored password.",
|
||||
"typeHelp": "socks5 resolves the target hostname locally before dialing through the proxy; socks5h forwards the hostname to the proxy for remote DNS resolution.",
|
||||
"requiredFields": "Host and port are required.",
|
||||
"saving": "Saving...",
|
||||
"testing": "Testing",
|
||||
"testAction": "Check proxy",
|
||||
"untested": "Untested",
|
||||
"saveAdd": "Add proxy",
|
||||
"saveEdit": "Save changes",
|
||||
"addSuccess": "Proxy added successfully.",
|
||||
"updateSuccess": "Proxy updated successfully.",
|
||||
"deleteConfirm": "Delete proxy {name}? Accounts bound to it will fall back to direct connection.",
|
||||
"noProxies": "No proxy nodes yet.",
|
||||
"authEnabled": "Auth enabled",
|
||||
"testSuccessShort": "Reachable {time}ms",
|
||||
"testFailedShort": "Test failed",
|
||||
"totalProxies": "Total proxies",
|
||||
"socks5hCount": "socks5h nodes",
|
||||
"authProxyCount": "Authenticated nodes"
|
||||
},
|
||||
"apiTester": {
|
||||
"defaultMessage": "Hello, please introduce yourself in one sentence.",
|
||||
"models": {
|
||||
@@ -325,4 +372,4 @@
|
||||
"four": "Trigger a redeploy to apply the updated environment variables."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
"label": "账号管理",
|
||||
"desc": "管理 DeepSeek 账号池"
|
||||
},
|
||||
"proxies": {
|
||||
"label": "代理 IP",
|
||||
"desc": "管理账号可用的代理出口"
|
||||
},
|
||||
"test": {
|
||||
"label": "API 测试",
|
||||
"desc": "测试 API 连接与响应"
|
||||
@@ -140,12 +144,55 @@
|
||||
"deleteAllSessions": "删除所有会话",
|
||||
"deleteAllSessionsConfirm": "确定要删除该账号的所有会话吗?此操作不可恢复。",
|
||||
"deleteAllSessionsSuccess": "删除成功",
|
||||
"accountProxyLabel": "账号代理",
|
||||
"proxyNone": "不走代理",
|
||||
"proxyBadge": "代理: {name}",
|
||||
"proxyUpdateSuccess": "账号代理已更新",
|
||||
"envModeRiskTitle": "当前为环境变量配置模式(有持久化风险)",
|
||||
"envModeRiskDesc": "检测到 DS2API_CONFIG_JSON。若未开启 DS2API_ENV_WRITEBACK,管理台改动仅在内存生效,重启可能丢失。",
|
||||
"envModeWritebackPendingTitle": "环境变量模式 + 自动持久化已开启(等待落盘)",
|
||||
"envModeWritebackActiveTitle": "环境变量模式 + 自动持久化已生效",
|
||||
"envModeWritebackDesc": "程序会自动创建/写入配置文件并在后续切换为文件模式。当前持久化路径:{path}"
|
||||
},
|
||||
"proxyManager": {
|
||||
"title": "代理 IP",
|
||||
"desc": "维护账号可选的 SOCKS 代理节点,并测试到 DeepSeek 的出站连通性。",
|
||||
"addProxy": "添加代理",
|
||||
"editProxy": "编辑代理",
|
||||
"deleteProxy": "删除代理",
|
||||
"modalAddTitle": "添加代理节点",
|
||||
"modalEditTitle": "编辑代理节点",
|
||||
"modalDesc": "支持 socks5 与 socks5h,账号侧会按绑定结果选择出口。",
|
||||
"nameLabel": "代理名称",
|
||||
"namePlaceholder": "例如:香港出口 A",
|
||||
"typeLabel": "代理类型",
|
||||
"hostLabel": "代理主机",
|
||||
"hostPlaceholder": "127.0.0.1 或代理域名",
|
||||
"portLabel": "端口",
|
||||
"usernameLabel": "用户名(可选)",
|
||||
"usernamePlaceholder": "代理认证用户名",
|
||||
"passwordLabel": "密码(可选)",
|
||||
"passwordPlaceholder": "代理认证密码",
|
||||
"passwordKeepHint": "留空表示保留当前已保存的密码。",
|
||||
"typeHelp": "socks5 会先在本地解析目标域名,再交给代理拨号;socks5h 会把域名直接交给代理远端解析。",
|
||||
"requiredFields": "至少需要填写主机和端口。",
|
||||
"saving": "保存中...",
|
||||
"testing": "测试中",
|
||||
"testAction": "检查代理",
|
||||
"untested": "未测试",
|
||||
"saveAdd": "添加代理",
|
||||
"saveEdit": "保存修改",
|
||||
"addSuccess": "代理添加成功",
|
||||
"updateSuccess": "代理更新成功",
|
||||
"deleteConfirm": "确定要删除代理 {name} 吗?绑定到该代理的账号会自动切回直连。",
|
||||
"noProxies": "还没有任何代理节点。",
|
||||
"authEnabled": "已启用认证",
|
||||
"testSuccessShort": "已连通 {time}ms",
|
||||
"testFailedShort": "测试失败",
|
||||
"totalProxies": "代理总数",
|
||||
"socks5hCount": "socks5h 节点",
|
||||
"authProxyCount": "带认证节点"
|
||||
},
|
||||
"apiTester": {
|
||||
"defaultMessage": "你好,请用一句话介绍你自己。",
|
||||
"models": {
|
||||
@@ -325,4 +372,4 @@
|
||||
"four": "触发重新部署以应用新的环境变量。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user