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 maxDiagnosticBytes = 4 << 10 ) 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 DiagnosticLogger DiagnosticLogger } type CaptchaRecognizer interface { Recognize(context.Context, []byte, string) (string, error) } // DiagnosticLogger receives only redacted request/response summaries when // explicitly enabled by the API composition root. type DiagnosticLogger func(string) 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 diagnosticLog DiagnosticLogger diagnosticsOn bool authenticated bool identity sessionIdentity captchaTicket string captchaContent []byte captchaType string captchaExpires time.Time } type sessionIdentity struct { ID string Username string } 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, diagnosticLog: config.DiagnosticLogger, diagnosticsOn: config.DiagnosticLogger != nil, }, 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 { manager.logOCRResultFailed() return domain.ErrFreightSourceOCRInvalid } code = strings.TrimSpace(code) if !validCaptchaCode(code) { manager.logOCRResultInvalid() return domain.ErrFreightSourceOCRInvalid } manager.logOCRResult(code) _, err = manager.Login(ctx, status.CaptchaTicket, code) if errors.Is(err, ErrLoginRejected) { return domain.ErrFreightSourceLoginRejected } 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) manager.logERPRequest(request) response, err := manager.http.Do(request) if err != nil { manager.logERPTransportFailure(request) return manager.statusLocked(), domain.ErrFreightSourceUnavailable } defer response.Body.Close() if response.StatusCode != http.StatusOK { manager.logERPResponsePreview(request, response) 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 { manager.logERPResponseReadFailure(request, response) return manager.statusLocked(), domain.ErrFreightSourceUnavailable } manager.logERPResponse(request, response, content, false) 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.clearAuthenticatedLocked() 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 } identity, err := sessionIdentityFrom(data) if err != nil { return manager.statusLocked(), domain.ErrFreightSourceProtocol } if _, err := manager.validateIdentityLocked(ctx, identity); err != nil { return manager.statusLocked(), err } manager.identity = identity 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) { if manager.identity.ID == "" || manager.identity.Username == "" { manager.clearAuthenticatedLocked() return nil, domain.ErrFreightSourceProtocol } return manager.validateIdentityLocked(ctx, manager.identity) } func (manager *SessionManager) validateIdentityLocked( ctx context.Context, expected sessionIdentity, ) (any, error) { query := url.Values{} query.Set("id", expected.ID) data, err := manager.requestJSONLocked( ctx, http.MethodGet, UserPath+"?"+query.Encode(), nil, false, ) if err != nil { if errors.Is(err, domain.ErrFreightSourceProtocol) { manager.clearAuthenticatedLocked() } return nil, err } actual, err := sessionIdentityFrom(data) if err != nil || actual != expected { manager.clearAuthenticatedLocked() 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") } manager.logERPRequest(request) response, err := manager.http.Do(request) if err != nil { manager.logERPTransportFailure(request) return nil, domain.ErrFreightSourceUnavailable } defer response.Body.Close() if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { manager.logERPResponsePreview(request, response) if loginRequest { return nil, ErrLoginRejected } return nil, manager.responseErrorLocked(response.StatusCode) } contentBytes, err := readBounded(response.Body, maxERPResponseBytes) if err != nil { manager.logERPResponseReadFailure(request, response) return nil, domain.ErrFreightSourceUnavailable } manager.logERPResponse(request, response, contentBytes, false) 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 unauthenticatedCode(envelope.Code) { manager.clearAuthenticatedLocked() 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.clearAuthenticatedLocked() return domain.ErrFreightSourceSessionNeeded } return domain.ErrFreightSourceUnavailable } func (manager *SessionManager) clearAuthenticatedLocked() { manager.authenticated = false manager.identity = sessionIdentity{} } 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 (manager *SessionManager) logERPRequest(request *http.Request) { if !manager.diagnosticsOn { return } manager.diagnosticLog( "erp_request method=" + request.Method + " path=" + request.URL.EscapedPath(), ) } func (manager *SessionManager) logERPTransportFailure(request *http.Request) { if !manager.diagnosticsOn { return } manager.diagnosticLog( "erp_transport_failed method=" + request.Method + " path=" + request.URL.EscapedPath() + " class=transport", ) } func (manager *SessionManager) logOCRResult(value string) { if !manager.diagnosticsOn { return } manager.diagnosticLog( "erp_ocr_result value=" + strconv.Quote(value) + " length=" + strconv.Itoa(len(value)), ) } func (manager *SessionManager) logOCRResultInvalid() { if !manager.diagnosticsOn { return } manager.diagnosticLog("erp_ocr_result class=invalid") } func (manager *SessionManager) logOCRResultFailed() { if !manager.diagnosticsOn { return } manager.diagnosticLog("erp_ocr_result class=failed") } func (manager *SessionManager) logERPResponsePreview( request *http.Request, response *http.Response, ) { if !manager.diagnosticsOn { return } content, truncated, readable := readDiagnosticPreview(response.Body) if !readable { manager.logERPResponseReadFailure(request, response) return } manager.logERPResponse(request, response, content, truncated) } func (manager *SessionManager) logERPResponseReadFailure( request *http.Request, response *http.Response, ) { if !manager.diagnosticsOn { return } manager.diagnosticLog( "erp_response method=" + request.Method + " path=" + request.URL.EscapedPath() + " status=" + strconv.Itoa(response.StatusCode) + " content_type=" + diagnosticContentType(response.Header.Get("Content-Type")) + " body=unavailable", ) } func (manager *SessionManager) logERPResponse( request *http.Request, response *http.Response, content []byte, truncated bool, ) { if !manager.diagnosticsOn { return } byteCount := "bytes=" + strconv.Itoa(len(content)) body := "body=empty" if truncated { byteCount = "bytes_at_least=" + strconv.Itoa(len(content)) body = "body=omitted_truncated" } else if len(content) > 0 { body = "body=omitted_non_json" if summary, ok := redactedDiagnosticJSON(content); ok { body = "json=" + summary } } manager.diagnosticLog( "erp_response method=" + request.Method + " path=" + request.URL.EscapedPath() + " status=" + strconv.Itoa(response.StatusCode) + " content_type=" + diagnosticContentType(response.Header.Get("Content-Type")) + " " + byteCount + " " + body, ) } func readDiagnosticPreview(reader io.Reader) ([]byte, bool, bool) { content, err := io.ReadAll(io.LimitReader(reader, maxDiagnosticBytes+1)) if err != nil { return nil, false, false } if len(content) > maxDiagnosticBytes { return content[:maxDiagnosticBytes], true, true } return content, false, true } func diagnosticContentType(value string) string { value = strings.TrimSpace(strings.Split(value, ";")[0]) if value == "" || len(value) > 128 || !utf8.ValidString(value) || hasControl(value) { return "unknown" } return value } func redactedDiagnosticJSON(content []byte) (string, bool) { decoder := json.NewDecoder(bytes.NewReader(content)) decoder.UseNumber() var value any if err := decoder.Decode(&value); err != nil { return "", false } var extra any if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { return "", false } encoded, err := json.Marshal(redactDiagnosticValue(value)) if err != nil || len(encoded) > maxDiagnosticBytes { return `{"summary":"omitted_large_json"}`, true } return string(encoded), true } func redactDiagnosticValue(value any) any { switch typed := value.(type) { case map[string]any: redacted := make(map[string]any, len(typed)) for key, item := range typed { if sensitiveDiagnosticKey(key) { redacted[key] = "[REDACTED]" continue } redacted[key] = redactDiagnosticValue(item) } return redacted case []any: limit := len(typed) if limit > 20 { limit = 20 } redacted := make([]any, 0, limit+1) for _, item := range typed[:limit] { redacted = append(redacted, redactDiagnosticValue(item)) } if len(typed) > limit { redacted = append(redacted, "[TRUNCATED]") } return redacted case string: return redactDiagnosticString(typed) default: return value } } func sensitiveDiagnosticKey(value string) bool { var normalized strings.Builder for _, character := range strings.ToLower(value) { if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') { normalized.WriteRune(character) } } key := normalized.String() for _, marker := range []string{ "password", "passwd", "pwd", "username", "user", "captcha", "token", "cookie", "authorization", "auth", "receiver", "recipient", "phone", "mobile", "tel", "address", "email", "order", "stock", "tracking", "track", "express", "shipment", "shop", "name", "id", "code", "remark", "note", "detail", } { if strings.Contains(key, marker) { return true } } return false } func redactDiagnosticString(value string) string { value = strings.ToValidUTF8(strings.TrimSpace(value), "?") value = strings.NewReplacer("\r", " ", "\n", " ", "\t", " ").Replace(value) if strings.Contains(value, "@") { return "[REDACTED]" } var result strings.Builder for index := 0; index < len(value); { if value[index] < '0' || value[index] > '9' { result.WriteByte(value[index]) index++ continue } end := index for end < len(value) && value[end] >= '0' && value[end] <= '9' { end++ } if end-index >= 6 { result.WriteString("[REDACTED]") } else { result.WriteString(value[index:end]) } index = end } characters := []rune(result.String()) if len(characters) > 512 { return string(characters[:512]) + "[TRUNCATED]" } return string(characters) } func sessionIdentityFrom(value any) (sessionIdentity, error) { data, ok := value.(map[string]any) if !ok { return sessionIdentity{}, errInvalidProtocolInput } if user, exists := data["user"]; exists { data, ok = user.(map[string]any) if !ok { return sessionIdentity{}, errInvalidProtocolInput } } id, err := externalID(data["id"]) if err != nil { return sessionIdentity{}, errInvalidProtocolInput } username, ok := data["username"].(string) username = strings.TrimSpace(username) if !ok || username == "" || len([]byte(username)) > 512 || !utf8.ValidString(username) || hasControl(username) { return sessionIdentity{}, errInvalidProtocolInput } return sessionIdentity{ID: id, Username: 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 }