From 2657d37f76b89ad79aeb729857d224c643ef82dc Mon Sep 17 00:00:00 2001
From: latticeon <35923185+latticeon@users.noreply.github.com>
Date: Mon, 16 Mar 2026 00:50:31 +0800
Subject: [PATCH 1/6] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BC=9A=E8=AF=9D?=
=?UTF-8?q?=E6=95=B0=E9=87=8F=E6=98=BE=E7=A4=BA=E4=B8=8E=E6=B8=85=E9=99=A4?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
添加会话清除功能,增强安全性,避免账号被盗等情况泄露源代码
账号列表点击测试后显示账号的会话数量
设置页添加自动清除开关,每次调用后清除被调用账号的所有会话
---
internal/adapter/openai/deps.go | 2 +
internal/adapter/openai/handler_chat.go | 13 +
internal/admin/deps.go | 3 +
internal/admin/handler.go | 1 +
internal/admin/handler_accounts_testing.go | 45 +-
internal/admin/handler_settings_parse.go | 60 +-
internal/admin/handler_settings_read.go | 1 +
internal/admin/handler_settings_write.go | 5 +-
internal/config/codec.go | 6 +
internal/config/config.go | 7 +-
internal/config/store_accessors.go | 6 +
internal/deepseek/client_http_json.go | 48 ++
internal/deepseek/client_session.go | 436 +++++++++++++
internal/deepseek/constants.go | 2 +
.../account/AccountManagerContainer.jsx | 248 ++++----
webui/src/features/account/AccountsTable.jsx | 404 ++++++------
.../src/features/account/useAccountActions.js | 46 ++
.../features/settings/AutoDeleteSection.jsx | 39 ++
.../features/settings/SettingsContainer.jsx | 3 +
.../src/features/settings/useSettingsForm.js | 5 +
webui/src/locales/en.json | 602 +++++++++---------
webui/src/locales/zh.json | 602 +++++++++---------
22 files changed, 1655 insertions(+), 929 deletions(-)
create mode 100644 internal/deepseek/client_session.go
create mode 100644 webui/src/features/settings/AutoDeleteSection.jsx
diff --git a/internal/adapter/openai/deps.go b/internal/adapter/openai/deps.go
index 6688756..b033d3d 100644
--- a/internal/adapter/openai/deps.go
+++ b/internal/adapter/openai/deps.go
@@ -19,6 +19,7 @@ type DeepSeekCaller interface {
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
+ DeleteAllSessionsForToken(ctx context.Context, token string) (int, error)
}
type ConfigReader interface {
@@ -28,6 +29,7 @@ type ConfigReader interface {
ToolcallEarlyEmitConfidence() string
ResponsesStoreTTLSeconds() int
EmbeddingsProvider() string
+ AutoDeleteSessions() bool
}
var _ AuthResolver = (*auth.Resolver)(nil)
diff --git a/internal/adapter/openai/handler_chat.go b/internal/adapter/openai/handler_chat.go
index 26a4bf2..8ff1c90 100644
--- a/internal/adapter/openai/handler_chat.go
+++ b/internal/adapter/openai/handler_chat.go
@@ -36,6 +36,19 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
return
}
defer h.Auth.Release(a)
+
+ // 自动删除会话功能
+ if h.Store.AutoDeleteSessions() && a.DeepSeekToken != "" {
+ defer func() {
+ deleted, err := h.DS.DeleteAllSessionsForToken(context.Background(), a.DeepSeekToken)
+ if err != nil {
+ config.Logger.Warn("[auto_delete_sessions] failed", "account", a.AccountID, "error", err)
+ } else {
+ config.Logger.Debug("[auto_delete_sessions] deleted", "account", a.AccountID, "count", deleted)
+ }
+ }()
+ }
+
r = r.WithContext(auth.WithAuth(r.Context(), a))
var req map[string]any
diff --git a/internal/admin/deps.go b/internal/admin/deps.go
index 7debcf0..5c58a4f 100644
--- a/internal/admin/deps.go
+++ b/internal/admin/deps.go
@@ -27,6 +27,7 @@ type ConfigStore interface {
RuntimeAccountMaxInflight() int
RuntimeAccountMaxQueue(defaultSize int) int
RuntimeGlobalMaxInflight(defaultSize int) int
+ AutoDeleteSessions() bool
}
type PoolController interface {
@@ -40,6 +41,8 @@ type DeepSeekCaller interface {
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
+ GetSessionCountForToken(ctx context.Context, token string) (*deepseek.SessionStats, error)
+ DeleteAllSessionsForToken(ctx context.Context, token string) (int, error)
}
var _ ConfigStore = (*config.Store)(nil)
diff --git a/internal/admin/handler.go b/internal/admin/handler.go
index c8f7702..96111e2 100644
--- a/internal/admin/handler.go
+++ b/internal/admin/handler.go
@@ -31,6 +31,7 @@ func RegisterRoutes(r chi.Router, h *Handler) {
pr.Get("/queue/status", h.queueStatus)
pr.Post("/accounts/test", h.testSingleAccount)
pr.Post("/accounts/test-all", h.testAllAccounts)
+ pr.Post("/accounts/sessions/delete-all", h.deleteAllSessions)
pr.Post("/import", h.batchImport)
pr.Post("/test", h.testAPI)
pr.Post("/vercel/sync", h.syncVercel)
diff --git a/internal/admin/handler_accounts_testing.go b/internal/admin/handler_accounts_testing.go
index 7a7430d..d54c4cb 100644
--- a/internal/admin/handler_accounts_testing.go
+++ b/internal/admin/handler_accounts_testing.go
@@ -89,7 +89,7 @@ func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int,
func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
start := time.Now()
identifier := acc.Identifier()
- result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model}
+ result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model, "session_count": 0}
defer func() {
status := "failed"
if ok, _ := result["success"].(bool); ok {
@@ -124,6 +124,13 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
return result
}
}
+
+ // 获取会话数量
+ sessionStats, sessionErr := h.DS.GetSessionCountForToken(ctx, token)
+ if sessionErr == nil && sessionStats != nil {
+ result["session_count"] = sessionStats.TotalCount
+ }
+
if strings.TrimSpace(message) == "" {
result["success"] = true
result["message"] = "API 测试成功(仅会话创建)"
@@ -210,3 +217,39 @@ func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)})
}
+
+func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) {
+ var req map[string]any
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ identifier, _ := req["identifier"].(string)
+ if strings.TrimSpace(identifier) == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识(identifier / email / mobile)"})
+ return
+ }
+ acc, ok := findAccountByIdentifier(h.Store, identifier)
+ if !ok {
+ writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
+ return
+ }
+
+ // 获取 token
+ token := strings.TrimSpace(acc.Token)
+ if token == "" {
+ newToken, err := h.DS.Login(r.Context(), acc)
+ if err != nil {
+ writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()})
+ return
+ }
+ token = newToken
+ _ = h.Store.UpdateAccountToken(acc.Identifier(), token)
+ }
+
+ // 删除所有会话
+ deleted, err := h.DS.DeleteAllSessionsForToken(r.Context(), token)
+ if err != nil {
+ writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error(), "deleted": deleted})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{"success": true, "deleted": deleted, "message": fmt.Sprintf("成功删除 %d 个会话", deleted)})
+}
diff --git a/internal/admin/handler_settings_parse.go b/internal/admin/handler_settings_parse.go
index 6c5b7ee..c1d735a 100644
--- a/internal/admin/handler_settings_parse.go
+++ b/internal/admin/handler_settings_parse.go
@@ -7,15 +7,30 @@ import (
"ds2api/internal/config"
)
-func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ToolcallConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, map[string]string, map[string]string, error) {
+func boolFrom(v any) bool {
+ if v == nil {
+ return false
+ }
+ switch x := v.(type) {
+ case bool:
+ return x
+ case string:
+ return strings.ToLower(strings.TrimSpace(x)) == "true"
+ default:
+ return false
+ }
+}
+
+func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ToolcallConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, map[string]string, map[string]string, error) {
var (
- adminCfg *config.AdminConfig
- runtimeCfg *config.RuntimeConfig
- toolcallCfg *config.ToolcallConfig
- respCfg *config.ResponsesConfig
- embCfg *config.EmbeddingsConfig
- claudeMap map[string]string
- aliasMap map[string]string
+ adminCfg *config.AdminConfig
+ runtimeCfg *config.RuntimeConfig
+ toolcallCfg *config.ToolcallConfig
+ respCfg *config.ResponsesConfig
+ embCfg *config.EmbeddingsConfig
+ autoDeleteCfg *config.AutoDeleteConfig
+ claudeMap map[string]string
+ aliasMap map[string]string
)
if raw, ok := req["admin"].(map[string]any); ok {
@@ -23,7 +38,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["jwt_expire_hours"]; exists {
n := intFrom(v)
if n < 1 || n > 720 {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720")
+ return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720")
}
cfg.JWTExpireHours = n
}
@@ -35,26 +50,26 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["account_max_inflight"]; exists {
n := intFrom(v)
if n < 1 || n > 256 {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256")
+ return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256")
}
cfg.AccountMaxInflight = n
}
if v, exists := raw["account_max_queue"]; exists {
n := intFrom(v)
if n < 1 || n > 200000 {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000")
+ return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000")
}
cfg.AccountMaxQueue = n
}
if v, exists := raw["global_max_inflight"]; exists {
n := intFrom(v)
if n < 1 || n > 200000 {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000")
+ return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000")
}
cfg.GlobalMaxInflight = n
}
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
+ return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
}
runtimeCfg = cfg
}
@@ -67,7 +82,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
case "feature_match", "off":
cfg.Mode = mode
default:
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.mode must be feature_match or off")
+ return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.mode must be feature_match or off")
}
}
if v, exists := raw["early_emit_confidence"]; exists {
@@ -76,7 +91,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
case "high", "low", "off":
cfg.EarlyEmitConfidence = level
default:
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.early_emit_confidence must be high, low or off")
+ return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.early_emit_confidence must be high, low or off")
}
}
toolcallCfg = cfg
@@ -87,7 +102,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["store_ttl_seconds"]; exists {
n := intFrom(v)
if n < 30 || n > 86400 {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400")
+ return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400")
}
cfg.StoreTTLSeconds = n
}
@@ -98,9 +113,6 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
cfg := &config.EmbeddingsConfig{}
if v, exists := raw["provider"]; exists {
p := strings.TrimSpace(fmt.Sprintf("%v", v))
- if p == "" {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("embeddings.provider cannot be empty")
- }
cfg.Provider = p
}
embCfg = cfg
@@ -130,5 +142,13 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
}
}
- return adminCfg, runtimeCfg, toolcallCfg, respCfg, embCfg, claudeMap, aliasMap, nil
+ if raw, ok := req["auto_delete"].(map[string]any); ok {
+ cfg := &config.AutoDeleteConfig{}
+ if v, exists := raw["sessions"]; exists {
+ cfg.Sessions = boolFrom(v)
+ }
+ autoDeleteCfg = cfg
+ }
+
+ return adminCfg, runtimeCfg, toolcallCfg, respCfg, embCfg, autoDeleteCfg, claudeMap, aliasMap, nil
}
diff --git a/internal/admin/handler_settings_read.go b/internal/admin/handler_settings_read.go
index 565519f..367e40e 100644
--- a/internal/admin/handler_settings_read.go
+++ b/internal/admin/handler_settings_read.go
@@ -28,6 +28,7 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
"toolcall": snap.Toolcall,
"responses": snap.Responses,
"embeddings": snap.Embeddings,
+ "auto_delete": snap.AutoDelete,
"claude_mapping": settingsClaudeMapping(snap),
"model_aliases": snap.ModelAliases,
"env_backed": h.Store.IsEnvBacked(),
diff --git a/internal/admin/handler_settings_write.go b/internal/admin/handler_settings_write.go
index c0076ea..76d106b 100644
--- a/internal/admin/handler_settings_write.go
+++ b/internal/admin/handler_settings_write.go
@@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
return
}
- adminCfg, runtimeCfg, toolcallCfg, responsesCfg, embeddingsCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req)
+ adminCfg, runtimeCfg, toolcallCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
@@ -60,6 +60,9 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
if embeddingsCfg != nil && strings.TrimSpace(embeddingsCfg.Provider) != "" {
c.Embeddings.Provider = strings.TrimSpace(embeddingsCfg.Provider)
}
+ if autoDeleteCfg != nil {
+ c.AutoDelete.Sessions = autoDeleteCfg.Sessions
+ }
if claudeMap != nil {
c.ClaudeMapping = claudeMap
c.ClaudeModelMap = nil
diff --git a/internal/config/codec.go b/internal/config/codec.go
index 2a23e20..24cc10e 100644
--- a/internal/config/codec.go
+++ b/internal/config/codec.go
@@ -47,6 +47,7 @@ func (c Config) MarshalJSON() ([]byte, error) {
if strings.TrimSpace(c.Embeddings.Provider) != "" {
m["embeddings"] = c.Embeddings
}
+ m["auto_delete"] = c.AutoDelete
if c.VercelSyncHash != "" {
m["_vercel_sync_hash"] = c.VercelSyncHash
}
@@ -108,6 +109,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(v, &c.Embeddings); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
+ case "auto_delete":
+ if err := json.Unmarshal(v, &c.AutoDelete); err != nil {
+ return fmt.Errorf("invalid field %q: %w", k, err)
+ }
case "_vercel_sync_hash":
if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
@@ -141,6 +146,7 @@ func (c Config) Clone() Config {
Toolcall: c.Toolcall,
Responses: c.Responses,
Embeddings: c.Embeddings,
+ AutoDelete: c.AutoDelete,
VercelSyncHash: c.VercelSyncHash,
VercelSyncTime: c.VercelSyncTime,
AdditionalFields: map[string]any{},
diff --git a/internal/config/config.go b/internal/config/config.go
index b8d59d6..0d541ed 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -12,7 +12,8 @@ type Config struct {
Toolcall ToolcallConfig `json:"toolcall,omitempty"`
Responses ResponsesConfig `json:"responses,omitempty"`
Embeddings EmbeddingsConfig `json:"embeddings,omitempty"`
- VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
+ AutoDelete AutoDeleteConfig `json:"auto_delete"`
+ VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
AdditionalFields map[string]any `json:"-"`
}
@@ -53,3 +54,7 @@ type ResponsesConfig struct {
type EmbeddingsConfig struct {
Provider string `json:"provider,omitempty"`
}
+
+type AutoDeleteConfig struct {
+ Sessions bool `json:"sessions"`
+}
diff --git a/internal/config/store_accessors.go b/internal/config/store_accessors.go
index f0c5938..2817bad 100644
--- a/internal/config/store_accessors.go
+++ b/internal/config/store_accessors.go
@@ -165,3 +165,9 @@ func (s *Store) RuntimeGlobalMaxInflight(defaultSize int) int {
}
return defaultSize
}
+
+func (s *Store) AutoDeleteSessions() bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.cfg.AutoDelete.Sessions
+}
diff --git a/internal/deepseek/client_http_json.go b/internal/deepseek/client_http_json.go
index 6d3599d..1620b2e 100644
--- a/internal/deepseek/client_http_json.go
+++ b/internal/deepseek/client_http_json.go
@@ -62,3 +62,51 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url st
}
return out, resp.StatusCode, nil
}
+
+func (c *Client) getJSON(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, error) {
+ body, status, err := c.getJSONWithStatus(ctx, doer, url, headers)
+ if err != nil {
+ return nil, err
+ }
+ if status == 0 {
+ return nil, errors.New("request failed")
+ }
+ return body, nil
+}
+
+func (c *Client) getJSONWithStatus(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, int, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, 0, err
+ }
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ resp, err := doer.Do(req)
+ if err != nil {
+ config.Logger.Warn("[deepseek] fingerprint GET request failed, fallback to std transport", "url", url, "error", err)
+ req2, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if reqErr != nil {
+ return nil, 0, err
+ }
+ for k, v := range headers {
+ req2.Header.Set(k, v)
+ }
+ resp, err = c.fallback.Do(req2)
+ if err != nil {
+ return nil, 0, err
+ }
+ }
+ defer resp.Body.Close()
+ payloadBytes, err := readResponseBody(resp)
+ if err != nil {
+ return nil, resp.StatusCode, err
+ }
+ out := map[string]any{}
+ if len(payloadBytes) > 0 {
+ if err := json.Unmarshal(payloadBytes, &out); err != nil {
+ config.Logger.Warn("[deepseek] json parse failed", "url", url, "status", resp.StatusCode, "content_encoding", resp.Header.Get("Content-Encoding"), "preview", preview(payloadBytes))
+ }
+ }
+ return out, resp.StatusCode, nil
+}
diff --git a/internal/deepseek/client_session.go b/internal/deepseek/client_session.go
new file mode 100644
index 0000000..2996047
--- /dev/null
+++ b/internal/deepseek/client_session.go
@@ -0,0 +1,436 @@
+package deepseek
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "ds2api/internal/auth"
+ "ds2api/internal/config"
+)
+
+// SessionInfo 会话信息
+type SessionInfo struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ TitleType string `json:"title_type"`
+ Pinned bool `json:"pinned"`
+ UpdatedAt float64 `json:"updated_at"`
+}
+
+// SessionStats 会话统计结果
+type SessionStats struct {
+ AccountID string // 账号标识 (email 或 mobile)
+ TotalCount int // 总会话数量
+ PinnedCount int // 置顶会话数量
+ HasMore bool // 是否还有更多
+ Success bool // 请求是否成功
+ ErrorMessage string // 错误信息
+}
+
+// GetSessionCount 获取单个账号的会话数量
+func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (*SessionStats, error) {
+ if maxAttempts <= 0 {
+ maxAttempts = c.maxRetries
+ }
+
+ stats := &SessionStats{
+ AccountID: a.AccountID,
+ }
+
+ attempts := 0
+ refreshed := false
+
+ for attempts < maxAttempts {
+ headers := c.authHeaders(a.DeepSeekToken)
+
+ // 构建请求 URL
+ reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
+
+ resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
+ if err != nil {
+ config.Logger.Warn("[get_session_count] request error", "error", err, "account", a.AccountID)
+ attempts++
+ continue
+ }
+
+ code := intFrom(resp["code"])
+ if status == http.StatusOK && code == 0 {
+ data, _ := resp["data"].(map[string]any)
+ bizData, _ := data["biz_data"].(map[string]any)
+ chatSessions, _ := bizData["chat_sessions"].([]any)
+ hasMore, _ := bizData["has_more"].(bool)
+
+ stats.TotalCount = len(chatSessions)
+ stats.HasMore = hasMore
+ stats.Success = true
+
+ // 统计置顶会话数量
+ for _, session := range chatSessions {
+ if s, ok := session.(map[string]any); ok {
+ if pinned, ok := s["pinned"].(bool); ok && pinned {
+ stats.PinnedCount++
+ }
+ }
+ }
+
+ return stats, nil
+ }
+
+ msg, _ := resp["msg"].(string)
+ stats.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
+ config.Logger.Warn("[get_session_count] failed", "status", status, "code", code, "msg", msg, "account", a.AccountID)
+
+ if a.UseConfigToken {
+ if isTokenInvalid(status, code, msg) && !refreshed {
+ if c.Auth.RefreshToken(ctx, a) {
+ refreshed = true
+ continue
+ }
+ }
+ if c.Auth.SwitchAccount(ctx, a) {
+ refreshed = false
+ attempts++
+ continue
+ }
+ }
+ attempts++
+ }
+
+ stats.Success = false
+ stats.ErrorMessage = "get session count failed after retries"
+ return stats, errors.New(stats.ErrorMessage)
+}
+
+// GetSessionCountForToken 直接使用 token 获取会话数量(直通模式)
+func (c *Client) GetSessionCountForToken(ctx context.Context, token string) (*SessionStats, error) {
+ headers := c.authHeaders(token)
+ reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
+
+ resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
+ if err != nil {
+ return nil, err
+ }
+
+ code := intFrom(resp["code"])
+ if status != http.StatusOK || code != 0 {
+ msg, _ := resp["msg"].(string)
+ return nil, fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
+ }
+
+ data, _ := resp["data"].(map[string]any)
+ bizData, _ := data["biz_data"].(map[string]any)
+ chatSessions, _ := bizData["chat_sessions"].([]any)
+ hasMore, _ := bizData["has_more"].(bool)
+
+ stats := &SessionStats{
+ TotalCount: len(chatSessions),
+ HasMore: hasMore,
+ Success: true,
+ }
+
+ // 统计置顶会话数量
+ for _, session := range chatSessions {
+ if s, ok := session.(map[string]any); ok {
+ if pinned, ok := s["pinned"].(bool); ok && pinned {
+ stats.PinnedCount++
+ }
+ }
+ }
+
+ return stats, nil
+}
+
+// GetSessionCountAll 获取所有账号的会话数量统计
+func (c *Client) GetSessionCountAll(ctx context.Context) []*SessionStats {
+ accounts := c.Store.Accounts()
+ results := make([]*SessionStats, 0, len(accounts))
+
+ for _, acc := range accounts {
+ token := acc.Token
+ accountID := acc.Email
+ if accountID == "" {
+ accountID = acc.Mobile
+ }
+
+ // 如果没有 token,尝试登录获取
+ if token == "" {
+ var err error
+ token, err = c.Login(ctx, acc)
+ if err != nil {
+ results = append(results, &SessionStats{
+ AccountID: accountID,
+ Success: false,
+ ErrorMessage: fmt.Sprintf("login failed: %v", err),
+ })
+ continue
+ }
+ }
+
+ stats, err := c.GetSessionCountForToken(ctx, token)
+ if err != nil {
+ results = append(results, &SessionStats{
+ AccountID: accountID,
+ Success: false,
+ ErrorMessage: err.Error(),
+ })
+ continue
+ }
+
+ stats.AccountID = accountID
+ results = append(results, stats)
+ }
+
+ return results
+}
+
+// FetchSessionPage 获取会话列表(支持分页)
+func (c *Client) FetchSessionPage(ctx context.Context, a *auth.RequestAuth, cursor string) ([]SessionInfo, bool, error) {
+ headers := c.authHeaders(a.DeepSeekToken)
+
+ // 构建请求 URL
+ params := url.Values{}
+ params.Set("lte_cursor.pinned", "false")
+ if cursor != "" {
+ params.Set("lte_cursor", cursor)
+ }
+ reqURL := DeepSeekFetchSessionURL + "?" + params.Encode()
+
+ resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
+ if err != nil {
+ return nil, false, err
+ }
+
+ code := intFrom(resp["code"])
+ if status != http.StatusOK || code != 0 {
+ msg, _ := resp["msg"].(string)
+ return nil, false, fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
+ }
+
+ data, _ := resp["data"].(map[string]any)
+ bizData, _ := data["biz_data"].(map[string]any)
+ chatSessions, _ := bizData["chat_sessions"].([]any)
+ hasMore, _ := bizData["has_more"].(bool)
+
+ sessions := make([]SessionInfo, 0, len(chatSessions))
+ for _, s := range chatSessions {
+ if m, ok := s.(map[string]any); ok {
+ session := SessionInfo{
+ ID: stringFromMap(m, "id"),
+ Title: stringFromMap(m, "title"),
+ TitleType: stringFromMap(m, "title_type"),
+ Pinned: boolFromMap(m, "pinned"),
+ UpdatedAt: floatFromMap(m, "updated_at"),
+ }
+ sessions = append(sessions, session)
+ }
+ }
+
+ return sessions, hasMore, nil
+}
+
+// 辅助函数
+func stringFromMap(m map[string]any, key string) string {
+ if v, ok := m[key].(string); ok {
+ return v
+ }
+ return ""
+}
+
+func boolFromMap(m map[string]any, key string) bool {
+ if v, ok := m[key].(bool); ok {
+ return v
+ }
+ return false
+}
+
+func floatFromMap(m map[string]any, key string) float64 {
+ if v, ok := m[key].(float64); ok {
+ return v
+ }
+ return 0
+}
+
+// DeleteSessionResult 删除会话结果
+type DeleteSessionResult struct {
+ SessionID string // 会话 ID
+ Success bool // 是否成功
+ ErrorMessage string // 错误信息
+}
+
+// DeleteSession 删除单个会话
+func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, sessionID string, maxAttempts int) (*DeleteSessionResult, error) {
+ if maxAttempts <= 0 {
+ maxAttempts = c.maxRetries
+ }
+
+ result := &DeleteSessionResult{
+ SessionID: sessionID,
+ }
+
+ if sessionID == "" {
+ result.ErrorMessage = "session_id is required"
+ return result, errors.New(result.ErrorMessage)
+ }
+
+ attempts := 0
+ refreshed := false
+
+ for attempts < maxAttempts {
+ headers := c.authHeaders(a.DeepSeekToken)
+
+ payload := map[string]any{
+ "chat_session_id": sessionID,
+ }
+
+ resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
+ if err != nil {
+ config.Logger.Warn("[delete_session] request error", "error", err, "session_id", sessionID)
+ attempts++
+ continue
+ }
+
+ code := intFrom(resp["code"])
+ if status == http.StatusOK && code == 0 {
+ result.Success = true
+ return result, nil
+ }
+
+ msg, _ := resp["msg"].(string)
+ result.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
+ config.Logger.Warn("[delete_session] failed", "status", status, "code", code, "msg", msg, "session_id", sessionID)
+
+ if a.UseConfigToken {
+ if isTokenInvalid(status, code, msg) && !refreshed {
+ if c.Auth.RefreshToken(ctx, a) {
+ refreshed = true
+ continue
+ }
+ }
+ if c.Auth.SwitchAccount(ctx, a) {
+ refreshed = false
+ attempts++
+ continue
+ }
+ }
+ attempts++
+ }
+
+ result.Success = false
+ result.ErrorMessage = "delete session failed after retries"
+ return result, errors.New(result.ErrorMessage)
+}
+
+// DeleteSessionForToken 直接使用 token 删除会话(直通模式)
+func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*DeleteSessionResult, error) {
+ result := &DeleteSessionResult{
+ SessionID: sessionID,
+ }
+
+ if sessionID == "" {
+ result.ErrorMessage = "session_id is required"
+ return result, errors.New(result.ErrorMessage)
+ }
+
+ headers := c.authHeaders(token)
+ payload := map[string]any{
+ "chat_session_id": sessionID,
+ }
+
+ resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
+ if err != nil {
+ result.ErrorMessage = err.Error()
+ return result, err
+ }
+
+ code := intFrom(resp["code"])
+ if status != http.StatusOK || code != 0 {
+ msg, _ := resp["msg"].(string)
+ result.ErrorMessage = fmt.Sprintf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
+ return result, fmt.Errorf(result.ErrorMessage)
+ }
+
+ result.Success = true
+ return result, nil
+}
+
+// DeleteAllSessions 删除所有会话(谨慎使用)
+func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) (int, error) {
+ deleted := 0
+ cursor := ""
+
+ for {
+ sessions, hasMore, err := c.FetchSessionPage(ctx, a, cursor)
+ if err != nil {
+ return deleted, err
+ }
+
+ for _, session := range sessions {
+ _, err := c.DeleteSession(ctx, a, session.ID, 1)
+ if err == nil {
+ deleted++
+ }
+ }
+
+ if !hasMore || len(sessions) == 0 {
+ break
+ }
+ }
+
+ return deleted, nil
+}
+
+// DeleteAllSessionsForToken 直接使用 token 删除所有会话(直通模式)
+func (c *Client) DeleteAllSessionsForToken(ctx context.Context, token string) (int, error) {
+ deleted := 0
+ cursor := ""
+
+ for {
+ // 获取会话列表
+ headers := c.authHeaders(token)
+ params := url.Values{}
+ params.Set("lte_cursor.pinned", "false")
+ if cursor != "" {
+ params.Set("lte_cursor", cursor)
+ }
+ reqURL := DeepSeekFetchSessionURL + "?" + params.Encode()
+
+ resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
+ if err != nil {
+ return deleted, err
+ }
+
+ code := intFrom(resp["code"])
+ if status != http.StatusOK || code != 0 {
+ msg, _ := resp["msg"].(string)
+ return deleted, fmt.Errorf("fetch sessions failed: status=%d, code=%d, msg=%s", status, code, msg)
+ }
+
+ data, _ := resp["data"].(map[string]any)
+ bizData, _ := data["biz_data"].(map[string]any)
+ chatSessions, _ := bizData["chat_sessions"].([]any)
+ hasMore, _ := bizData["has_more"].(bool)
+
+ // 删除每个会话
+ for _, s := range chatSessions {
+ if m, ok := s.(map[string]any); ok {
+ sessionID := stringFromMap(m, "id")
+ if sessionID == "" {
+ continue
+ }
+ _, err := c.DeleteSessionForToken(ctx, token, sessionID)
+ if err == nil {
+ deleted++
+ }
+ }
+ }
+
+ if !hasMore || len(chatSessions) == 0 {
+ break
+ }
+ }
+
+ return deleted, nil
+}
diff --git a/internal/deepseek/constants.go b/internal/deepseek/constants.go
index 042ec29..35b5c29 100644
--- a/internal/deepseek/constants.go
+++ b/internal/deepseek/constants.go
@@ -11,6 +11,8 @@ const (
DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create"
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion"
+ DeepSeekFetchSessionURL = "https://chat.deepseek.com/api/v0/chat_session/fetch_page"
+ DeepSeekDeleteSessionURL = "https://chat.deepseek.com/api/v0/chat_session/delete"
)
var defaultBaseHeaders = map[string]string{
diff --git a/webui/src/features/account/AccountManagerContainer.jsx b/webui/src/features/account/AccountManagerContainer.jsx
index 9da3616..8300df7 100644
--- a/webui/src/features/account/AccountManagerContainer.jsx
+++ b/webui/src/features/account/AccountManagerContainer.jsx
@@ -1,121 +1,127 @@
-import { useI18n } from '../../i18n'
-import { useAccountsData } from './useAccountsData'
-import { useAccountActions } from './useAccountActions'
-import QueueCards from './QueueCards'
-import ApiKeysPanel from './ApiKeysPanel'
-import AccountsTable from './AccountsTable'
-import AddKeyModal from './AddKeyModal'
-import AddAccountModal from './AddAccountModal'
-
-export default function AccountManagerContainer({ config, onRefresh, onMessage, authFetch }) {
- const { t } = useI18n()
- const apiFetch = authFetch || fetch
-
- const {
- queueStatus,
- keysExpanded,
- setKeysExpanded,
- accounts,
- page,
- pageSize,
- totalPages,
- totalAccounts,
- loadingAccounts,
- fetchAccounts,
- changePageSize,
- resolveAccountIdentifier,
- searchQuery,
- handleSearchChange,
- } = useAccountsData({ apiFetch })
-
- const {
- showAddKey,
- setShowAddKey,
- showAddAccount,
- setShowAddAccount,
- newKey,
- setNewKey,
- copiedKey,
- setCopiedKey,
- newAccount,
- setNewAccount,
- loading,
- testing,
- testingAll,
- batchProgress,
- addKey,
- deleteKey,
- addAccount,
- deleteAccount,
- testAccount,
- testAllAccounts,
- } = useAccountActions({
- apiFetch,
- t,
- onMessage,
- onRefresh,
- config,
- fetchAccounts,
- resolveAccountIdentifier,
- })
-
- return (
-
-
-
-
-
-
setShowAddAccount(true)}
- onTestAccount={testAccount}
- onDeleteAccount={deleteAccount}
- onPrevPage={() => fetchAccounts(page - 1)}
- onNextPage={() => fetchAccounts(page + 1)}
- onPageSizeChange={changePageSize}
- searchQuery={searchQuery}
- onSearchChange={handleSearchChange}
- />
-
- setShowAddKey(false)}
- onAdd={addKey}
- />
-
- setShowAddAccount(false)}
- onAdd={addAccount}
- />
-
- )
-}
+import { useI18n } from '../../i18n'
+import { useAccountsData } from './useAccountsData'
+import { useAccountActions } from './useAccountActions'
+import QueueCards from './QueueCards'
+import ApiKeysPanel from './ApiKeysPanel'
+import AccountsTable from './AccountsTable'
+import AddKeyModal from './AddKeyModal'
+import AddAccountModal from './AddAccountModal'
+
+export default function AccountManagerContainer({ config, onRefresh, onMessage, authFetch }) {
+ const { t } = useI18n()
+ const apiFetch = authFetch || fetch
+
+ const {
+ queueStatus,
+ keysExpanded,
+ setKeysExpanded,
+ accounts,
+ page,
+ pageSize,
+ totalPages,
+ totalAccounts,
+ loadingAccounts,
+ fetchAccounts,
+ changePageSize,
+ resolveAccountIdentifier,
+ searchQuery,
+ handleSearchChange,
+ } = useAccountsData({ apiFetch })
+
+ const {
+ showAddKey,
+ setShowAddKey,
+ showAddAccount,
+ setShowAddAccount,
+ newKey,
+ setNewKey,
+ copiedKey,
+ setCopiedKey,
+ newAccount,
+ setNewAccount,
+ loading,
+ testing,
+ testingAll,
+ batchProgress,
+ sessionCounts,
+ deletingSessions,
+ addKey,
+ deleteKey,
+ addAccount,
+ deleteAccount,
+ testAccount,
+ testAllAccounts,
+ deleteAllSessions,
+ } = useAccountActions({
+ apiFetch,
+ t,
+ onMessage,
+ onRefresh,
+ config,
+ fetchAccounts,
+ resolveAccountIdentifier,
+ })
+
+ return (
+
+
+
+
+
+
setShowAddAccount(true)}
+ onTestAccount={testAccount}
+ onDeleteAccount={deleteAccount}
+ onDeleteAllSessions={deleteAllSessions}
+ onPrevPage={() => fetchAccounts(page - 1)}
+ onNextPage={() => fetchAccounts(page + 1)}
+ onPageSizeChange={changePageSize}
+ searchQuery={searchQuery}
+ onSearchChange={handleSearchChange}
+ />
+
+ setShowAddKey(false)}
+ onAdd={addKey}
+ />
+
+ setShowAddAccount(false)}
+ onAdd={addAccount}
+ />
+
+ )
+}
diff --git a/webui/src/features/account/AccountsTable.jsx b/webui/src/features/account/AccountsTable.jsx
index d7be383..896550c 100644
--- a/webui/src/features/account/AccountsTable.jsx
+++ b/webui/src/features/account/AccountsTable.jsx
@@ -1,191 +1,213 @@
-import { useState } from 'react'
-import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2 } from 'lucide-react'
-import clsx from 'clsx'
-
-export default function AccountsTable({
- t,
- accounts,
- loadingAccounts,
- testing,
- testingAll,
- batchProgress,
- totalAccounts,
- page,
- pageSize,
- totalPages,
- resolveAccountIdentifier,
- onTestAll,
- onShowAddAccount,
- onTestAccount,
- onDeleteAccount,
- onPrevPage,
- onNextPage,
- onPageSizeChange,
- searchQuery,
- onSearchChange,
-}) {
- const [copiedId, setCopiedId] = useState(null)
-
- const copyId = (id) => {
- navigator.clipboard.writeText(id).then(() => {
- setCopiedId(id)
- setTimeout(() => setCopiedId(null), 1500)
- })
- }
- return (
-
-
-
-
{t('accountManager.accountsTitle')}
-
{t('accountManager.accountsDesc')}
-
-
-
onSearchChange(e.target.value)}
- placeholder={t('accountManager.searchPlaceholder')}
- className="px-3 py-1.5 text-sm bg-muted border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground"
- />
-
-
-
-
-
- {testingAll && batchProgress.total > 0 && (
-
-
- {t('accountManager.testingAllAccounts')}
- {batchProgress.current} / {batchProgress.total}
-
-
- {batchProgress.results.length > 0 && (
-
- {batchProgress.results.map((r, i) => (
-
- {r.success ? '✓' : '✗'} {r.id}
-
- ))}
-
- )}
-
- )}
-
-
- {loadingAccounts ? (
-
{t('actions.loading')}
- ) : accounts.length > 0 ? (
- accounts.map((acc, i) => {
- const id = resolveAccountIdentifier(acc)
- return (
-
-
-
-
-
copyId(id)}
- >
- {id || '-'}
- {copiedId === id
- ?
- :
- }
-
-
- {acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}
- {acc.token_preview && (
-
- {acc.token_preview}
-
- )}
-
-
-
-
-
-
-
-
- )
- })
- ) : (
-
{searchQuery ? t('accountManager.searchNoResults') : t('accountManager.noAccounts')}
- )}
-
-
- {totalPages > 1 && (
-
-
-
- {t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })}
-
-
-
-
-
- {page} / {totalPages}
-
-
-
- )}
-
- )
-}
+import { useState } from 'react'
+import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2, FolderX } from 'lucide-react'
+import clsx from 'clsx'
+
+export default function AccountsTable({
+ t,
+ accounts,
+ loadingAccounts,
+ testing,
+ testingAll,
+ batchProgress,
+ sessionCounts,
+ deletingSessions,
+ totalAccounts,
+ page,
+ pageSize,
+ totalPages,
+ resolveAccountIdentifier,
+ onTestAll,
+ onShowAddAccount,
+ onTestAccount,
+ onDeleteAccount,
+ onDeleteAllSessions,
+ onPrevPage,
+ onNextPage,
+ onPageSizeChange,
+ searchQuery,
+ onSearchChange,
+}) {
+ const [copiedId, setCopiedId] = useState(null)
+
+ const copyId = (id) => {
+ navigator.clipboard.writeText(id).then(() => {
+ setCopiedId(id)
+ setTimeout(() => setCopiedId(null), 1500)
+ })
+ }
+ return (
+
+
+
+
{t('accountManager.accountsTitle')}
+
{t('accountManager.accountsDesc')}
+
+
+
onSearchChange(e.target.value)}
+ placeholder={t('accountManager.searchPlaceholder')}
+ className="px-3 py-1.5 text-sm bg-muted border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground"
+ />
+
+
+
+
+
+ {testingAll && batchProgress.total > 0 && (
+
+
+ {t('accountManager.testingAllAccounts')}
+ {batchProgress.current} / {batchProgress.total}
+
+
+ {batchProgress.results.length > 0 && (
+
+ {batchProgress.results.map((r, i) => (
+
+ {r.success ? '✓' : '✗'} {r.id}
+
+ ))}
+
+ )}
+
+ )}
+
+
+ {loadingAccounts ? (
+
{t('actions.loading')}
+ ) : accounts.length > 0 ? (
+ accounts.map((acc, i) => {
+ const id = resolveAccountIdentifier(acc)
+ return (
+
+
+
+
+
copyId(id)}
+ >
+ {id || '-'}
+ {copiedId === id
+ ?
+ :
+ }
+
+
+ {acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}
+ {acc.token_preview && (
+
+ {acc.token_preview}
+
+ )}
+ {sessionCounts && sessionCounts[id] !== undefined && (
+
+ {t('accountManager.sessionCount', { count: sessionCounts[id] })}
+
+ )}
+ {sessionCounts && sessionCounts[id] !== undefined && sessionCounts[id] > 0 && (
+
+ )}
+
+
+
+
+
+
+
+
+ )
+ })
+ ) : (
+
{searchQuery ? t('accountManager.searchNoResults') : t('accountManager.noAccounts')}
+ )}
+
+
+ {totalPages > 1 && (
+
+
+
+ {t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })}
+
+
+
+
+
+ {page} / {totalPages}
+
+
+
+ )}
+
+ )
+}
diff --git a/webui/src/features/account/useAccountActions.js b/webui/src/features/account/useAccountActions.js
index 717d5bf..a14df92 100644
--- a/webui/src/features/account/useAccountActions.js
+++ b/webui/src/features/account/useAccountActions.js
@@ -10,6 +10,8 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
const [testing, setTesting] = useState({})
const [testingAll, setTestingAll] = useState(false)
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] })
+ const [sessionCounts, setSessionCounts] = useState({})
+ const [deletingSessions, setDeletingSessions] = useState({})
const addKey = async () => {
if (!newKey.trim()) return
@@ -115,6 +117,12 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
body: JSON.stringify({ identifier: accountID }),
})
const data = await res.json()
+
+ // 更新会话数
+ if (data.session_count !== undefined) {
+ setSessionCounts(prev => ({ ...prev, [accountID]: data.session_count }))
+ }
+
const statusMessage = data.success
? t('apiTester.testSuccess', { account: accountID, time: data.response_time })
: `${accountID}: ${data.message}`
@@ -170,6 +178,41 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
setTestingAll(false)
}
+ const deleteAllSessions = async (identifier) => {
+ const accountID = String(identifier || '').trim()
+ if (!accountID) {
+ onMessage('error', t('accountManager.invalidIdentifier'))
+ return
+ }
+ if (!confirm(t('accountManager.deleteAllSessionsConfirm'))) return
+
+ setDeletingSessions(prev => ({ ...prev, [accountID]: true }))
+ try {
+ const res = await apiFetch('/admin/accounts/sessions/delete-all', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ identifier: accountID }),
+ })
+ const data = await res.json()
+
+ if (data.success) {
+ onMessage('success', t('accountManager.deleteAllSessionsSuccess', { count: data.deleted }))
+ // 清除会话数显示
+ setSessionCounts(prev => {
+ const newCounts = { ...prev }
+ delete newCounts[accountID]
+ return newCounts
+ })
+ } else {
+ onMessage('error', data.message || t('messages.requestFailed'))
+ }
+ } catch (e) {
+ onMessage('error', t('messages.networkError'))
+ } finally {
+ setDeletingSessions(prev => ({ ...prev, [accountID]: false }))
+ }
+ }
+
return {
showAddKey,
setShowAddKey,
@@ -185,11 +228,14 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
testing,
testingAll,
batchProgress,
+ sessionCounts,
+ deletingSessions,
addKey,
deleteKey,
addAccount,
deleteAccount,
testAccount,
testAllAccounts,
+ deleteAllSessions,
}
}
diff --git a/webui/src/features/settings/AutoDeleteSection.jsx b/webui/src/features/settings/AutoDeleteSection.jsx
new file mode 100644
index 0000000..81c091f
--- /dev/null
+++ b/webui/src/features/settings/AutoDeleteSection.jsx
@@ -0,0 +1,39 @@
+import { Trash2 } from 'lucide-react'
+
+export default function AutoDeleteSection({ t, form, setForm }) {
+ return (
+
+
+
+
{t('settings.autoDeleteTitle')}
+
+
{t('settings.autoDeleteDesc')}
+
+
+
+
+ {form.auto_delete?.sessions && (
+
+ {t('settings.autoDeleteWarning')}
+
+ )}
+
+ )
+}
diff --git a/webui/src/features/settings/SettingsContainer.jsx b/webui/src/features/settings/SettingsContainer.jsx
index 4a7a50e..86ea4a5 100644
--- a/webui/src/features/settings/SettingsContainer.jsx
+++ b/webui/src/features/settings/SettingsContainer.jsx
@@ -5,6 +5,7 @@ import { useSettingsForm } from './useSettingsForm'
import SecuritySection from './SecuritySection'
import RuntimeSection from './RuntimeSection'
import BehaviorSection from './BehaviorSection'
+import AutoDeleteSection from './AutoDeleteSection'
import ModelSection from './ModelSection'
import BackupSection from './BackupSection'
@@ -91,6 +92,8 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
+
+
Date: Mon, 16 Mar 2026 01:44:21 +0800
Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BC=9A=E8=AF=9D?=
=?UTF-8?q?=E7=AE=A1=E7=90=86=E7=9B=B8=E5=85=B3=E9=97=AE=E9=A2=98=E5=B9=B6?=
=?UTF-8?q?=E6=8B=86=E5=88=86=E6=96=87=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1. 修复无限循环问题
- DeleteAllSessions/DeleteAllSessionsForToken 添加无进度检测
- 连续 3 轮删除失败则退出循环
- DeleteAllSessionsForToken 添加 cursor 推进逻辑
2. 修复字段语义不准确
- TotalCount 重命名为 FirstPageCount
- 明确该值仅统计第一页,多页账户需关注 HasMore
3. 修复 defer 执行顺序问题
- 合并两个 defer,确保先删除会话再释放账号
- 使用同步删除避免并发截断风险
4. 文件拆分
- 新建 client_session_delete.go 处理会话删除
- client_session.go 专注于会话查询
---
internal/adapter/openai/handler_chat.go | 17 +-
internal/admin/handler_accounts_testing.go | 2 +-
internal/deepseek/client_session.go | 202 +----------------
internal/deepseek/client_session_delete.go | 238 +++++++++++++++++++++
4 files changed, 259 insertions(+), 200 deletions(-)
create mode 100644 internal/deepseek/client_session_delete.go
diff --git a/internal/adapter/openai/handler_chat.go b/internal/adapter/openai/handler_chat.go
index 8ff1c90..a06e126 100644
--- a/internal/adapter/openai/handler_chat.go
+++ b/internal/adapter/openai/handler_chat.go
@@ -35,19 +35,22 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, status, detail)
return
}
- defer h.Auth.Release(a)
-
- // 自动删除会话功能
- if h.Store.AutoDeleteSessions() && a.DeepSeekToken != "" {
- defer func() {
+ defer func() {
+ // 自动删除会话(同步)
+ // 必须在 Release 之前同步删除,否则:
+ // 1. 异步删除时账号已被 Release
+ // 2. 新请求可能获取到同一账号并开始使用
+ // 3. 异步删除仍在进行,会截断新请求正在使用的会话
+ if h.Store.AutoDeleteSessions() && a.DeepSeekToken != "" {
deleted, err := h.DS.DeleteAllSessionsForToken(context.Background(), a.DeepSeekToken)
if err != nil {
config.Logger.Warn("[auto_delete_sessions] failed", "account", a.AccountID, "error", err)
} else {
config.Logger.Debug("[auto_delete_sessions] deleted", "account", a.AccountID, "count", deleted)
}
- }()
- }
+ }
+ h.Auth.Release(a)
+ }()
r = r.WithContext(auth.WithAuth(r.Context(), a))
diff --git a/internal/admin/handler_accounts_testing.go b/internal/admin/handler_accounts_testing.go
index d54c4cb..33f43c7 100644
--- a/internal/admin/handler_accounts_testing.go
+++ b/internal/admin/handler_accounts_testing.go
@@ -128,7 +128,7 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
// 获取会话数量
sessionStats, sessionErr := h.DS.GetSessionCountForToken(ctx, token)
if sessionErr == nil && sessionStats != nil {
- result["session_count"] = sessionStats.TotalCount
+ result["session_count"] = sessionStats.FirstPageCount
}
if strings.TrimSpace(message) == "" {
diff --git a/internal/deepseek/client_session.go b/internal/deepseek/client_session.go
index 2996047..fa422b7 100644
--- a/internal/deepseek/client_session.go
+++ b/internal/deepseek/client_session.go
@@ -22,12 +22,12 @@ type SessionInfo struct {
// SessionStats 会话统计结果
type SessionStats struct {
- AccountID string // 账号标识 (email 或 mobile)
- TotalCount int // 总会话数量
- PinnedCount int // 置顶会话数量
- HasMore bool // 是否还有更多
- Success bool // 请求是否成功
- ErrorMessage string // 错误信息
+ AccountID string // 账号标识 (email 或 mobile)
+ FirstPageCount int // 第一页会话数量(当 HasMore 为 true 时,真实总数可能更大)
+ PinnedCount int // 置顶会话数量
+ HasMore bool // 是否还有更多页
+ Success bool // 请求是否成功
+ ErrorMessage string // 错误信息
}
// GetSessionCount 获取单个账号的会话数量
@@ -63,7 +63,7 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt
chatSessions, _ := bizData["chat_sessions"].([]any)
hasMore, _ := bizData["has_more"].(bool)
- stats.TotalCount = len(chatSessions)
+ stats.FirstPageCount = len(chatSessions)
stats.HasMore = hasMore
stats.Success = true
@@ -126,9 +126,9 @@ func (c *Client) GetSessionCountForToken(ctx context.Context, token string) (*Se
hasMore, _ := bizData["has_more"].(bool)
stats := &SessionStats{
- TotalCount: len(chatSessions),
- HasMore: hasMore,
- Success: true,
+ FirstPageCount: len(chatSessions),
+ HasMore: hasMore,
+ Success: true,
}
// 统计置顶会话数量
@@ -252,185 +252,3 @@ func floatFromMap(m map[string]any, key string) float64 {
}
return 0
}
-
-// DeleteSessionResult 删除会话结果
-type DeleteSessionResult struct {
- SessionID string // 会话 ID
- Success bool // 是否成功
- ErrorMessage string // 错误信息
-}
-
-// DeleteSession 删除单个会话
-func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, sessionID string, maxAttempts int) (*DeleteSessionResult, error) {
- if maxAttempts <= 0 {
- maxAttempts = c.maxRetries
- }
-
- result := &DeleteSessionResult{
- SessionID: sessionID,
- }
-
- if sessionID == "" {
- result.ErrorMessage = "session_id is required"
- return result, errors.New(result.ErrorMessage)
- }
-
- attempts := 0
- refreshed := false
-
- for attempts < maxAttempts {
- headers := c.authHeaders(a.DeepSeekToken)
-
- payload := map[string]any{
- "chat_session_id": sessionID,
- }
-
- resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
- if err != nil {
- config.Logger.Warn("[delete_session] request error", "error", err, "session_id", sessionID)
- attempts++
- continue
- }
-
- code := intFrom(resp["code"])
- if status == http.StatusOK && code == 0 {
- result.Success = true
- return result, nil
- }
-
- msg, _ := resp["msg"].(string)
- result.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
- config.Logger.Warn("[delete_session] failed", "status", status, "code", code, "msg", msg, "session_id", sessionID)
-
- if a.UseConfigToken {
- if isTokenInvalid(status, code, msg) && !refreshed {
- if c.Auth.RefreshToken(ctx, a) {
- refreshed = true
- continue
- }
- }
- if c.Auth.SwitchAccount(ctx, a) {
- refreshed = false
- attempts++
- continue
- }
- }
- attempts++
- }
-
- result.Success = false
- result.ErrorMessage = "delete session failed after retries"
- return result, errors.New(result.ErrorMessage)
-}
-
-// DeleteSessionForToken 直接使用 token 删除会话(直通模式)
-func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*DeleteSessionResult, error) {
- result := &DeleteSessionResult{
- SessionID: sessionID,
- }
-
- if sessionID == "" {
- result.ErrorMessage = "session_id is required"
- return result, errors.New(result.ErrorMessage)
- }
-
- headers := c.authHeaders(token)
- payload := map[string]any{
- "chat_session_id": sessionID,
- }
-
- resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
- if err != nil {
- result.ErrorMessage = err.Error()
- return result, err
- }
-
- code := intFrom(resp["code"])
- if status != http.StatusOK || code != 0 {
- msg, _ := resp["msg"].(string)
- result.ErrorMessage = fmt.Sprintf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
- return result, fmt.Errorf(result.ErrorMessage)
- }
-
- result.Success = true
- return result, nil
-}
-
-// DeleteAllSessions 删除所有会话(谨慎使用)
-func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) (int, error) {
- deleted := 0
- cursor := ""
-
- for {
- sessions, hasMore, err := c.FetchSessionPage(ctx, a, cursor)
- if err != nil {
- return deleted, err
- }
-
- for _, session := range sessions {
- _, err := c.DeleteSession(ctx, a, session.ID, 1)
- if err == nil {
- deleted++
- }
- }
-
- if !hasMore || len(sessions) == 0 {
- break
- }
- }
-
- return deleted, nil
-}
-
-// DeleteAllSessionsForToken 直接使用 token 删除所有会话(直通模式)
-func (c *Client) DeleteAllSessionsForToken(ctx context.Context, token string) (int, error) {
- deleted := 0
- cursor := ""
-
- for {
- // 获取会话列表
- headers := c.authHeaders(token)
- params := url.Values{}
- params.Set("lte_cursor.pinned", "false")
- if cursor != "" {
- params.Set("lte_cursor", cursor)
- }
- reqURL := DeepSeekFetchSessionURL + "?" + params.Encode()
-
- resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
- if err != nil {
- return deleted, err
- }
-
- code := intFrom(resp["code"])
- if status != http.StatusOK || code != 0 {
- msg, _ := resp["msg"].(string)
- return deleted, fmt.Errorf("fetch sessions failed: status=%d, code=%d, msg=%s", status, code, msg)
- }
-
- data, _ := resp["data"].(map[string]any)
- bizData, _ := data["biz_data"].(map[string]any)
- chatSessions, _ := bizData["chat_sessions"].([]any)
- hasMore, _ := bizData["has_more"].(bool)
-
- // 删除每个会话
- for _, s := range chatSessions {
- if m, ok := s.(map[string]any); ok {
- sessionID := stringFromMap(m, "id")
- if sessionID == "" {
- continue
- }
- _, err := c.DeleteSessionForToken(ctx, token, sessionID)
- if err == nil {
- deleted++
- }
- }
- }
-
- if !hasMore || len(chatSessions) == 0 {
- break
- }
- }
-
- return deleted, nil
-}
diff --git a/internal/deepseek/client_session_delete.go b/internal/deepseek/client_session_delete.go
new file mode 100644
index 0000000..2f49fc5
--- /dev/null
+++ b/internal/deepseek/client_session_delete.go
@@ -0,0 +1,238 @@
+package deepseek
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "ds2api/internal/auth"
+ "ds2api/internal/config"
+)
+
+// DeleteSessionResult 删除会话结果
+type DeleteSessionResult struct {
+ SessionID string // 会话 ID
+ Success bool // 是否成功
+ ErrorMessage string // 错误信息
+}
+
+// DeleteSession 删除单个会话
+func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, sessionID string, maxAttempts int) (*DeleteSessionResult, error) {
+ if maxAttempts <= 0 {
+ maxAttempts = c.maxRetries
+ }
+
+ result := &DeleteSessionResult{
+ SessionID: sessionID,
+ }
+
+ if sessionID == "" {
+ result.ErrorMessage = "session_id is required"
+ return result, errors.New(result.ErrorMessage)
+ }
+
+ attempts := 0
+ refreshed := false
+
+ for attempts < maxAttempts {
+ headers := c.authHeaders(a.DeepSeekToken)
+
+ payload := map[string]any{
+ "chat_session_id": sessionID,
+ }
+
+ resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
+ if err != nil {
+ config.Logger.Warn("[delete_session] request error", "error", err, "session_id", sessionID)
+ attempts++
+ continue
+ }
+
+ code := intFrom(resp["code"])
+ if status == http.StatusOK && code == 0 {
+ result.Success = true
+ return result, nil
+ }
+
+ msg, _ := resp["msg"].(string)
+ result.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
+ config.Logger.Warn("[delete_session] failed", "status", status, "code", code, "msg", msg, "session_id", sessionID)
+
+ if a.UseConfigToken {
+ if isTokenInvalid(status, code, msg) && !refreshed {
+ if c.Auth.RefreshToken(ctx, a) {
+ refreshed = true
+ continue
+ }
+ }
+ if c.Auth.SwitchAccount(ctx, a) {
+ refreshed = false
+ attempts++
+ continue
+ }
+ }
+ attempts++
+ }
+
+ result.Success = false
+ result.ErrorMessage = "delete session failed after retries"
+ return result, errors.New(result.ErrorMessage)
+}
+
+// DeleteSessionForToken 直接使用 token 删除会话(直通模式)
+func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*DeleteSessionResult, error) {
+ result := &DeleteSessionResult{
+ SessionID: sessionID,
+ }
+
+ if sessionID == "" {
+ result.ErrorMessage = "session_id is required"
+ return result, errors.New(result.ErrorMessage)
+ }
+
+ headers := c.authHeaders(token)
+ payload := map[string]any{
+ "chat_session_id": sessionID,
+ }
+
+ resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
+ if err != nil {
+ result.ErrorMessage = err.Error()
+ return result, err
+ }
+
+ code := intFrom(resp["code"])
+ if status != http.StatusOK || code != 0 {
+ msg, _ := resp["msg"].(string)
+ result.ErrorMessage = fmt.Sprintf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
+ return result, fmt.Errorf(result.ErrorMessage)
+ }
+
+ result.Success = true
+ return result, nil
+}
+
+// DeleteAllSessions 删除所有会话(谨慎使用)
+func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) (int, error) {
+ const maxNoProgress = 3 // 最大无进度次数
+
+ deleted := 0
+ cursor := ""
+ noProgressCount := 0
+
+ for {
+ sessions, hasMore, err := c.FetchSessionPage(ctx, a, cursor)
+ if err != nil {
+ return deleted, err
+ }
+
+ deletedThisRound := 0
+ for _, session := range sessions {
+ _, err := c.DeleteSession(ctx, a, session.ID, 1)
+ if err == nil {
+ deleted++
+ deletedThisRound++
+ }
+ }
+
+ // 无进度检测:如果连续多轮没有成功删除任何会话,退出循环
+ if deletedThisRound == 0 {
+ noProgressCount++
+ if noProgressCount >= maxNoProgress {
+ config.Logger.Warn("[delete_all_sessions] exiting due to no progress", "deleted", deleted)
+ break
+ }
+ } else {
+ noProgressCount = 0
+ }
+
+ if !hasMore || len(sessions) == 0 {
+ break
+ }
+ }
+
+ return deleted, nil
+}
+
+// DeleteAllSessionsForToken 直接使用 token 删除所有会话(直通模式)
+func (c *Client) DeleteAllSessionsForToken(ctx context.Context, token string) (int, error) {
+ const maxNoProgress = 3 // 最大无进度次数
+
+ deleted := 0
+ cursor := ""
+ noProgressCount := 0
+
+ for {
+ // 获取会话列表
+ headers := c.authHeaders(token)
+ params := url.Values{}
+ params.Set("lte_cursor.pinned", "false")
+ if cursor != "" {
+ params.Set("lte_cursor", cursor)
+ }
+ reqURL := DeepSeekFetchSessionURL + "?" + params.Encode()
+
+ resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
+ if err != nil {
+ return deleted, err
+ }
+
+ code := intFrom(resp["code"])
+ if status != http.StatusOK || code != 0 {
+ msg, _ := resp["msg"].(string)
+ return deleted, fmt.Errorf("fetch sessions failed: status=%d, code=%d, msg=%s", status, code, msg)
+ }
+
+ data, _ := resp["data"].(map[string]any)
+ bizData, _ := data["biz_data"].(map[string]any)
+ chatSessions, _ := bizData["chat_sessions"].([]any)
+ hasMore, _ := bizData["has_more"].(bool)
+
+ // 删除每个会话
+ deletedThisRound := 0
+ for _, s := range chatSessions {
+ if m, ok := s.(map[string]any); ok {
+ sessionID := stringFromMap(m, "id")
+ if sessionID == "" {
+ continue
+ }
+ _, err := c.DeleteSessionForToken(ctx, token, sessionID)
+ if err == nil {
+ deleted++
+ deletedThisRound++
+ }
+ }
+ }
+
+ // 无进度检测:如果连续多轮没有成功删除任何会话,退出循环
+ if deletedThisRound == 0 {
+ noProgressCount++
+ if noProgressCount >= maxNoProgress {
+ config.Logger.Warn("[delete_all_sessions_for_token] exiting due to no progress", "deleted", deleted)
+ break
+ }
+ } else {
+ noProgressCount = 0
+ }
+
+ if !hasMore || len(chatSessions) == 0 {
+ break
+ }
+
+ // 推进 cursor:从最后一个会话中提取 cursor(通常基于 updated_at 或 id)
+ if len(chatSessions) > 0 {
+ if lastSession, ok := chatSessions[len(chatSessions)-1].(map[string]any); ok {
+ // 尝试从响应中获取 cursor 字段,或使用最后会话的 ID/updated_at
+ if nextCursor, ok := bizData["cursor"].(string); ok && nextCursor != "" {
+ cursor = nextCursor
+ } else if nextCursor := stringFromMap(lastSession, "id"); nextCursor != "" {
+ cursor = nextCursor
+ }
+ }
+ }
+ }
+
+ return deleted, nil
+}
From dfea092583c3ccd2c975c25047e8419a138ce38e Mon Sep 17 00:00:00 2001
From: latticeon <35923185+latticeon@users.noreply.github.com>
Date: Mon, 16 Mar 2026 11:58:07 +0800
Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=E6=B5=8B=E8=AF=95?=
=?UTF-8?q?=20mock=20=E7=BB=93=E6=9E=84=E4=BD=93=E4=BB=A5=E5=AE=9E?=
=?UTF-8?q?=E7=8E=B0=E6=96=B0=E5=A2=9E=E7=9A=84=E6=8E=A5=E5=8F=A3=E6=96=B9?=
=?UTF-8?q?=E6=B3=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
会话管理功能新增接口方法后,同步更新测试 mock 结构体:
- mockOpenAIConfig: 添加 AutoDeleteSessions() 方法
- streamStatusDSStub: 添加 DeleteAllSessionsForToken() 方法
- testingDSMock: 添加 DeleteAllSessionsForToken() 和 GetSessionCountForToken() 方法
同时修复 client_session_delete.go 中 fmt.Errorf 使用非常量格式字符串的编译错误,改用 errors.New()
---
internal/adapter/openai/deps_injection_test.go | 1 +
internal/adapter/openai/stream_status_test.go | 4 ++++
internal/admin/handler_accounts_testing_test.go | 9 +++++++++
internal/deepseek/client_session_delete.go | 2 +-
4 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/internal/adapter/openai/deps_injection_test.go b/internal/adapter/openai/deps_injection_test.go
index 6286c0c..9025ef3 100644
--- a/internal/adapter/openai/deps_injection_test.go
+++ b/internal/adapter/openai/deps_injection_test.go
@@ -19,6 +19,7 @@ func (m mockOpenAIConfig) ToolcallMode() string { return m.toolMo
func (m mockOpenAIConfig) ToolcallEarlyEmitConfidence() string { return m.earlyEmit }
func (m mockOpenAIConfig) ResponsesStoreTTLSeconds() int { return m.responsesTTL }
func (m mockOpenAIConfig) EmbeddingsProvider() string { return m.embedProv }
+func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false }
func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) {
cfg := mockOpenAIConfig{
diff --git a/internal/adapter/openai/stream_status_test.go b/internal/adapter/openai/stream_status_test.go
index 4d66b46..9bd6ccd 100644
--- a/internal/adapter/openai/stream_status_test.go
+++ b/internal/adapter/openai/stream_status_test.go
@@ -53,6 +53,10 @@ func (m streamStatusDSStub) CallCompletion(_ context.Context, _ *auth.RequestAut
return m.resp, nil
}
+func (m streamStatusDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) (int, error) {
+ return 0, nil
+}
+
func makeOpenAISSEHTTPResponse(lines ...string) *http.Response {
body := strings.Join(lines, "\n")
if !strings.HasSuffix(body, "\n") {
diff --git a/internal/admin/handler_accounts_testing_test.go b/internal/admin/handler_accounts_testing_test.go
index 4c84fd6..47013c1 100644
--- a/internal/admin/handler_accounts_testing_test.go
+++ b/internal/admin/handler_accounts_testing_test.go
@@ -9,6 +9,7 @@ import (
"ds2api/internal/auth"
"ds2api/internal/config"
+ "ds2api/internal/deepseek"
)
type testingDSMock struct {
@@ -38,6 +39,14 @@ func (m *testingDSMock) CallCompletion(_ context.Context, _ *auth.RequestAuth, _
return nil, errors.New("should not call CallCompletion in this test")
}
+func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) (int, error) {
+ return 0, nil
+}
+
+func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*deepseek.SessionStats, error) {
+ return &deepseek.SessionStats{Success: true}, nil
+}
+
func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`)
store := config.LoadStore()
diff --git a/internal/deepseek/client_session_delete.go b/internal/deepseek/client_session_delete.go
index 2f49fc5..a34a56f 100644
--- a/internal/deepseek/client_session_delete.go
+++ b/internal/deepseek/client_session_delete.go
@@ -107,7 +107,7 @@ func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessio
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
result.ErrorMessage = fmt.Sprintf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
- return result, fmt.Errorf(result.ErrorMessage)
+ return result, errors.New(result.ErrorMessage)
}
result.Success = true
From f6296d506fb1e003072d831d3787c1a7a873c644 Mon Sep 17 00:00:00 2001
From: latticeon <35923185+latticeon@users.noreply.github.com>
Date: Mon, 16 Mar 2026 16:23:39 +0800
Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E4=BF=AE=E6=94=B9=E6=89=B9=E9=87=8F?=
=?UTF-8?q?=E5=88=A0=E9=99=A4=E4=BC=9A=E8=AF=9D=E6=96=B9=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 从逐条单个删除改为官方的批量删除接口
- 单个删除函数保留备用
---
internal/adapter/openai/deps.go | 2 +-
internal/adapter/openai/handler_chat.go | 4 +-
internal/adapter/openai/stream_status_test.go | 4 +-
internal/admin/deps.go | 2 +-
internal/admin/handler_accounts_testing.go | 6 +-
.../admin/handler_accounts_testing_test.go | 4 +-
internal/deepseek/client_session_delete.go | 142 ++++--------------
internal/deepseek/constants.go | 3 +-
.../src/features/account/useAccountActions.js | 2 +-
webui/src/locales/en.json | 2 +-
webui/src/locales/zh.json | 2 +-
11 files changed, 46 insertions(+), 127 deletions(-)
diff --git a/internal/adapter/openai/deps.go b/internal/adapter/openai/deps.go
index b033d3d..b2270c7 100644
--- a/internal/adapter/openai/deps.go
+++ b/internal/adapter/openai/deps.go
@@ -19,7 +19,7 @@ type DeepSeekCaller interface {
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
- DeleteAllSessionsForToken(ctx context.Context, token string) (int, error)
+ DeleteAllSessionsForToken(ctx context.Context, token string) error
}
type ConfigReader interface {
diff --git a/internal/adapter/openai/handler_chat.go b/internal/adapter/openai/handler_chat.go
index a06e126..c13e75c 100644
--- a/internal/adapter/openai/handler_chat.go
+++ b/internal/adapter/openai/handler_chat.go
@@ -42,11 +42,11 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
// 2. 新请求可能获取到同一账号并开始使用
// 3. 异步删除仍在进行,会截断新请求正在使用的会话
if h.Store.AutoDeleteSessions() && a.DeepSeekToken != "" {
- deleted, err := h.DS.DeleteAllSessionsForToken(context.Background(), a.DeepSeekToken)
+ err := h.DS.DeleteAllSessionsForToken(context.Background(), a.DeepSeekToken)
if err != nil {
config.Logger.Warn("[auto_delete_sessions] failed", "account", a.AccountID, "error", err)
} else {
- config.Logger.Debug("[auto_delete_sessions] deleted", "account", a.AccountID, "count", deleted)
+ config.Logger.Debug("[auto_delete_sessions] success", "account", a.AccountID)
}
}
h.Auth.Release(a)
diff --git a/internal/adapter/openai/stream_status_test.go b/internal/adapter/openai/stream_status_test.go
index 9bd6ccd..033dc37 100644
--- a/internal/adapter/openai/stream_status_test.go
+++ b/internal/adapter/openai/stream_status_test.go
@@ -53,8 +53,8 @@ func (m streamStatusDSStub) CallCompletion(_ context.Context, _ *auth.RequestAut
return m.resp, nil
}
-func (m streamStatusDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) (int, error) {
- return 0, nil
+func (m streamStatusDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error {
+ return nil
}
func makeOpenAISSEHTTPResponse(lines ...string) *http.Response {
diff --git a/internal/admin/deps.go b/internal/admin/deps.go
index 5c58a4f..997c42b 100644
--- a/internal/admin/deps.go
+++ b/internal/admin/deps.go
@@ -42,7 +42,7 @@ type DeepSeekCaller interface {
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
GetSessionCountForToken(ctx context.Context, token string) (*deepseek.SessionStats, error)
- DeleteAllSessionsForToken(ctx context.Context, token string) (int, error)
+ DeleteAllSessionsForToken(ctx context.Context, token string) error
}
var _ ConfigStore = (*config.Store)(nil)
diff --git a/internal/admin/handler_accounts_testing.go b/internal/admin/handler_accounts_testing.go
index 33f43c7..0dc602d 100644
--- a/internal/admin/handler_accounts_testing.go
+++ b/internal/admin/handler_accounts_testing.go
@@ -245,11 +245,11 @@ func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) {
}
// 删除所有会话
- deleted, err := h.DS.DeleteAllSessionsForToken(r.Context(), token)
+ err := h.DS.DeleteAllSessionsForToken(r.Context(), token)
if err != nil {
- writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error(), "deleted": deleted})
+ writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error()})
return
}
- writeJSON(w, http.StatusOK, map[string]any{"success": true, "deleted": deleted, "message": fmt.Sprintf("成功删除 %d 个会话", deleted)})
+ writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "删除成功"})
}
diff --git a/internal/admin/handler_accounts_testing_test.go b/internal/admin/handler_accounts_testing_test.go
index 47013c1..1636669 100644
--- a/internal/admin/handler_accounts_testing_test.go
+++ b/internal/admin/handler_accounts_testing_test.go
@@ -39,8 +39,8 @@ func (m *testingDSMock) CallCompletion(_ context.Context, _ *auth.RequestAuth, _
return nil, errors.New("should not call CallCompletion in this test")
}
-func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) (int, error) {
- return 0, nil
+func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) error {
+ return nil
}
func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*deepseek.SessionStats, error) {
diff --git a/internal/deepseek/client_session_delete.go b/internal/deepseek/client_session_delete.go
index a34a56f..e4814bf 100644
--- a/internal/deepseek/client_session_delete.go
+++ b/internal/deepseek/client_session_delete.go
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"net/http"
- "net/url"
"ds2api/internal/auth"
"ds2api/internal/config"
@@ -115,124 +114,43 @@ func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessio
}
// DeleteAllSessions 删除所有会话(谨慎使用)
-func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) (int, error) {
- const maxNoProgress = 3 // 最大无进度次数
+func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) error {
+ headers := c.authHeaders(a.DeepSeekToken)
+ payload := map[string]any{}
- deleted := 0
- cursor := ""
- noProgressCount := 0
-
- for {
- sessions, hasMore, err := c.FetchSessionPage(ctx, a, cursor)
- if err != nil {
- return deleted, err
- }
-
- deletedThisRound := 0
- for _, session := range sessions {
- _, err := c.DeleteSession(ctx, a, session.ID, 1)
- if err == nil {
- deleted++
- deletedThisRound++
- }
- }
-
- // 无进度检测:如果连续多轮没有成功删除任何会话,退出循环
- if deletedThisRound == 0 {
- noProgressCount++
- if noProgressCount >= maxNoProgress {
- config.Logger.Warn("[delete_all_sessions] exiting due to no progress", "deleted", deleted)
- break
- }
- } else {
- noProgressCount = 0
- }
-
- if !hasMore || len(sessions) == 0 {
- break
- }
+ resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
+ if err != nil {
+ config.Logger.Warn("[delete_all_sessions] request error", "error", err)
+ return err
}
- return deleted, nil
+ code := intFrom(resp["code"])
+ if status != http.StatusOK || code != 0 {
+ msg, _ := resp["msg"].(string)
+ config.Logger.Warn("[delete_all_sessions] failed", "status", status, "code", code, "msg", msg)
+ return fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
+ }
+
+ return nil
}
// DeleteAllSessionsForToken 直接使用 token 删除所有会话(直通模式)
-func (c *Client) DeleteAllSessionsForToken(ctx context.Context, token string) (int, error) {
- const maxNoProgress = 3 // 最大无进度次数
+func (c *Client) DeleteAllSessionsForToken(ctx context.Context, token string) error {
+ headers := c.authHeaders(token)
+ payload := map[string]any{}
- deleted := 0
- cursor := ""
- noProgressCount := 0
-
- for {
- // 获取会话列表
- headers := c.authHeaders(token)
- params := url.Values{}
- params.Set("lte_cursor.pinned", "false")
- if cursor != "" {
- params.Set("lte_cursor", cursor)
- }
- reqURL := DeepSeekFetchSessionURL + "?" + params.Encode()
-
- resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
- if err != nil {
- return deleted, err
- }
-
- code := intFrom(resp["code"])
- if status != http.StatusOK || code != 0 {
- msg, _ := resp["msg"].(string)
- return deleted, fmt.Errorf("fetch sessions failed: status=%d, code=%d, msg=%s", status, code, msg)
- }
-
- data, _ := resp["data"].(map[string]any)
- bizData, _ := data["biz_data"].(map[string]any)
- chatSessions, _ := bizData["chat_sessions"].([]any)
- hasMore, _ := bizData["has_more"].(bool)
-
- // 删除每个会话
- deletedThisRound := 0
- for _, s := range chatSessions {
- if m, ok := s.(map[string]any); ok {
- sessionID := stringFromMap(m, "id")
- if sessionID == "" {
- continue
- }
- _, err := c.DeleteSessionForToken(ctx, token, sessionID)
- if err == nil {
- deleted++
- deletedThisRound++
- }
- }
- }
-
- // 无进度检测:如果连续多轮没有成功删除任何会话,退出循环
- if deletedThisRound == 0 {
- noProgressCount++
- if noProgressCount >= maxNoProgress {
- config.Logger.Warn("[delete_all_sessions_for_token] exiting due to no progress", "deleted", deleted)
- break
- }
- } else {
- noProgressCount = 0
- }
-
- if !hasMore || len(chatSessions) == 0 {
- break
- }
-
- // 推进 cursor:从最后一个会话中提取 cursor(通常基于 updated_at 或 id)
- if len(chatSessions) > 0 {
- if lastSession, ok := chatSessions[len(chatSessions)-1].(map[string]any); ok {
- // 尝试从响应中获取 cursor 字段,或使用最后会话的 ID/updated_at
- if nextCursor, ok := bizData["cursor"].(string); ok && nextCursor != "" {
- cursor = nextCursor
- } else if nextCursor := stringFromMap(lastSession, "id"); nextCursor != "" {
- cursor = nextCursor
- }
- }
- }
+ resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
+ if err != nil {
+ config.Logger.Warn("[delete_all_sessions_for_token] request error", "error", err)
+ return err
}
- return deleted, nil
+ code := intFrom(resp["code"])
+ if status != http.StatusOK || code != 0 {
+ msg, _ := resp["msg"].(string)
+ config.Logger.Warn("[delete_all_sessions_for_token] failed", "status", status, "code", code, "msg", msg)
+ return fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
+ }
+
+ return nil
}
diff --git a/internal/deepseek/constants.go b/internal/deepseek/constants.go
index 35b5c29..f35332a 100644
--- a/internal/deepseek/constants.go
+++ b/internal/deepseek/constants.go
@@ -12,7 +12,8 @@ const (
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion"
DeepSeekFetchSessionURL = "https://chat.deepseek.com/api/v0/chat_session/fetch_page"
- DeepSeekDeleteSessionURL = "https://chat.deepseek.com/api/v0/chat_session/delete"
+ DeepSeekDeleteSessionURL = "https://chat.deepseek.com/api/v0/chat_session/delete"
+ DeepSeekDeleteAllSessionsURL = "https://chat.deepseek.com/api/v0/chat_session/delete_all"
)
var defaultBaseHeaders = map[string]string{
diff --git a/webui/src/features/account/useAccountActions.js b/webui/src/features/account/useAccountActions.js
index a14df92..e91f8fb 100644
--- a/webui/src/features/account/useAccountActions.js
+++ b/webui/src/features/account/useAccountActions.js
@@ -196,7 +196,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
const data = await res.json()
if (data.success) {
- onMessage('success', t('accountManager.deleteAllSessionsSuccess', { count: data.deleted }))
+ onMessage('success', t('accountManager.deleteAllSessionsSuccess'))
// 清除会话数显示
setSessionCounts(prev => {
const newCounts = { ...prev }
diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json
index 817b27c..ecdf16f 100644
--- a/webui/src/locales/en.json
+++ b/webui/src/locales/en.json
@@ -135,7 +135,7 @@
"sessionCount": "Sessions: {count}",
"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 {count} sessions"
+ "deleteAllSessionsSuccess": "Successfully deleted all sessions"
},
"apiTester": {
"defaultMessage": "Hello, please introduce yourself in one sentence.",
diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json
index 6631f99..a52c72f 100644
--- a/webui/src/locales/zh.json
+++ b/webui/src/locales/zh.json
@@ -135,7 +135,7 @@
"sessionCount": "会话: {count}",
"deleteAllSessions": "删除所有会话",
"deleteAllSessionsConfirm": "确定要删除该账号的所有会话吗?此操作不可恢复。",
- "deleteAllSessionsSuccess": "成功删除 {count} 个会话"
+ "deleteAllSessionsSuccess": "删除成功"
},
"apiTester": {
"defaultMessage": "你好,请用一句话介绍你自己。",
From d35e5eab255de3122e93dadc6f6a6e64e6541ae3 Mon Sep 17 00:00:00 2001
From: "CJACK."
Date: Mon, 16 Mar 2026 22:58:13 +0800
Subject: [PATCH 5/6] ci: ignore tests in line gate and raise frontend limit
---
plans/refactor-line-gate-targets.txt | 4 ++-
plans/refactor-line-gate.md | 9 ++++---
tests/scripts/check-refactor-line-gate.sh | 30 ++++++++++++++++++++++-
3 files changed, 37 insertions(+), 6 deletions(-)
diff --git a/plans/refactor-line-gate-targets.txt b/plans/refactor-line-gate-targets.txt
index 645b7e1..ac45d57 100644
--- a/plans/refactor-line-gate-targets.txt
+++ b/plans/refactor-line-gate-targets.txt
@@ -1,6 +1,8 @@
# Line gate targets for large-file decoupling refactor.
-# Default limit: 300 lines
+# Backend default limit: 300 lines
+# Frontend (webui/) default limit: 500 lines
# Entry/facade limit: 120 lines (enforced in script)
+# Test files are ignored by the gate script.
internal/config/config.go
internal/config/logger.go
diff --git a/plans/refactor-line-gate.md b/plans/refactor-line-gate.md
index 86f0d82..103782d 100644
--- a/plans/refactor-line-gate.md
+++ b/plans/refactor-line-gate.md
@@ -2,10 +2,11 @@
## Rules
-1. Production file default upper bound: `<= 300` lines.
-2. Entry/facade files upper bound: `<= 120` lines.
-3. Scope is limited to target files in `plans/refactor-line-gate-targets.txt`.
-4. Test files are out of scope for this gate.
+1. Backend production files upper bound: `<= 300` lines.
+2. Frontend (`webui/`) production files upper bound: `<= 500` lines.
+3. Entry/facade files upper bound: `<= 120` lines.
+4. Scope is limited to target files in `plans/refactor-line-gate-targets.txt`.
+5. Test files are out of scope for this gate.
## Command
diff --git a/tests/scripts/check-refactor-line-gate.sh b/tests/scripts/check-refactor-line-gate.sh
index 4118d15..8a7f890 100755
--- a/tests/scripts/check-refactor-line-gate.sh
+++ b/tests/scripts/check-refactor-line-gate.sh
@@ -5,6 +5,7 @@ ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
TARGETS_FILE="$ROOT_DIR/plans/refactor-line-gate-targets.txt"
DEFAULT_MAX=300
+FRONTEND_MAX=500
ENTRY_MAX=120
is_entry_file() {
@@ -22,6 +23,27 @@ is_entry_file() {
return 1
}
+is_frontend_file() {
+ [[ "$1" == webui/* ]]
+}
+
+is_test_file() {
+ local file="$1"
+ local base
+ base="$(basename "$file")"
+
+ [[ "$file" == tests/* ]] && return 0
+ [[ "$file" == */tests/* ]] && return 0
+ [[ "$file" == */__tests__/* ]] && return 0
+ [[ "$base" == *_test.go ]] && return 0
+ [[ "$base" == *.test.js ]] && return 0
+ [[ "$base" == *.test.jsx ]] && return 0
+ [[ "$base" == *.test.ts ]] && return 0
+ [[ "$base" == *.test.tsx ]] && return 0
+
+ return 1
+}
+
if [[ ! -f "$TARGETS_FILE" ]]; then
echo "missing targets file: $TARGETS_FILE" >&2
exit 1
@@ -35,6 +57,10 @@ while IFS= read -r file; do
[[ -z "$file" ]] && continue
[[ "${file:0:1}" == "#" ]] && continue
+ if is_test_file "$file"; then
+ continue
+ fi
+
checked=$((checked + 1))
abs="$ROOT_DIR/$file"
if [[ ! -f "$abs" ]]; then
@@ -45,7 +71,9 @@ while IFS= read -r file; do
lines="$(wc -l < "$abs" | tr -d ' ')"
limit="$DEFAULT_MAX"
- if is_entry_file "$file"; then
+ if is_frontend_file "$file"; then
+ limit="$FRONTEND_MAX"
+ elif is_entry_file "$file"; then
limit="$ENTRY_MAX"
fi
From 7648d5f1929a1fea7cd50fd0a481b70dd94d5198 Mon Sep 17 00:00:00 2001
From: "CJACK."
Date: Mon, 16 Mar 2026 23:06:58 +0800
Subject: [PATCH 6/6] ci: keep entry line cap precedence over frontend cap
---
tests/scripts/check-refactor-line-gate.sh | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/tests/scripts/check-refactor-line-gate.sh b/tests/scripts/check-refactor-line-gate.sh
index 8a7f890..3fda714 100755
--- a/tests/scripts/check-refactor-line-gate.sh
+++ b/tests/scripts/check-refactor-line-gate.sh
@@ -71,10 +71,10 @@ while IFS= read -r file; do
lines="$(wc -l < "$abs" | tr -d ' ')"
limit="$DEFAULT_MAX"
- if is_frontend_file "$file"; then
- limit="$FRONTEND_MAX"
- elif is_entry_file "$file"; then
+ if is_entry_file "$file"; then
limit="$ENTRY_MAX"
+ elif is_frontend_file "$file"; then
+ limit="$FRONTEND_MAX"
fi
if (( lines > limit )); then