feat(t226): add Go ERP session login

This commit is contained in:
QiuSW
2026-07-29 09:51:41 +08:00
parent c2f340d067
commit a5a61c05d0
23 changed files with 1633 additions and 33 deletions
@@ -0,0 +1,402 @@
package shunyunbao
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"cmroubao/backend-api/internal/domain"
)
const (
defaultSessionTimeout = 30 * time.Second
defaultCaptchaTTL = 5 * time.Minute
maxCaptchaBytes = 2 << 20
maxERPResponseBytes = 4 << 20
)
var (
ErrCaptchaTicketInvalid = errors.New("ERP captcha ticket is invalid")
ErrLoginRejected = errors.New("ERP login was rejected")
)
type SessionConfig struct {
BaseURL string
Username string
Password string
Timeout time.Duration
CaptchaTTL time.Duration
AllowInsecureHTTP bool // Used only by isolated httptest contracts.
}
type SessionStatus struct {
Configured bool
Authenticated bool
CaptchaReady bool
CaptchaTicket string
}
type CaptchaImage struct {
Content []byte
ContentType string
}
type SessionManager struct {
mu sync.Mutex
baseURL string
username string
password string
timeout time.Duration
captchaTTL time.Duration
headers http.Header
http *http.Client
authenticated bool
captchaTicket string
captchaContent []byte
captchaType string
captchaExpires time.Time
}
func NewSessionManager(config SessionConfig) (*SessionManager, error) {
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
headers, err := RequestHeaders(baseURL)
if err != nil {
return nil, errors.New("shunyunbao session configuration is invalid")
}
parsed, _ := url.Parse(baseURL)
if parsed.Scheme != "https" && !config.AllowInsecureHTTP {
return nil, errors.New("shunyunbao session requires HTTPS")
}
username := strings.TrimSpace(config.Username)
if (username == "") != (config.Password == "") ||
!utf8.ValidString(username) || hasControl(username) ||
strings.ContainsRune(config.Password, '\x00') {
return nil, errors.New("shunyunbao credentials are invalid")
}
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultSessionTimeout
}
captchaTTL := config.CaptchaTTL
if captchaTTL <= 0 {
captchaTTL = defaultCaptchaTTL
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, errors.New("create shunyunbao cookie jar")
}
return &SessionManager{
baseURL: baseURL,
username: username,
password: config.Password,
timeout: timeout,
captchaTTL: captchaTTL,
headers: headers,
http: &http.Client{
Jar: jar,
Timeout: timeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}, nil
}
func (manager *SessionManager) Status() SessionStatus {
manager.mu.Lock()
defer manager.mu.Unlock()
manager.expireCaptchaLocked(time.Now())
return manager.statusLocked()
}
func (manager *SessionManager) FetchCaptcha(
ctx context.Context,
) (SessionStatus, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
if !manager.configuredLocked() {
return manager.statusLocked(), domain.ErrFreightSourceNotConfigured
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
manager.baseURL+CaptchaPath+"?_="+strconv.FormatInt(time.Now().UnixMilli(), 10),
nil,
)
if err != nil {
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
}
manager.applyHeaders(request)
response, err := manager.http.Do(request)
if err != nil {
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return manager.statusLocked(), manager.responseErrorLocked(response.StatusCode)
}
contentType := strings.TrimSpace(
strings.Split(response.Header.Get("Content-Type"), ";")[0],
)
if !strings.HasPrefix(contentType, "image/") {
return manager.statusLocked(), domain.ErrFreightSourceProtocol
}
content, err := readBounded(response.Body, maxCaptchaBytes)
if err != nil || len(content) == 0 {
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
}
ticket, err := newCaptchaTicket()
if err != nil {
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
}
manager.captchaTicket = ticket
manager.captchaContent = content
manager.captchaType = contentType
manager.captchaExpires = time.Now().Add(manager.captchaTTL)
return manager.statusLocked(), nil
}
func (manager *SessionManager) OpenCaptcha(
ticket string,
) (CaptchaImage, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
manager.expireCaptchaLocked(time.Now())
if !manager.captchaMatchesLocked(ticket) {
return CaptchaImage{}, ErrCaptchaTicketInvalid
}
return CaptchaImage{
Content: append([]byte(nil), manager.captchaContent...),
ContentType: manager.captchaType,
}, nil
}
func (manager *SessionManager) Login(
ctx context.Context,
ticket, captchaCode string,
) (SessionStatus, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
if !manager.configuredLocked() {
return manager.statusLocked(), domain.ErrFreightSourceNotConfigured
}
manager.expireCaptchaLocked(time.Now())
if !manager.captchaMatchesLocked(ticket) || !validCaptchaCode(captchaCode) {
return manager.statusLocked(), ErrCaptchaTicketInvalid
}
defer manager.clearCaptchaLocked()
manager.authenticated = false
payload, err := json.Marshal(map[string]string{
"username": manager.username,
"password": manager.password,
"code": strings.TrimSpace(captchaCode),
})
if err != nil {
return manager.statusLocked(), domain.ErrFreightSourceProtocol
}
data, err := manager.requestJSONLocked(
ctx,
http.MethodPost,
LoginPath,
payload,
true,
)
if err != nil {
return manager.statusLocked(), err
}
if !hasUser(data) {
return manager.statusLocked(), domain.ErrFreightSourceProtocol
}
if _, err := manager.validateLocked(ctx); err != nil {
return manager.statusLocked(), err
}
manager.authenticated = true
manager.clearCaptchaLocked()
return manager.statusLocked(), nil
}
func (manager *SessionManager) Validate(
ctx context.Context,
) (SessionStatus, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
if !manager.configuredLocked() {
return manager.statusLocked(), domain.ErrFreightSourceNotConfigured
}
if !manager.authenticated {
return manager.statusLocked(), domain.ErrFreightSourceSessionNeeded
}
if _, err := manager.validateLocked(ctx); err != nil {
return manager.statusLocked(), err
}
return manager.statusLocked(), nil
}
func (manager *SessionManager) validateLocked(ctx context.Context) (any, error) {
data, err := manager.requestJSONLocked(
ctx,
http.MethodGet,
UserPath,
nil,
false,
)
if err != nil {
return nil, err
}
if !hasUser(data) {
return nil, domain.ErrFreightSourceProtocol
}
return data, nil
}
func (manager *SessionManager) requestJSONLocked(
ctx context.Context,
method, path string,
body []byte,
loginRequest bool,
) (any, error) {
var content io.Reader
if body != nil {
content = bytes.NewReader(body)
}
request, err := http.NewRequestWithContext(
ctx,
method,
manager.baseURL+path,
content,
)
if err != nil {
return nil, domain.ErrFreightSourceUnavailable
}
manager.applyHeaders(request)
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := manager.http.Do(request)
if err != nil {
return nil, domain.ErrFreightSourceUnavailable
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
if loginRequest {
return nil, ErrLoginRejected
}
return nil, manager.responseErrorLocked(response.StatusCode)
}
contentBytes, err := readBounded(response.Body, maxERPResponseBytes)
if err != nil {
return nil, domain.ErrFreightSourceUnavailable
}
var envelope struct {
Status bool `json:"status"`
Data json.RawMessage `json:"data"`
}
decoder := json.NewDecoder(bytes.NewReader(contentBytes))
if err := decoder.Decode(&envelope); err != nil || len(envelope.Data) == 0 {
return nil, domain.ErrFreightSourceProtocol
}
if !envelope.Status {
if loginRequest {
return nil, ErrLoginRejected
}
manager.authenticated = false
return nil, domain.ErrFreightSourceSessionNeeded
}
var data any
dataDecoder := json.NewDecoder(bytes.NewReader(envelope.Data))
dataDecoder.UseNumber()
if err := dataDecoder.Decode(&data); err != nil {
return nil, domain.ErrFreightSourceProtocol
}
return data, nil
}
func (manager *SessionManager) applyHeaders(request *http.Request) {
for name, values := range manager.headers {
request.Header[name] = append([]string(nil), values...)
}
}
func (manager *SessionManager) responseErrorLocked(status int) error {
if status == http.StatusUnauthorized || status == http.StatusForbidden {
manager.authenticated = false
return domain.ErrFreightSourceSessionNeeded
}
return domain.ErrFreightSourceUnavailable
}
func (manager *SessionManager) configuredLocked() bool {
return manager.username != "" && manager.password != ""
}
func (manager *SessionManager) statusLocked() SessionStatus {
return SessionStatus{
Configured: manager.configuredLocked(),
Authenticated: manager.authenticated,
CaptchaReady: manager.captchaTicket != "",
CaptchaTicket: manager.captchaTicket,
}
}
func (manager *SessionManager) captchaMatchesLocked(ticket string) bool {
return ticket != "" && ticket == manager.captchaTicket
}
func (manager *SessionManager) expireCaptchaLocked(now time.Time) {
if manager.captchaTicket != "" && !now.Before(manager.captchaExpires) {
manager.clearCaptchaLocked()
}
}
func (manager *SessionManager) clearCaptchaLocked() {
manager.captchaTicket = ""
manager.captchaContent = nil
manager.captchaType = ""
manager.captchaExpires = time.Time{}
}
func hasUser(value any) bool {
data, ok := value.(map[string]any)
if !ok {
return false
}
if user, exists := data["user"]; exists {
_, ok := user.(map[string]any)
return ok
}
return data["id"] != nil || data["username"] != nil
}
func validCaptchaCode(value string) bool {
value = strings.TrimSpace(value)
return value != "" && len([]byte(value)) <= 64 && utf8.ValidString(value) &&
!hasControl(value)
}
func newCaptchaTicket() (string, error) {
value := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, value); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func readBounded(reader io.Reader, maximum int64) ([]byte, error) {
content, err := io.ReadAll(io.LimitReader(reader, maximum+1))
if err != nil || int64(len(content)) > maximum {
return nil, errors.New("response exceeds limit")
}
return content, nil
}
@@ -0,0 +1,194 @@
package shunyunbao
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"cmroubao/backend-api/internal/domain"
)
func TestSessionManagerCaptchaLoginAndValidationShareCookieJar(t *testing.T) {
var captchaCalls, loginCalls, userCalls int
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
switch request.URL.Path {
case CaptchaPath:
captchaCalls++
assertERPHeaders(t, request)
http.SetCookie(writer, &http.Cookie{Name: "captcha", Value: "ready", Path: "/"})
writer.Header().Set("Content-Type", "image/png")
_, _ = writer.Write([]byte("sanitized-captcha-image"))
case LoginPath:
loginCalls++
assertERPHeaders(t, request)
if cookie, err := request.Cookie("captcha"); err != nil || cookie.Value != "ready" {
t.Fatalf("login captcha cookie = %v / %v", cookie, err)
}
content, _ := io.ReadAll(request.Body)
if string(content) != `{"code":"1234","password":"test-password","username":"test-user"}` {
t.Fatalf("login body = %s", content)
}
http.SetCookie(writer, &http.Cookie{Name: "authenticated", Value: "yes", Path: "/"})
_, _ = writer.Write([]byte(`{"status":true,"data":{"user":{"id":12},"token":"never-exposed"}}`))
case UserPath:
userCalls++
if cookie, err := request.Cookie("authenticated"); err != nil || cookie.Value != "yes" {
t.Fatalf("user cookie = %v / %v", cookie, err)
}
_, _ = writer.Write([]byte(`{"status":true,"data":{"id":12}}`))
default:
writer.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
manager := testSessionManager(t, server.URL, "test-user", "test-password")
status, err := manager.FetchCaptcha(context.Background())
if err != nil || !status.Configured || !status.CaptchaReady || status.CaptchaTicket == "" {
t.Fatalf("FetchCaptcha() = %+v, %v", status, err)
}
image, err := manager.OpenCaptcha(status.CaptchaTicket)
if err != nil || image.ContentType != "image/png" ||
string(image.Content) != "sanitized-captcha-image" {
t.Fatalf("OpenCaptcha() = %+v, %v", image, err)
}
status, err = manager.Login(context.Background(), status.CaptchaTicket, "1234")
if err != nil || !status.Authenticated || status.CaptchaReady || status.CaptchaTicket != "" {
t.Fatalf("Login() = %+v, %v", status, err)
}
status, err = manager.Validate(context.Background())
if err != nil || !status.Authenticated || captchaCalls != 1 || loginCalls != 1 || userCalls != 2 {
t.Fatalf("Validate()/calls = %+v, %v / %d %d %d", status, err, captchaCalls, loginCalls, userCalls)
}
}
func TestSessionManagerMapsAnonymousFailuresAndExpiresState(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
switch request.URL.Path {
case CaptchaPath:
writer.Header().Set("Content-Type", "image/jpeg")
_, _ = writer.Write([]byte("captcha"))
case LoginPath:
_, _ = writer.Write([]byte(`{"status":false,"msg":"private rejected response","data":null}`))
case UserPath:
writer.WriteHeader(http.StatusUnauthorized)
_, _ = writer.Write([]byte("private session response"))
}
}))
defer server.Close()
missing := testSessionManager(t, server.URL, "", "")
if _, err := missing.FetchCaptcha(context.Background()); !errors.Is(err, domain.ErrFreightSourceNotConfigured) {
t.Fatalf("missing FetchCaptcha() error = %v", err)
}
manager := testSessionManager(t, server.URL, "test-user", "test-password")
status, err := manager.FetchCaptcha(context.Background())
if err != nil {
t.Fatalf("FetchCaptcha() error = %v", err)
}
_, err = manager.Login(context.Background(), status.CaptchaTicket, "bad")
if !errors.Is(err, ErrLoginRejected) || strings.Contains(err.Error(), "private") {
t.Fatalf("Login() error = %v", err)
}
if manager.Status().Authenticated || manager.Status().CaptchaReady {
t.Fatalf("rejected login state = %+v", manager.Status())
}
if _, err := manager.OpenCaptcha(status.CaptchaTicket); !errors.Is(err, ErrCaptchaTicketInvalid) {
t.Fatalf("OpenCaptcha() after login error = %v", err)
}
manager = testSessionManager(t, server.URL, "test-user", "test-password")
manager.captchaTTL = time.Nanosecond
status, err = manager.FetchCaptcha(context.Background())
if err != nil {
t.Fatalf("short FetchCaptcha() error = %v", err)
}
time.Sleep(time.Millisecond)
if _, err := manager.OpenCaptcha(status.CaptchaTicket); !errors.Is(err, ErrCaptchaTicketInvalid) {
t.Fatalf("expired captcha error = %v", err)
}
manager.authenticated = true
if _, err := manager.Validate(context.Background()); !errors.Is(err, domain.ErrFreightSourceSessionNeeded) ||
manager.Status().Authenticated {
t.Fatalf("expired session error/status = %v / %+v", err, manager.Status())
}
}
func TestSessionManagerSerializesCaptchaRequests(t *testing.T) {
var mutex sync.Mutex
inFlight, maximum := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
if request.URL.Path != CaptchaPath {
writer.WriteHeader(http.StatusNotFound)
return
}
mutex.Lock()
inFlight++
if inFlight > maximum {
maximum = inFlight
}
mutex.Unlock()
time.Sleep(20 * time.Millisecond)
mutex.Lock()
inFlight--
mutex.Unlock()
writer.Header().Set("Content-Type", "image/png")
_, _ = writer.Write([]byte("captcha"))
}))
defer server.Close()
manager := testSessionManager(t, server.URL, "test-user", "test-password")
var group sync.WaitGroup
for range 2 {
group.Add(1)
go func() {
defer group.Done()
if _, err := manager.FetchCaptcha(context.Background()); err != nil {
t.Errorf("FetchCaptcha() error = %v", err)
}
}()
}
group.Wait()
if maximum != 1 {
t.Fatalf("maximum concurrent ERP requests = %d", maximum)
}
}
func testSessionManager(
t *testing.T,
baseURL, username, password string,
) *SessionManager {
t.Helper()
manager, err := NewSessionManager(SessionConfig{
BaseURL: baseURL,
Username: username,
Password: password,
Timeout: time.Second,
AllowInsecureHTTP: true,
})
if err != nil {
t.Fatalf("NewSessionManager() error = %v", err)
}
return manager
}
func assertERPHeaders(t *testing.T, request *http.Request) {
t.Helper()
if request.Header.Get("Accept") != "application/json, text/plain, */*" ||
request.Header.Get("X-Requested-With") != "XMLHttpRequest" ||
request.Header.Get("Origin") == "" || request.Header.Get("Referer") == "" {
t.Fatalf("ERP headers = %#v", request.Header)
}
}