fix(t236): retry rejected ERP captchas

This commit is contained in:
QiuSW
2026-07-29 11:54:06 +08:00
parent d7ab970f5a
commit cf83fb50e4
6 changed files with 237 additions and 23 deletions
@@ -23,6 +23,9 @@ import (
const (
defaultSessionTimeout = 30 * time.Second
defaultCaptchaTTL = 5 * time.Minute
authenticationBudget = 20 * time.Second
maxCaptchaRetries = 6
maxCaptchaAttempts = maxCaptchaRetries + 1
maxCaptchaBytes = 2 << 20
maxERPResponseBytes = 4 << 20
maxDiagnosticBytes = 4 << 10
@@ -30,6 +33,7 @@ const (
var (
ErrCaptchaTicketInvalid = errors.New("ERP captcha ticket is invalid")
ErrCaptchaRejected = errors.New("ERP captcha was rejected")
ErrLoginRejected = errors.New("ERP login was rejected")
)
@@ -143,7 +147,9 @@ func NewSessionManager(config SessionConfig) (*SessionManager, error) {
func (manager *SessionManager) EnsureAuthenticated(ctx context.Context) error {
manager.authMu.Lock()
defer manager.authMu.Unlock()
if _, err := manager.Validate(ctx); err == nil {
authCtx, cancel := context.WithTimeout(ctx, authenticationBudget)
defer cancel()
if _, err := manager.Validate(authCtx); err == nil {
return nil
} else if !errors.Is(err, domain.ErrFreightSourceSessionNeeded) {
return err
@@ -151,6 +157,30 @@ func (manager *SessionManager) EnsureAuthenticated(ctx context.Context) error {
if manager.recognizer == nil {
return domain.ErrFreightSourceOCRInvalid
}
for attempt := 1; attempt <= maxCaptchaAttempts; attempt++ {
err := manager.authenticateOnce(authCtx)
switch {
case err == nil:
manager.logERPLoginAttempt(attempt, "success")
return nil
case errors.Is(err, ErrCaptchaRejected):
manager.logERPLoginAttempt(attempt, "captcha_rejected")
if attempt < maxCaptchaAttempts {
continue
}
return domain.ErrFreightSourceLoginRejected
case errors.Is(err, ErrLoginRejected):
manager.logERPLoginAttempt(attempt, "login_rejected")
return domain.ErrFreightSourceLoginRejected
default:
manager.logERPLoginAttempt(attempt, "failed")
return err
}
}
return domain.ErrFreightSourceLoginRejected
}
func (manager *SessionManager) authenticateOnce(ctx context.Context) error {
status, err := manager.FetchCaptcha(ctx)
if err != nil {
return err
@@ -171,9 +201,6 @@ func (manager *SessionManager) EnsureAuthenticated(ctx context.Context) error {
}
manager.logOCRResult(code)
_, err = manager.Login(ctx, status.CaptchaTicket, code)
if errors.Is(err, ErrLoginRejected) {
return domain.ErrFreightSourceLoginRejected
}
return err
}
@@ -393,9 +420,10 @@ func (manager *SessionManager) requestJSONLocked(
}
manager.logERPResponse(request, response, contentBytes, false)
var envelope struct {
Status *bool `json:"status"`
Code json.RawMessage `json:"code"`
Data json.RawMessage `json:"data"`
Status *bool `json:"status"`
Code json.RawMessage `json:"code"`
Data json.RawMessage `json:"data"`
Message *string `json:"msg"`
}
decoder := json.NewDecoder(bytes.NewReader(contentBytes))
if err := decoder.Decode(&envelope); err != nil || envelope.Status == nil ||
@@ -408,6 +436,9 @@ func (manager *SessionManager) requestJSONLocked(
}
if !*envelope.Status {
if loginRequest {
if envelope.Message != nil && captchaRejectedMessage(*envelope.Message) {
return nil, ErrCaptchaRejected
}
return nil, ErrLoginRejected
}
if unauthenticatedCode(envelope.Code) {
@@ -428,6 +459,10 @@ func (manager *SessionManager) requestJSONLocked(
return data, nil
}
func captchaRejectedMessage(message string) bool {
return strings.TrimSpace(message) == "图片验证码不正确"
}
func unauthenticatedCode(raw json.RawMessage) bool {
var value any
decoder := json.NewDecoder(bytes.NewReader(raw))
@@ -538,6 +573,17 @@ func (manager *SessionManager) logOCRResultFailed() {
manager.diagnosticLog("erp_ocr_result class=failed")
}
func (manager *SessionManager) logERPLoginAttempt(attempt int, result string) {
if !manager.diagnosticsOn {
return
}
manager.diagnosticLog(
"erp_login_attempt attempt=" + strconv.Itoa(attempt) +
" max_attempts=" + strconv.Itoa(maxCaptchaAttempts) +
" result=" + result,
)
}
func (manager *SessionManager) logERPResponsePreview(
request *http.Request,
response *http.Response,
@@ -259,6 +259,162 @@ func TestSessionManagerEnsureAuthenticatedUsesRecognizerOnce(t *testing.T) {
}
}
func TestSessionManagerEnsureAuthenticatedRetriesCaptchaRejectionSixTimes(t *testing.T) {
var captchaCalls, loginCalls, userCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case CaptchaPath:
captchaCalls++
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("captcha-image"))
case LoginPath:
loginCalls++
w.Header().Set("Content-Type", "application/json")
if loginCalls <= maxCaptchaRetries {
_, _ = w.Write([]byte(`{"status":false,"data":null,"msg":"图片验证码不正确"}`))
return
}
_, _ = w.Write([]byte(`{"status":true,"data":{"user":{"id":12,"username":"test-user"}}}`))
case UserPath:
userCalls++
_, _ = w.Write([]byte(`{"status":true,"data":{"id":12,"username":"test-user"}}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
var events []string
recognizer := &fixedRecognizer{code: "1234"}
manager, err := NewSessionManager(SessionConfig{
BaseURL: server.URL,
Username: "test-user",
Password: "test-password",
Timeout: time.Second,
AllowInsecureHTTP: true,
CaptchaRecognizer: recognizer,
DiagnosticLogger: func(event string) {
events = append(events, event)
},
})
if err != nil {
t.Fatalf("NewSessionManager() error = %v", err)
}
if err := manager.EnsureAuthenticated(context.Background()); err != nil {
t.Fatalf("EnsureAuthenticated() error = %v", err)
}
if captchaCalls != maxCaptchaAttempts || recognizer.calls != maxCaptchaAttempts ||
loginCalls != maxCaptchaAttempts || userCalls != 1 {
t.Fatalf(
"captcha/OCR/login/user calls = %d/%d/%d/%d",
captchaCalls,
recognizer.calls,
loginCalls,
userCalls,
)
}
actual := strings.Join(events, "\n")
for _, expected := range []string{
"erp_login_attempt attempt=1 max_attempts=7 result=captcha_rejected",
"erp_login_attempt attempt=6 max_attempts=7 result=captcha_rejected",
"erp_login_attempt attempt=7 max_attempts=7 result=success",
} {
if !strings.Contains(actual, expected) {
t.Fatalf("diagnostic log missing %q: %s", expected, actual)
}
}
for _, secret := range []string{"test-user", "test-password"} {
if strings.Contains(actual, secret) {
t.Fatalf("diagnostic log leaked %q: %s", secret, actual)
}
}
}
func TestSessionManagerEnsureAuthenticatedStopsAfterCaptchaRetries(t *testing.T) {
var captchaCalls, loginCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case CaptchaPath:
captchaCalls++
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("captcha-image"))
case LoginPath:
loginCalls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":false,"data":null,"msg":"图片验证码不正确"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
recognizer := &fixedRecognizer{code: "1234"}
manager := testSessionManager(t, server.URL, "test-user", "test-password")
manager.recognizer = recognizer
err := manager.EnsureAuthenticated(context.Background())
if !errors.Is(err, domain.ErrFreightSourceLoginRejected) {
t.Fatalf("EnsureAuthenticated() error = %v", err)
}
if captchaCalls != maxCaptchaAttempts || recognizer.calls != maxCaptchaAttempts ||
loginCalls != maxCaptchaAttempts {
t.Fatalf(
"captcha/OCR/login calls = %d/%d/%d",
captchaCalls,
recognizer.calls,
loginCalls,
)
}
}
func TestSessionManagerEnsureAuthenticatedDoesNotRetryOtherLoginRejections(t *testing.T) {
var captchaCalls, loginCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case CaptchaPath:
captchaCalls++
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("captcha-image"))
case LoginPath:
loginCalls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":false,"data":null,"msg":"账号或密码错误"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
recognizer := &fixedRecognizer{code: "1234"}
manager := testSessionManager(t, server.URL, "test-user", "test-password")
manager.recognizer = recognizer
err := manager.EnsureAuthenticated(context.Background())
if !errors.Is(err, domain.ErrFreightSourceLoginRejected) {
t.Fatalf("EnsureAuthenticated() error = %v", err)
}
if captchaCalls != 1 || recognizer.calls != 1 || loginCalls != 1 {
t.Fatalf(
"captcha/OCR/login calls = %d/%d/%d",
captchaCalls,
recognizer.calls,
loginCalls,
)
}
}
func TestCaptchaRejectedMessageIsExact(t *testing.T) {
for _, testCase := range []struct {
message string
want bool
}{
{message: "图片验证码不正确", want: true},
{message: " 图片验证码不正确 ", want: true},
{message: "验证码不正确", want: false},
{message: "账号或密码错误", want: false},
{message: "", want: false},
} {
if actual := captchaRejectedMessage(testCase.message); actual != testCase.want {
t.Fatalf("captchaRejectedMessage(%q) = %t, want %t", testCase.message, actual, testCase.want)
}
}
}
func TestSessionManagerEnsureAuthenticatedRequiresRecognizer(t *testing.T) {
manager := testSessionManager(t, "https://erp.example.test", "test-user", "test-password")
err := manager.EnsureAuthenticated(context.Background())