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