feat(proxy): add proxy IP management and account routing

Add admin CRUD and connectivity checks for SOCKS5/SOCKS5H proxy nodes.

Allow accounts to bind to a proxy, route DeepSeek requests through the selected node, and expose proxy management in the admin UI.
This commit is contained in:
Jason.li
2026-04-07 02:05:25 +08:00
parent 1c95942e5d
commit 8ae2ea10c8
30 changed files with 1675 additions and 51 deletions

View File

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

View File

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

View File

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

View File

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

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

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

View File

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