feat(t230): use OCR for ERP session login

This commit is contained in:
QiuSW
2026-07-29 10:48:06 +08:00
parent 8c84b81d48
commit f887bc4882
30 changed files with 512 additions and 205 deletions
@@ -39,6 +39,11 @@ type SessionConfig struct {
Timeout time.Duration
CaptchaTTL time.Duration
AllowInsecureHTTP bool // Used only by isolated httptest contracts.
CaptchaRecognizer CaptchaRecognizer
}
type CaptchaRecognizer interface {
Recognize(context.Context, []byte, string) (string, error)
}
type SessionStatus struct {
@@ -54,6 +59,7 @@ type CaptchaImage struct {
}
type SessionManager struct {
authMu sync.Mutex
mu sync.Mutex
baseURL string
username string
@@ -62,6 +68,7 @@ type SessionManager struct {
captchaTTL time.Duration
headers http.Header
http *http.Client
recognizer CaptchaRecognizer
authenticated bool
captchaTicket string
captchaContent []byte
@@ -111,9 +118,39 @@ func NewSessionManager(config SessionConfig) (*SessionManager, error) {
return http.ErrUseLastResponse
},
},
recognizer: config.CaptchaRecognizer,
}, nil
}
// EnsureAuthenticated establishes the single in-memory ERP session only when
// the current cookie jar cannot be validated.
func (manager *SessionManager) EnsureAuthenticated(ctx context.Context) error {
manager.authMu.Lock()
defer manager.authMu.Unlock()
if _, err := manager.Validate(ctx); err == nil {
return nil
} else if !errors.Is(err, domain.ErrFreightSourceSessionNeeded) {
return err
}
if manager.recognizer == nil {
return domain.ErrFreightSourceOCRInvalid
}
status, err := manager.FetchCaptcha(ctx)
if err != nil {
return err
}
image, err := manager.OpenCaptcha(status.CaptchaTicket)
if err != nil {
return domain.ErrFreightSourceProtocol
}
code, err := manager.recognizer.Recognize(ctx, image.Content, image.ContentType)
if err != nil || !validCaptchaCode(code) {
return domain.ErrFreightSourceOCRInvalid
}
_, err = manager.Login(ctx, status.CaptchaTicket, code)
return err
}
func (manager *SessionManager) Status() SessionStatus {
manager.mu.Lock()
defer manager.mu.Unlock()
@@ -166,6 +166,66 @@ func TestSessionManagerSerializesCaptchaRequests(t *testing.T) {
}
}
func TestSessionManagerEnsureAuthenticatedUsesRecognizerOnce(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++
http.SetCookie(w, &http.Cookie{Name: "captcha", Value: "ready", Path: "/"})
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("captcha-image"))
case LoginPath:
loginCalls++
http.SetCookie(w, &http.Cookie{Name: "authenticated", Value: "yes", Path: "/"})
_, _ = w.Write([]byte(`{"status":true,"data":{"user":{"id":12}}}`))
case UserPath:
_, _ = w.Write([]byte(`{"status":true,"data":{"id":12}}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
recognizer := &fixedRecognizer{code: "1234"}
manager := testSessionManager(t, server.URL, "test-user", "test-password")
manager.recognizer = recognizer
if err := manager.EnsureAuthenticated(context.Background()); err != nil {
t.Fatalf("EnsureAuthenticated() error = %v", err)
}
if !manager.Status().Authenticated || recognizer.calls != 1 || captchaCalls != 1 || loginCalls != 1 {
t.Fatalf("state/calls = %+v / %d / %d / %d", manager.Status(), recognizer.calls, captchaCalls, loginCalls)
}
if err := manager.EnsureAuthenticated(context.Background()); err != nil {
t.Fatalf("second EnsureAuthenticated() error = %v", err)
}
if recognizer.calls != 1 || captchaCalls != 1 || loginCalls != 1 {
t.Fatalf("second call repeated OCR/login = %d / %d / %d", recognizer.calls, captchaCalls, loginCalls)
}
}
func TestSessionManagerEnsureAuthenticatedRequiresRecognizer(t *testing.T) {
manager := testSessionManager(t, "https://erp.example.test", "test-user", "test-password")
err := manager.EnsureAuthenticated(context.Background())
if !errors.Is(err, domain.ErrFreightSourceOCRInvalid) {
t.Fatalf("EnsureAuthenticated() error = %v", err)
}
}
type fixedRecognizer struct {
code string
err error
calls int
}
func (recognizer *fixedRecognizer) Recognize(
context.Context,
[]byte,
string,
) (string, error) {
recognizer.calls++
return recognizer.code, recognizer.err
}
func testSessionManager(
t *testing.T,
baseURL, username, password string,