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. CaptchaRecognizer CaptchaRecognizer } type CaptchaRecognizer interface { Recognize(context.Context, []byte, string) (string, error) } type SessionStatus struct { Configured bool Authenticated bool CaptchaReady bool CaptchaTicket string } type CaptchaImage struct { Content []byte ContentType string } type SessionManager struct { authMu sync.Mutex mu sync.Mutex baseURL string username string password string timeout time.Duration captchaTTL time.Duration headers http.Header http *http.Client recognizer CaptchaRecognizer 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 }, }, 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() 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, false, ) 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, true, ) 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, requireSession 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"` Code json.RawMessage `json:"code"` Data json.RawMessage `json:"data"` } decoder := json.NewDecoder(bytes.NewReader(contentBytes)) if err := decoder.Decode(&envelope); err != nil || envelope.Status == nil || len(envelope.Data) == 0 { return nil, domain.ErrFreightSourceProtocol } var extra any if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { return nil, domain.ErrFreightSourceProtocol } if !*envelope.Status { if loginRequest { return nil, ErrLoginRejected } if requireSession || unauthenticatedCode(envelope.Code) { manager.authenticated = false return nil, domain.ErrFreightSourceSessionNeeded } return nil, domain.ErrFreightSourceProtocol } var data any dataDecoder := json.NewDecoder(bytes.NewReader(envelope.Data)) dataDecoder.UseNumber() if err := dataDecoder.Decode(&data); err != nil { return nil, domain.ErrFreightSourceProtocol } if err := dataDecoder.Decode(&extra); !errors.Is(err, io.EOF) { return nil, domain.ErrFreightSourceProtocol } return data, nil } func unauthenticatedCode(raw json.RawMessage) bool { var value any decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() if len(raw) == 0 || decoder.Decode(&value) != nil { return false } switch typed := value.(type) { case json.Number: return typed == "-2" case string: return strings.TrimSpace(typed) == "-2" default: return false } } 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 }