From 3ae5b57ebe67da7357c9d9ae88583869311ad85d Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Mon, 2 Mar 2026 23:48:54 +0800 Subject: [PATCH] fix(deepseek): normalize mobile before login token refresh --- internal/admin/handler_accounts_testing.go | 421 +++++++++--------- .../admin/handler_accounts_testing_test.go | 76 ++++ internal/deepseek/client_auth.go | 29 +- internal/deepseek/client_auth_mobile_test.go | 33 ++ 4 files changed, 348 insertions(+), 211 deletions(-) create mode 100644 internal/admin/handler_accounts_testing_test.go create mode 100644 internal/deepseek/client_auth_mobile_test.go diff --git a/internal/admin/handler_accounts_testing.go b/internal/admin/handler_accounts_testing.go index 262c809..7a7430d 100644 --- a/internal/admin/handler_accounts_testing.go +++ b/internal/admin/handler_accounts_testing.go @@ -1,209 +1,212 @@ -package admin - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "sync" - "time" - - authn "ds2api/internal/auth" - "ds2api/internal/config" - "ds2api/internal/sse" -) - -func (h *Handler) testSingleAccount(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 - } - model, _ := req["model"].(string) - if model == "" { - model = "deepseek-chat" - } - message, _ := req["message"].(string) - result := h.testAccount(r.Context(), acc, model, message) - writeJSON(w, http.StatusOK, result) -} - -func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) { - var req map[string]any - _ = json.NewDecoder(r.Body).Decode(&req) - model, _ := req["model"].(string) - if model == "" { - model = "deepseek-chat" - } - accounts := h.Store.Snapshot().Accounts - if len(accounts) == 0 { - writeJSON(w, http.StatusOK, map[string]any{"total": 0, "success": 0, "failed": 0, "results": []any{}}) - return - } - - // Concurrent testing with a semaphore to limit parallelism. - const maxConcurrency = 5 - results := runAccountTestsConcurrently(accounts, maxConcurrency, func(_ int, account config.Account) map[string]any { - return h.testAccount(r.Context(), account, model, "") - }) - - success := 0 - for _, res := range results { - if ok, _ := res["success"].(bool); ok { - success++ - } - } - writeJSON(w, http.StatusOK, map[string]any{"total": len(accounts), "success": success, "failed": len(accounts) - success, "results": results}) -} - -func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, testFn func(int, config.Account) map[string]any) []map[string]any { - if maxConcurrency <= 0 { - maxConcurrency = 1 - } - sem := make(chan struct{}, maxConcurrency) - results := make([]map[string]any, len(accounts)) - var wg sync.WaitGroup - for i, acc := range accounts { - wg.Add(1) - go func(idx int, account config.Account) { - defer wg.Done() - sem <- struct{}{} // acquire - defer func() { <-sem }() // release - results[idx] = testFn(idx, account) - }(i, acc) - } - wg.Wait() - return results -} - -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} - defer func() { - status := "failed" - if ok, _ := result["success"].(bool); ok { - status = "ok" - } - _ = h.Store.UpdateAccountTestStatus(identifier, status) - }() - token := strings.TrimSpace(acc.Token) - if token == "" { - newToken, err := h.DS.Login(ctx, acc) - if err != nil { - result["message"] = "登录失败: " + err.Error() - return result - } - token = newToken - _ = h.Store.UpdateAccountToken(acc.Identifier(), token) - } - authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token} - sessionID, err := h.DS.CreateSession(ctx, authCtx, 1) - if err != nil { - newToken, loginErr := h.DS.Login(ctx, acc) - if loginErr != nil { - result["message"] = "创建会话失败: " + err.Error() - return result - } - token = newToken - authCtx.DeepSeekToken = token - _ = h.Store.UpdateAccountToken(acc.Identifier(), token) - sessionID, err = h.DS.CreateSession(ctx, authCtx, 1) - if err != nil { - result["message"] = "创建会话失败: " + err.Error() - return result - } - } - if strings.TrimSpace(message) == "" { - message = "你是谁?" - } - thinking, search, ok := config.GetModelConfig(model) - if !ok { - thinking, search = false, false - } - _ = search - pow, err := h.DS.GetPow(ctx, authCtx, 1) - if err != nil { - result["message"] = "获取 PoW 失败: " + err.Error() - return result - } - payload := map[string]any{"chat_session_id": sessionID, "prompt": "<|User|>" + message, "ref_file_ids": []any{}, "thinking_enabled": thinking, "search_enabled": search} - resp, err := h.DS.CallCompletion(ctx, authCtx, payload, pow, 1) - if err != nil { - result["message"] = "请求失败: " + err.Error() - return result - } - if resp.StatusCode != http.StatusOK { - defer resp.Body.Close() - result["message"] = fmt.Sprintf("请求失败: HTTP %d", resp.StatusCode) - return result - } - collected := sse.CollectStream(resp, thinking, true) - result["success"] = true - result["response_time"] = int(time.Since(start).Milliseconds()) - if collected.Text != "" { - result["message"] = collected.Text - } else { - result["message"] = "(无回复内容)" - } - if collected.Thinking != "" { - result["thinking"] = collected.Thinking - } - return result -} - -func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) { - var req map[string]any - _ = json.NewDecoder(r.Body).Decode(&req) - model, _ := req["model"].(string) - message, _ := req["message"].(string) - apiKey, _ := req["api_key"].(string) - if model == "" { - model = "deepseek-chat" - } - if message == "" { - message = "你好" - } - if apiKey == "" { - keys := h.Store.Snapshot().Keys - if len(keys) == 0 { - writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "没有可用的 API Key"}) - return - } - apiKey = keys[0] - } - host := r.Host - scheme := "http" - if strings.Contains(strings.ToLower(host), "vercel") || strings.Contains(strings.ToLower(r.Header.Get("X-Forwarded-Proto")), "https") { - scheme = "https" - } - payload := map[string]any{"model": model, "messages": []map[string]any{{"role": "user", "content": message}}, "stream": false} - b, _ := json.Marshal(payload) - request, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s://%s/v1/chat/completions", scheme, host), bytes.NewReader(b)) - request.Header.Set("Authorization", "Bearer "+apiKey) - request.Header.Set("Content-Type", "application/json") - resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(request) - if err != nil { - writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()}) - return - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode == http.StatusOK { - var parsed any - _ = json.Unmarshal(body, &parsed) - writeJSON(w, http.StatusOK, map[string]any{"success": true, "status_code": resp.StatusCode, "response": parsed}) - return - } - writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)}) -} +package admin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + authn "ds2api/internal/auth" + "ds2api/internal/config" + "ds2api/internal/sse" +) + +func (h *Handler) testSingleAccount(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 + } + model, _ := req["model"].(string) + if model == "" { + model = "deepseek-chat" + } + message, _ := req["message"].(string) + result := h.testAccount(r.Context(), acc, model, message) + writeJSON(w, http.StatusOK, result) +} + +func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) { + var req map[string]any + _ = json.NewDecoder(r.Body).Decode(&req) + model, _ := req["model"].(string) + if model == "" { + model = "deepseek-chat" + } + accounts := h.Store.Snapshot().Accounts + if len(accounts) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"total": 0, "success": 0, "failed": 0, "results": []any{}}) + return + } + + // Concurrent testing with a semaphore to limit parallelism. + const maxConcurrency = 5 + results := runAccountTestsConcurrently(accounts, maxConcurrency, func(_ int, account config.Account) map[string]any { + return h.testAccount(r.Context(), account, model, "") + }) + + success := 0 + for _, res := range results { + if ok, _ := res["success"].(bool); ok { + success++ + } + } + writeJSON(w, http.StatusOK, map[string]any{"total": len(accounts), "success": success, "failed": len(accounts) - success, "results": results}) +} + +func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, testFn func(int, config.Account) map[string]any) []map[string]any { + if maxConcurrency <= 0 { + maxConcurrency = 1 + } + sem := make(chan struct{}, maxConcurrency) + results := make([]map[string]any, len(accounts)) + var wg sync.WaitGroup + for i, acc := range accounts { + wg.Add(1) + go func(idx int, account config.Account) { + defer wg.Done() + sem <- struct{}{} // acquire + defer func() { <-sem }() // release + results[idx] = testFn(idx, account) + }(i, acc) + } + wg.Wait() + return results +} + +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} + defer func() { + status := "failed" + if ok, _ := result["success"].(bool); ok { + status = "ok" + } + _ = h.Store.UpdateAccountTestStatus(identifier, status) + }() + token := strings.TrimSpace(acc.Token) + if token == "" { + newToken, err := h.DS.Login(ctx, acc) + if err != nil { + result["message"] = "登录失败: " + err.Error() + return result + } + token = newToken + _ = h.Store.UpdateAccountToken(acc.Identifier(), token) + } + authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token} + sessionID, err := h.DS.CreateSession(ctx, authCtx, 1) + if err != nil { + newToken, loginErr := h.DS.Login(ctx, acc) + if loginErr != nil { + result["message"] = "创建会话失败: " + err.Error() + return result + } + token = newToken + authCtx.DeepSeekToken = token + _ = h.Store.UpdateAccountToken(acc.Identifier(), token) + sessionID, err = h.DS.CreateSession(ctx, authCtx, 1) + if err != nil { + result["message"] = "创建会话失败: " + err.Error() + return result + } + } + if strings.TrimSpace(message) == "" { + result["success"] = true + result["message"] = "API 测试成功(仅会话创建)" + result["response_time"] = int(time.Since(start).Milliseconds()) + return result + } + thinking, search, ok := config.GetModelConfig(model) + if !ok { + thinking, search = false, false + } + _ = search + pow, err := h.DS.GetPow(ctx, authCtx, 1) + if err != nil { + result["message"] = "获取 PoW 失败: " + err.Error() + return result + } + payload := map[string]any{"chat_session_id": sessionID, "prompt": "<|User|>" + message, "ref_file_ids": []any{}, "thinking_enabled": thinking, "search_enabled": search} + resp, err := h.DS.CallCompletion(ctx, authCtx, payload, pow, 1) + if err != nil { + result["message"] = "请求失败: " + err.Error() + return result + } + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + result["message"] = fmt.Sprintf("请求失败: HTTP %d", resp.StatusCode) + return result + } + collected := sse.CollectStream(resp, thinking, true) + result["success"] = true + result["response_time"] = int(time.Since(start).Milliseconds()) + if collected.Text != "" { + result["message"] = collected.Text + } else { + result["message"] = "(无回复内容)" + } + if collected.Thinking != "" { + result["thinking"] = collected.Thinking + } + return result +} + +func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) { + var req map[string]any + _ = json.NewDecoder(r.Body).Decode(&req) + model, _ := req["model"].(string) + message, _ := req["message"].(string) + apiKey, _ := req["api_key"].(string) + if model == "" { + model = "deepseek-chat" + } + if message == "" { + message = "你好" + } + if apiKey == "" { + keys := h.Store.Snapshot().Keys + if len(keys) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "没有可用的 API Key"}) + return + } + apiKey = keys[0] + } + host := r.Host + scheme := "http" + if strings.Contains(strings.ToLower(host), "vercel") || strings.Contains(strings.ToLower(r.Header.Get("X-Forwarded-Proto")), "https") { + scheme = "https" + } + payload := map[string]any{"model": model, "messages": []map[string]any{{"role": "user", "content": message}}, "stream": false} + b, _ := json.Marshal(payload) + request, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s://%s/v1/chat/completions", scheme, host), bytes.NewReader(b)) + request.Header.Set("Authorization", "Bearer "+apiKey) + request.Header.Set("Content-Type", "application/json") + resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(request) + if err != nil { + writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()}) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == http.StatusOK { + var parsed any + _ = json.Unmarshal(body, &parsed) + writeJSON(w, http.StatusOK, map[string]any{"success": true, "status_code": resp.StatusCode, "response": parsed}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)}) +} diff --git a/internal/admin/handler_accounts_testing_test.go b/internal/admin/handler_accounts_testing_test.go new file mode 100644 index 0000000..4c84fd6 --- /dev/null +++ b/internal/admin/handler_accounts_testing_test.go @@ -0,0 +1,76 @@ +package admin + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "ds2api/internal/auth" + "ds2api/internal/config" +) + +type testingDSMock struct { + loginCalls int + createSessionCalls int + getPowCalls int + callCompletionCalls int +} + +func (m *testingDSMock) Login(_ context.Context, _ config.Account) (string, error) { + m.loginCalls++ + return "new-token", nil +} + +func (m *testingDSMock) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + m.createSessionCalls++ + return "session-id", nil +} + +func (m *testingDSMock) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + m.getPowCalls++ + return "", errors.New("should not call GetPow in this test") +} + +func (m *testingDSMock) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) { + m.callCompletionCalls++ + return nil, errors.New("should not call CallCompletion in this test") +} + +func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`) + store := config.LoadStore() + ds := &testingDSMock{} + h := &Handler{Store: store, DS: ds} + acc, ok := store.FindAccount("batch@example.com") + if !ok { + t.Fatal("expected test account") + } + + result := h.testAccount(context.Background(), acc, "deepseek-chat", "") + + if ok, _ := result["success"].(bool); !ok { + t.Fatalf("expected success=true, got %#v", result) + } + msg, _ := result["message"].(string) + if !strings.Contains(msg, "仅会话创建") { + t.Fatalf("expected session-only success message, got %q", msg) + } + if ds.loginCalls != 1 || ds.createSessionCalls != 1 { + t.Fatalf("unexpected Login/CreateSession calls: login=%d createSession=%d", ds.loginCalls, ds.createSessionCalls) + } + if ds.getPowCalls != 0 || ds.callCompletionCalls != 0 { + t.Fatalf("expected no completion flow calls, got getPow=%d callCompletion=%d", ds.getPowCalls, ds.callCompletionCalls) + } + updated, ok := store.FindAccount("batch@example.com") + if !ok { + t.Fatal("expected updated account") + } + if updated.Token != "new-token" { + t.Fatalf("expected refreshed token to be persisted, got %q", updated.Token) + } + if updated.TestStatus != "ok" { + t.Fatalf("expected test status ok, got %q", updated.TestStatus) + } +} diff --git a/internal/deepseek/client_auth.go b/internal/deepseek/client_auth.go index 820acaf..efa96ba 100644 --- a/internal/deepseek/client_auth.go +++ b/internal/deepseek/client_auth.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "strings" + "unicode" "ds2api/internal/auth" "ds2api/internal/config" @@ -20,8 +21,9 @@ func (c *Client) Login(ctx context.Context, acc config.Account) (string, error) if email := strings.TrimSpace(acc.Email); email != "" { payload["email"] = email } else if mobile := strings.TrimSpace(acc.Mobile); mobile != "" { - payload["mobile"] = mobile - payload["area_code"] = nil + loginMobile, areaCode := normalizeMobileForLogin(mobile) + payload["mobile"] = loginMobile + payload["area_code"] = areaCode } else { return "", errors.New("missing email/mobile") } @@ -151,3 +153,26 @@ func isTokenInvalid(status int, code int, msg string) bool { } return strings.Contains(msg, "token") || strings.Contains(msg, "unauthorized") } + +func normalizeMobileForLogin(raw string) (mobile string, areaCode any) { + s := strings.TrimSpace(raw) + if s == "" { + return "", nil + } + hasPlus := strings.HasPrefix(s, "+") + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if unicode.IsDigit(r) { + b.WriteRune(r) + } + } + digits := b.String() + if digits == "" { + return "", nil + } + if (hasPlus || strings.HasPrefix(digits, "86")) && strings.HasPrefix(digits, "86") && len(digits) == 13 { + return digits[2:], nil + } + return digits, nil +} diff --git a/internal/deepseek/client_auth_mobile_test.go b/internal/deepseek/client_auth_mobile_test.go new file mode 100644 index 0000000..de81690 --- /dev/null +++ b/internal/deepseek/client_auth_mobile_test.go @@ -0,0 +1,33 @@ +package deepseek + +import "testing" + +func TestNormalizeMobileForLogin_ChinaWithPlus86(t *testing.T) { + mobile, areaCode := normalizeMobileForLogin("+8613800138000") + if mobile != "13800138000" { + t.Fatalf("unexpected mobile: %q", mobile) + } + if areaCode != nil { + t.Fatalf("expected nil areaCode, got %#v", areaCode) + } +} + +func TestNormalizeMobileForLogin_ChinaWith86Prefix(t *testing.T) { + mobile, areaCode := normalizeMobileForLogin("8613800138000") + if mobile != "13800138000" { + t.Fatalf("unexpected mobile: %q", mobile) + } + if areaCode != nil { + t.Fatalf("expected nil areaCode, got %#v", areaCode) + } +} + +func TestNormalizeMobileForLogin_KeepPlainDigits(t *testing.T) { + mobile, areaCode := normalizeMobileForLogin("13800138000") + if mobile != "13800138000" { + t.Fatalf("unexpected mobile: %q", mobile) + } + if areaCode != nil { + t.Fatalf("expected nil areaCode, got %#v", areaCode) + } +}