diff --git a/server/app/goauto/yeekeclient/client.go b/server/app/goauto/yeekeclient/client.go index 7cd9661..57f6f03 100644 --- a/server/app/goauto/yeekeclient/client.go +++ b/server/app/goauto/yeekeclient/client.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/cookiejar" "net/url" + "strconv" "strings" "time" ) @@ -103,6 +104,9 @@ func (c *Client) do(ctx context.Context, method, path string, body any, query ur return nil, e } req.Header.Set("Accept", "application/json") + // yeeke is driven through its web front end; a Go default user agent is + // treated differently from the browser (see demo/yeeke_demo.py and the HAR). + req.Header.Set("User-Agent", browserUserAgent) if body != nil { req.Header.Set("Content-Type", "application/json") } @@ -136,15 +140,48 @@ func (c *Client) do(ctx context.Context, method, path string, body any, query ur return nil, e } if !env.Success { - if env.Code == 401 || strings.Contains(env.Message, "登录") || strings.Contains(env.Message, "token") { - return nil, ErrSessionInvalid - } - return nil, fmt.Errorf("yeeke request failed code=%d", env.Code) + return nil, &APIError{Code: env.Code, Message: env.Message} } return env.Result, nil } + +// browserUserAgent mirrors the desktop Chrome the yeeke web client runs in. +const browserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + +// APIError is a yeeke business failure (success=false). Message is yeeke's own +// short text (e.g. "验证码错误"); it never contains our credentials or token. +type APIError struct { + Code int + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("yeeke request failed code=%d: %s", e.Code, safeMessage(e.Message)) +} + +// Is keeps the historical contract: an expired or missing login is reported as +// ErrSessionInvalid so callers can re-login. +func (e *APIError) Is(target error) bool { + if target != ErrSessionInvalid { + return false + } + return e.Code == 401 || strings.Contains(e.Message, "token") || strings.Contains(e.Message, "登录已过期") || strings.Contains(e.Message, "未登录") || strings.Contains(e.Message, "重新登录") +} + +// isCaptchaRejected reports a wrong captcha, the only login failure worth +// retrying with a fresh image. +func (e *APIError) isCaptchaRejected() bool { return strings.Contains(e.Message, "验证码") } + +func safeMessage(m string) string { + r := []rune(strings.TrimSpace(m)) + if len(r) > 60 { + r = r[:60] + } + return string(r) +} func (c *Client) FetchCaptcha(ctx context.Context) (*Captcha, error) { - raw, e := c.do(ctx, http.MethodGet, "/agent-foreign/sys/randomImage", nil, nil) + q := url.Values{"_t": {strconv.FormatInt(time.Now().UnixMilli(), 10)}} + raw, e := c.do(ctx, http.MethodGet, "/agent-foreign/sys/randomImage", nil, q) if e != nil { return nil, e } @@ -195,10 +232,11 @@ func (c *Client) LoginWithOCR(ctx context.Context, ocr OCR, username, password s if maxAttempts <= 0 || maxAttempts > 5 { maxAttempts = 3 } + emptyOCR, captchaRejected := 0, 0 for i := 0; i < maxAttempts; i++ { cap, e := c.FetchCaptcha(ctx) if e != nil { - return nil, e + return nil, fmt.Errorf("yeeke 获取验证码失败: %w", e) } code, e := ocr.Recognize(ctx, cap.Image) if e != nil { @@ -206,13 +244,26 @@ func (c *Client) LoginWithOCR(ctx context.Context, ocr OCR, username, password s } code = strings.TrimSpace(code) if code == "" { + emptyOCR++ continue } - if out, e := c.Login(ctx, username, password, code, cap.CheckKey); e == nil { + out, e := c.Login(ctx, username, password, code, cap.CheckKey) + if e == nil { return out, nil } + var apiErr *APIError + if errors.As(e, &apiErr) && apiErr.isCaptchaRejected() { + captchaRejected++ + continue + } + // Anything else (wrong account/password, locked account, network) will + // not be fixed by another captcha; stop instead of burning attempts. + if errors.As(e, &apiErr) { + return nil, fmt.Errorf("yeeke 登录被拒绝: %s", safeMessage(apiErr.Message)) + } + return nil, fmt.Errorf("yeeke 登录请求失败: %w", e) } - return nil, fmt.Errorf("yeeke login failed after limited captcha attempts") + return nil, fmt.Errorf("yeeke login failed after limited captcha attempts (attempts=%d, captcha_rejected=%d, ocr_empty=%d)", maxAttempts, captchaRejected, emptyOCR) } func (c *Client) Login(ctx context.Context, username, password, captcha, checkKey string) (*LoginResult, error) { diff --git a/server/app/goauto/yeekeclient/client_test.go b/server/app/goauto/yeekeclient/client_test.go index 99bb0bb..e6966fe 100644 --- a/server/app/goauto/yeekeclient/client_test.go +++ b/server/app/goauto/yeekeclient/client_test.go @@ -3,8 +3,10 @@ package yeekeclient import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -97,3 +99,114 @@ func keysOnly(m map[string]any) []string { } return out } + +// Mirrors demo/yeeke_demo.py and the HAR: browser user agent on every call and +// a _t cache-buster on randomImage. +func TestRequestsCarryBrowserUserAgentAndCaptchaTimestamp(t *testing.T) { + var captchaQuery, captchaUA, loginUA string + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/agent-foreign/sys/randomImage": + captchaQuery, captchaUA = r.URL.RawQuery, r.UserAgent() + json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}}) + case "/agent-foreign/sys/login": + loginUA = r.UserAgent() + json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}}) + } + })) + defer s.Close() + c, _ := New(s.URL) + if _, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "p", 3); e != nil { + t.Fatal(e) + } + if !strings.HasPrefix(captchaQuery, "_t=") { + t.Fatalf("randomImage query = %q, want _t timestamp", captchaQuery) + } + for _, ua := range []string{captchaUA, loginUA} { + if !strings.Contains(ua, "Chrome/") { + t.Fatalf("user agent = %q, want browser UA", ua) + } + } +} + +// A wrong captcha is retried with a fresh image; a wrong password is not. +func TestLoginWithOCRRetriesOnlyCaptchaRejections(t *testing.T) { + cases := []struct { + name string + responses []map[string]any + wantOK bool + wantLogins int + wantErrSubstr string + }{ + { + name: "captcha wrong then ok", + responses: []map[string]any{ + {"success": false, "code": 500, "message": "验证码错误"}, + {"success": true, "code": 200, "result": map[string]any{"token": "opaque"}}, + }, + wantOK: true, wantLogins: 2, + }, + { + name: "wrong password stops immediately", + responses: []map[string]any{ + {"success": false, "code": 500, "message": "用户名或密码错误"}, + {"success": true, "code": 200, "result": map[string]any{"token": "opaque"}}, + }, + wantLogins: 1, wantErrSubstr: "用户名或密码错误", + }, + { + name: "captcha always wrong", + responses: []map[string]any{ + {"success": false, "code": 500, "message": "验证码错误"}, + {"success": false, "code": 500, "message": "验证码错误"}, + {"success": false, "code": 500, "message": "验证码错误"}, + }, + wantLogins: 3, wantErrSubstr: "captcha_rejected=3", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + logins := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/agent-foreign/sys/randomImage": + json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}}) + case "/agent-foreign/sys/login": + resp := tc.responses[logins] + logins++ + json.NewEncoder(w).Encode(resp) + } + })) + defer s.Close() + c, _ := New(s.URL) + _, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "S3cr3t-Do-Not-Leak", 3) + if (e == nil) != tc.wantOK { + t.Fatalf("err = %v, wantOK = %v", e, tc.wantOK) + } + if logins != tc.wantLogins { + t.Fatalf("login calls = %d, want %d", logins, tc.wantLogins) + } + if e != nil { + if !strings.Contains(e.Error(), tc.wantErrSubstr) { + t.Fatalf("err = %q, want it to contain %q", e, tc.wantErrSubstr) + } + if strings.Contains(e.Error(), "S3cr3t-Do-Not-Leak") || strings.Contains(e.Error(), "abcd") { + t.Fatalf("error leaks credentials or captcha text: %q", e) + } + } + }) + } +} + +func TestAPIErrorMapsExpiredLoginToSessionInvalid(t *testing.T) { + for msg, want := range map[string]bool{"token失效,请重新登录": true, "未登录": true, "验证码错误": false, "用户名或密码错误": false} { + if got := errors.Is(&APIError{Code: 500, Message: msg}, ErrSessionInvalid); got != want { + t.Fatalf("%q: errors.Is(ErrSessionInvalid) = %v, want %v", msg, got, want) + } + } + if !errors.Is(&APIError{Code: 401}, ErrSessionInvalid) { + t.Fatal("code 401 must map to ErrSessionInvalid") + } +}