ERP 连接
+建立本次服务进程内的顺运宝会话
+已连接
+当前后端进程持有受控 ERP 会话。重启后需要重新获取验证码登录。
+尚未配置
+请在后端启动环境中配置顺运宝账号和密码后重启服务。
+diff --git a/backend-api/cmd/api/main.go b/backend-api/cmd/api/main.go index dc73cc1..ed8db35 100644 --- a/backend-api/cmd/api/main.go +++ b/backend-api/cmd/api/main.go @@ -17,6 +17,7 @@ import ( "cmroubao/backend-api/internal/platform/erpconnector" "cmroubao/backend-api/internal/platform/migration" "cmroubao/backend-api/internal/platform/password" + "cmroubao/backend-api/internal/platform/shunyunbao" repository "cmroubao/backend-api/internal/repository/sqlite" "cmroubao/backend-api/internal/transport/authcommon" "cmroubao/backend-api/internal/transport/httpapi" @@ -230,6 +231,15 @@ func buildRouter( if err != nil { return nil, err } + erpSession, err := shunyunbao.NewSessionManager(shunyunbao.SessionConfig{ + BaseURL: cfg.ShunyunbaoURL, + Username: cfg.ShunyunbaoUsername, + Password: cfg.ShunyunbaoPassword, + Timeout: cfg.ShunyunbaoTimeout, + }) + if err != nil { + return nil, err + } freight, err := usecase.NewFreightService( store, erpClient, @@ -285,6 +295,7 @@ func buildRouter( return nil, err } webService.SetProcurement(procurement) + webService.SetERPConnection(erpSession) renderer, err := webui.NewRenderer() if err != nil { return nil, err @@ -328,6 +339,7 @@ func buildRouter( Authorizations: authorizations, Freight: freight, Procurement: procurement, + ERP: erpSession, }, webHandler, ) diff --git a/backend-api/cmd/api/main_test.go b/backend-api/cmd/api/main_test.go index 7bb7cc9..05daf83 100644 --- a/backend-api/cmd/api/main_test.go +++ b/backend-api/cmd/api/main_test.go @@ -137,10 +137,12 @@ func TestBuildRouterRegistersProtectedLogoutRoute(t *testing.T) { t.Fatalf("migration.Up() error = %v", err) } router, err := buildRouter(ctx, config.Config{ - AssetDirectory: filepath.Join(t.TempDir(), "assets"), - ClaimLease: 10 * time.Minute, - RunningLease: 30 * time.Minute, - ReadinessTTL: 2 * time.Minute, + AssetDirectory: filepath.Join(t.TempDir(), "assets"), + ClaimLease: 10 * time.Minute, + RunningLease: 30 * time.Minute, + ReadinessTTL: 2 * time.Minute, + ShunyunbaoURL: "https://www.shunyunbaoerp.com", + ShunyunbaoTimeout: 30 * time.Second, }, db) if err != nil { t.Fatalf("buildRouter() error = %v", err) @@ -159,7 +161,7 @@ func TestBuildRouterRegistersProtectedLogoutRoute(t *testing.T) { response.Header().Get("Location"), ) } - for _, target := range []string{"/freight", "/freight/import"} { + for _, target := range []string{"/freight", "/freight/import", "/erp"} { request = httptest.NewRequest(http.MethodGet, target, nil) response = httptest.NewRecorder() router.ServeHTTP(response, request) diff --git a/backend-api/internal/config/config.go b/backend-api/internal/config/config.go index 29db0e2..89cb018 100644 --- a/backend-api/internal/config/config.go +++ b/backend-api/internal/config/config.go @@ -21,6 +21,9 @@ const ( ReadinessTTLEnvironment = "CMROUBAO_READINESS_TTL" ERPConnectorURLEnvironment = "CMROUBAO_ERP_CONNECTOR_URL" ERPConnectorAPIKeyEnvironment = "CMROUBAO_ERP_CONNECTOR_API_KEY" + ShunyunbaoURLEnvironment = "CMROUBAO_SHUNYUNBAO_URL" + ShunyunbaoUsernameEnvironment = "CMROUBAO_SHUNYUNBAO_USERNAME" + ShunyunbaoPasswordEnvironment = "CMROUBAO_SHUNYUNBAO_PASSWORD" defaultHTTPAddress = "127.0.0.1:8080" defaultDatabasePath = "var/cmroubao.db" @@ -29,6 +32,7 @@ const ( defaultRunningLease = 30 * time.Minute defaultReadinessTTL = 2 * time.Minute defaultERPConnectorURL = "http://127.0.0.1:8091" + defaultShunyunbaoURL = "https://www.shunyunbaoerp.com" ) type LookupEnvironment func(string) (string, bool) @@ -51,6 +55,10 @@ type Config struct { ERPConnectorURL string ERPConnectorAPIKey string ERPConnectorTimeout time.Duration + ShunyunbaoURL string + ShunyunbaoUsername string + ShunyunbaoPassword string + ShunyunbaoTimeout time.Duration } func Load(lookup LookupEnvironment) (Config, error) { @@ -165,6 +173,37 @@ func Load(lookup LookupEnvironment) (Config, error) { ) } } + shunyunbaoURL, err := environmentValue( + lookup, + ShunyunbaoURLEnvironment, + defaultShunyunbaoURL, + ) + if err != nil { + return Config{}, err + } + if err := validateHTTPSOrigin(shunyunbaoURL, ShunyunbaoURLEnvironment); err != nil { + return Config{}, err + } + shunyunbaoUsername, usernameSet, err := optionalEnvironmentValue( + lookup, + ShunyunbaoUsernameEnvironment, + ) + if err != nil { + return Config{}, err + } + shunyunbaoPassword, passwordSet, err := optionalSecretEnvironmentValue( + lookup, + ShunyunbaoPasswordEnvironment, + ) + if err != nil { + return Config{}, err + } + if usernameSet != passwordSet { + return Config{}, errors.New( + ShunyunbaoUsernameEnvironment + " and " + + ShunyunbaoPasswordEnvironment + " must be set together", + ) + } return Config{ HTTPAddress: httpAddress, @@ -184,6 +223,10 @@ func Load(lookup LookupEnvironment) (Config, error) { ERPConnectorURL: strings.TrimRight(erpConnectorURL, "/"), ERPConnectorAPIKey: erpConnectorAPIKey, ERPConnectorTimeout: 90 * time.Second, + ShunyunbaoURL: strings.TrimRight(shunyunbaoURL, "/"), + ShunyunbaoUsername: shunyunbaoUsername, + ShunyunbaoPassword: shunyunbaoPassword, + ShunyunbaoTimeout: 30 * time.Second, }, nil } @@ -214,6 +257,18 @@ func validateLoopbackURL(value string) error { return nil } +func validateHTTPSOrigin(value, environment string) error { + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || + parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || + (parsed.Path != "" && parsed.Path != "/") { + return errors.New( + environment + " must be an https origin without credentials or path", + ) + } + return nil +} + func durationEnvironment( lookup LookupEnvironment, name string, @@ -261,6 +316,20 @@ func optionalEnvironmentValue( return value, true, nil } +func optionalSecretEnvironmentValue( + lookup LookupEnvironment, + name string, +) (string, bool, error) { + value, exists := lookup(name) + if !exists { + return "", false, nil + } + if value == "" || strings.ContainsRune(value, '\x00') { + return "", false, errors.New(name + " must not be blank") + } + return value, true, nil +} + func isLoopbackAddress(address string) bool { host, _, err := net.SplitHostPort(address) if err != nil { diff --git a/backend-api/internal/config/config_test.go b/backend-api/internal/config/config_test.go index cc1b2f9..c9b6288 100644 --- a/backend-api/internal/config/config_test.go +++ b/backend-api/internal/config/config_test.go @@ -52,6 +52,17 @@ func TestLoadUsesSafeDefaults(t *testing.T) { cfg.ERPConnectorTimeout, ) } + if cfg.ShunyunbaoURL != "https://www.shunyunbaoerp.com" || + cfg.ShunyunbaoUsername != "" || cfg.ShunyunbaoPassword != "" || + cfg.ShunyunbaoTimeout != 30*time.Second { + t.Fatalf( + "shunyunbao defaults = %q / %q / %q / %s", + cfg.ShunyunbaoURL, + cfg.ShunyunbaoUsername, + cfg.ShunyunbaoPassword, + cfg.ShunyunbaoTimeout, + ) + } } func TestLoadAcceptsExplicitConfiguration(t *testing.T) { @@ -66,6 +77,9 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) { ReadinessTTLEnvironment: "3m", ERPConnectorURLEnvironment: "http://localhost:18091", ERPConnectorAPIKeyEnvironment: "12345678901234567890123456789012", + ShunyunbaoURLEnvironment: "https://erp.example.test:8443", + ShunyunbaoUsernameEnvironment: "service-user", + ShunyunbaoPasswordEnvironment: " pass with spaces ", } cfg, err := Load(mapEnvironment(values)) @@ -108,6 +122,11 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) { cfg.ERPConnectorAPIKey, ) } + if cfg.ShunyunbaoURL != values[ShunyunbaoURLEnvironment] || + cfg.ShunyunbaoUsername != values[ShunyunbaoUsernameEnvironment] || + cfg.ShunyunbaoPassword != values[ShunyunbaoPasswordEnvironment] { + t.Fatalf("shunyunbao config was not preserved") + } } func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) { @@ -133,6 +152,30 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) { ERPConnectorAPIKeyEnvironment: "short", }, }, + { + name: "non HTTPS shunyunbao URL", + values: map[string]string{ + ShunyunbaoURLEnvironment: "http://erp.example.test", + }, + }, + { + name: "shunyunbao URL path", + values: map[string]string{ + ShunyunbaoURLEnvironment: "https://erp.example.test/private", + }, + }, + { + name: "shunyunbao username without password", + values: map[string]string{ + ShunyunbaoUsernameEnvironment: "service-user", + }, + }, + { + name: "shunyunbao password without username", + values: map[string]string{ + ShunyunbaoPasswordEnvironment: "password", + }, + }, { name: "blank explicit address", values: map[string]string{ diff --git a/backend-api/internal/platform/shunyunbao/session.go b/backend-api/internal/platform/shunyunbao/session.go new file mode 100644 index 0000000..4746bfd --- /dev/null +++ b/backend-api/internal/platform/shunyunbao/session.go @@ -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 +} diff --git a/backend-api/internal/platform/shunyunbao/session_test.go b/backend-api/internal/platform/shunyunbao/session_test.go new file mode 100644 index 0000000..32354c1 --- /dev/null +++ b/backend-api/internal/platform/shunyunbao/session_test.go @@ -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) + } +} diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go index 68c2514..34d8055 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers.go +++ b/backend-api/internal/transport/httpapi/admin_handlers.go @@ -11,6 +11,7 @@ import ( "time" "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/platform/shunyunbao" "cmroubao/backend-api/internal/transport/authcommon" "cmroubao/backend-api/internal/usecase" @@ -30,6 +31,7 @@ type AdminServices struct { Authorizations *usecase.OrderAuthorizationService Freight *usecase.FreightService Procurement *usecase.ProcurementService + ERP *shunyunbao.SessionManager } func (s AdminServices) validate() error { @@ -87,6 +89,9 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error { handler.createProcurementTask, ) } + if services.ERP != nil { + registerERPAdminAPI(routes, handler) + } return nil } diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index 68640a1..d93f4da 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers_test.go +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -23,6 +23,7 @@ import ( "cmroubao/backend-api/internal/platform/assetstore" "cmroubao/backend-api/internal/platform/database" "cmroubao/backend-api/internal/platform/migration" + "cmroubao/backend-api/internal/platform/shunyunbao" repository "cmroubao/backend-api/internal/repository/sqlite" "cmroubao/backend-api/internal/usecase" @@ -60,6 +61,100 @@ func TestCandidateDecisionDatasetResponseIncludesPersistentIdentity(t *testing.T } } +func TestERPAdminAPIUsesCaptchaTicketWithoutExposingCredentials(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, + request *http.Request, + ) { + switch request.URL.Path { + case shunyunbao.CaptchaPath: + http.SetCookie(writer, &http.Cookie{Name: "erp", Value: "captcha", Path: "/"}) + writer.Header().Set("Content-Type", "image/png") + _, _ = writer.Write([]byte("captcha-image")) + case shunyunbao.LoginPath: + if _, err := request.Cookie("erp"); err != nil { + t.Fatalf("login did not retain captcha cookie: %v", err) + } + http.SetCookie(writer, &http.Cookie{Name: "erp", Value: "login", Path: "/"}) + _, _ = writer.Write([]byte(`{"status":true,"data":{"user":{"id":12},"token":"private-token"}}`)) + case shunyunbao.UserPath: + if cookie, err := request.Cookie("erp"); err != nil || cookie.Value != "login" { + t.Fatalf("user session cookie = %v / %v", cookie, err) + } + _, _ = writer.Write([]byte(`{"status":true,"data":{"id":12}}`)) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + manager, err := shunyunbao.NewSessionManager(shunyunbao.SessionConfig{ + BaseURL: server.URL, + Username: "private-user", + Password: "private-password", + Timeout: time.Second, + AllowInsecureHTTP: true, + }) + if err != nil { + t.Fatalf("NewSessionManager() error = %v", err) + } + gin.SetMode(gin.TestMode) + router := gin.New() + registerERPAdminAPI(router, &adminHandlers{services: AdminServices{ERP: manager}}) + + status := performERPRequest(t, router, http.MethodGet, "/api/v1/erp-session", nil, "") + if status.Code != http.StatusOK || + strings.Contains(status.Body.String(), "private-user") || + strings.Contains(status.Body.String(), "private-password") { + t.Fatalf("status response = %d / %s", status.Code, status.Body) + } + captcha := performERPRequest( + t, + router, + http.MethodPost, + "/api/v1/erp-session/captcha", + nil, + "", + ) + if captcha.Code != http.StatusOK || + strings.Contains(captcha.Body.String(), "private-password") { + t.Fatalf("captcha response = %d / %s", captcha.Code, captcha.Body) + } + var captchaBody map[string]any + decodeResponse(t, captcha, &captchaBody) + ticket, _ := captchaBody["captcha_ticket"].(string) + if len(ticket) != 43 || responseContainsKey(captchaBody["session"], "captcha_ticket") { + t.Fatalf("captcha response body = %#v", captchaBody) + } + image := performERPRequest( + t, + router, + http.MethodGet, + "/api/v1/erp-session/captcha/"+ticket, + nil, + "", + ) + if image.Code != http.StatusOK || image.Header().Get("Cache-Control") != "no-store" || + image.Body.String() != "captcha-image" { + t.Fatalf("captcha image = %d / %q / %s", image.Code, image.Header(), image.Body) + } + login := performERPRequest( + t, + router, + http.MethodPost, + "/api/v1/erp-session/login", + strings.NewReader(`{"captcha_ticket":"`+ticket+`","captcha_code":"1234"}`), + "application/json", + ) + if login.Code != http.StatusOK || + strings.Contains(login.Body.String(), "private-token") || + strings.Contains(login.Body.String(), "private-password") { + t.Fatalf("login response = %d / %s", login.Code, login.Body) + } + if !strings.Contains(login.Body.String(), `"authenticated":true`) { + t.Fatalf("login does not report authenticated state: %s", login.Body) + } +} + func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) { router := newAdminIntegrationRouter(t) imageBody, imageContentType := referenceUpload(t, "asset-key-1") @@ -1306,6 +1401,23 @@ func performAdminRequest( return response } +func performERPRequest( + t *testing.T, + router http.Handler, + method, target string, + body io.Reader, + contentType string, +) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, target, body) + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + func decodeResponse( t *testing.T, response *httptest.ResponseRecorder, diff --git a/backend-api/internal/transport/httpapi/auth_handlers.go b/backend-api/internal/transport/httpapi/auth_handlers.go index 6275a58..593a0a0 100644 --- a/backend-api/internal/transport/httpapi/auth_handlers.go +++ b/backend-api/internal/transport/httpapi/auth_handlers.go @@ -233,7 +233,8 @@ func denyAdminSession(ctx *gin.Context) { (next != "/tasks" && !strings.HasPrefix(next, "/tasks?") && !strings.HasPrefix(next, "/tasks/") && next != "/freight" && !strings.HasPrefix(next, "/freight?") && - !strings.HasPrefix(next, "/freight/")) { + !strings.HasPrefix(next, "/freight/") && + next != "/erp" && !strings.HasPrefix(next, "/erp?")) { next = "/tasks" } ctx.Abort() diff --git a/backend-api/internal/transport/httpapi/erp_handlers.go b/backend-api/internal/transport/httpapi/erp_handlers.go new file mode 100644 index 0000000..8eb6885 --- /dev/null +++ b/backend-api/internal/transport/httpapi/erp_handlers.go @@ -0,0 +1,166 @@ +package httpapi + +import ( + "errors" + "net/http" + "strconv" + "strings" + "unicode/utf8" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/platform/shunyunbao" + + "github.com/gin-gonic/gin" +) + +func registerERPAdminAPI(routes gin.IRoutes, handler *adminHandlers) { + routes.GET("/api/v1/erp-session", handler.erpSessionStatus) + routes.POST("/api/v1/erp-session/captcha", handler.createERPCaptcha) + routes.GET( + "/api/v1/erp-session/captcha/:ticket", + handler.erpCaptchaContent, + ) + routes.POST("/api/v1/erp-session/login", handler.loginERP) +} + +func (h *adminHandlers) erpSessionStatus(ctx *gin.Context) { + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, erpSessionResponse(h.services.ERP.Status())) +} + +func (h *adminHandlers) createERPCaptcha(ctx *gin.Context) { + status, err := h.services.ERP.FetchCaptcha(ctx.Request.Context()) + if err != nil { + writeERPError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{ + "session": erpSessionResponse(status), + "captcha_ticket": status.CaptchaTicket, + "captcha_url": "/api/v1/erp-session/captcha/" + status.CaptchaTicket, + }) +} + +func (h *adminHandlers) erpCaptchaContent(ctx *gin.Context) { + ticket := strings.TrimSpace(ctx.Param("ticket")) + if !validERPTicket(ticket) { + writeERPError(ctx, shunyunbao.ErrCaptchaTicketInvalid) + return + } + image, err := h.services.ERP.OpenCaptcha(ticket) + if err != nil || !strings.HasPrefix(image.ContentType, "image/") || + len(image.Content) == 0 { + writeERPError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.Header("Content-Type", image.ContentType) + ctx.Header("Content-Length", strconv.Itoa(len(image.Content))) + ctx.Header("Content-Disposition", "inline") + ctx.Data(http.StatusOK, image.ContentType, image.Content) +} + +func (h *adminHandlers) loginERP(ctx *gin.Context) { + if !hasMediaType(ctx, "application/json") { + writePublicError( + ctx, + http.StatusUnsupportedMediaType, + "UNSUPPORTED_MEDIA_TYPE", + "application/json is required", + false, + gin.H{}, + ) + return + } + var request struct { + CaptchaTicket string `json:"captcha_ticket"` + CaptchaCode string `json:"captcha_code"` + } + if err := decodeJSON(ctx, &request); err != nil || + !validERPTicket(request.CaptchaTicket) || + !validERPCaptchaCode(request.CaptchaCode) { + writePublicError( + ctx, + http.StatusBadRequest, + "ERP_LOGIN_INVALID", + "ERP login request is invalid", + false, + gin.H{}, + ) + return + } + status, err := h.services.ERP.Login( + ctx.Request.Context(), + request.CaptchaTicket, + request.CaptchaCode, + ) + if err != nil { + writeERPError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, erpSessionResponse(status)) +} + +func erpSessionResponse(status shunyunbao.SessionStatus) gin.H { + return gin.H{ + "configured": status.Configured, + "authenticated": status.Authenticated, + "captcha_ready": status.CaptchaReady, + } +} + +func validERPTicket(value string) bool { + value = strings.TrimSpace(value) + return len(value) == 43 && utf8.ValidString(value) && + !strings.ContainsAny(value, " \t\r\n") +} + +func validERPCaptchaCode(value string) bool { + value = strings.TrimSpace(value) + if value == "" || len([]byte(value)) > 64 || !utf8.ValidString(value) { + return false + } + for _, character := range value { + if character < 32 || character == 127 { + return false + } + } + return true +} + +func writeERPError(ctx *gin.Context, err error) { + status := http.StatusInternalServerError + code := "ERP_INTERNAL_ERROR" + message := "ERP connection operation failed" + retryable := false + switch { + case errors.Is(err, domain.ErrFreightSourceNotConfigured): + status = http.StatusUnprocessableEntity + code = "ERP_NOT_CONFIGURED" + message = "ERP credentials are not configured" + case errors.Is(err, domain.ErrFreightSourceSessionNeeded): + status = http.StatusConflict + code = "ERP_SESSION_REQUIRED" + message = "ERP session is required" + case errors.Is(err, shunyunbao.ErrCaptchaTicketInvalid): + status = http.StatusConflict + code = "ERP_CAPTCHA_INVALID" + message = "ERP captcha must be requested again" + case errors.Is(err, shunyunbao.ErrLoginRejected): + status = http.StatusUnprocessableEntity + code = "ERP_LOGIN_REJECTED" + message = "ERP login was rejected" + case errors.Is(err, domain.ErrFreightSourceProtocol): + status = http.StatusBadGateway + code = "ERP_RESPONSE_INVALID" + message = "ERP response is invalid" + case errors.Is(err, domain.ErrFreightSourceUnavailable): + status = http.StatusServiceUnavailable + code = "ERP_UNAVAILABLE" + message = "ERP is temporarily unavailable" + retryable = true + } + writePublicError(ctx, status, code, message, retryable, gin.H{}) +} diff --git a/backend-api/internal/transport/webui/auth_handler.go b/backend-api/internal/transport/webui/auth_handler.go index e509ca5..a4c2aa7 100644 --- a/backend-api/internal/transport/webui/auth_handler.go +++ b/backend-api/internal/transport/webui/auth_handler.go @@ -269,7 +269,8 @@ func safeNext(value string) string { if parsed.Path != "/tasks" && !strings.HasPrefix(parsed.Path, "/tasks/") && parsed.Path != "/freight" && - !strings.HasPrefix(parsed.Path, "/freight/") { + !strings.HasPrefix(parsed.Path, "/freight/") && + parsed.Path != "/erp" { return "/tasks" } return parsed.String() diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go index 59ce058..d62a84d 100644 --- a/backend-api/internal/transport/webui/handler.go +++ b/backend-api/internal/transport/webui/handler.go @@ -77,6 +77,12 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) { routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport) routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail) } + if _, ok := h.service.(ERPConnectionService); ok { + routes.GET("/erp", SecurityHeaders(), h.ERPConnection) + routes.POST("/erp/captcha", SecurityHeaders(), h.RequestERPCaptcha) + routes.GET("/erp/captcha/:ticket", SecurityHeaders(), h.ERPCaptchaImage) + routes.POST("/erp/login", SecurityHeaders(), h.LoginERP) + } if _, ok := h.service.(ProcurementService); ok { routes.POST( "/freight/items/:id/procurement-request", @@ -96,6 +102,195 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) { } } +func (h *Handler) ERPConnection(ctx *gin.Context) { + service := h.service.(ERPConnectionService) + status, err := service.ERPConnectionStatus(ctx.Request.Context()) + if err != nil { + h.renderERPConnection( + ctx, + http.StatusServiceUnavailable, + ERPConnectionStatus{}, + "ERP 连接状态暂时无法读取。", + "", + ) + return + } + h.renderERPConnection( + ctx, + http.StatusOK, + status, + "", + erpConnectionNotice(ctx.Query("notice")), + ) +} + +func (h *Handler) RequestERPCaptcha(ctx *gin.Context) { + ctx.Request.Body = http.MaxBytesReader( + ctx.Writer, + ctx.Request.Body, + maxLoginFormBytes, + ) + if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) { + h.renderError(ctx, http.StatusForbidden, "请求已失效", "请刷新 ERP 连接页面后重试。") + return + } + service := h.service.(ERPConnectionService) + status, err := service.RequestERPCaptcha(ctx.Request.Context()) + if err != nil { + h.renderERPConnection( + ctx, + erpConnectionErrorStatus(err), + status, + erpConnectionErrorMessage(err), + "", + ) + return + } + ctx.Redirect(http.StatusSeeOther, "/erp?notice=captcha-ready") +} + +func (h *Handler) ERPCaptchaImage(ctx *gin.Context) { + ticket := strings.TrimSpace(ctx.Param("ticket")) + if !validToken(ticket) { + ctx.Status(http.StatusNotFound) + return + } + service := h.service.(ERPConnectionService) + image, err := service.OpenERPCaptcha(ctx.Request.Context(), ticket) + if err != nil || !strings.HasPrefix(image.ContentType, "image/") || + len(image.Content) == 0 { + ctx.Status(http.StatusNotFound) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.Header("Content-Type", image.ContentType) + ctx.Header("Content-Length", strconv.Itoa(len(image.Content))) + ctx.Header("Content-Disposition", "inline") + ctx.Data(http.StatusOK, image.ContentType, image.Content) +} + +func (h *Handler) LoginERP(ctx *gin.Context) { + ctx.Request.Body = http.MaxBytesReader( + ctx.Writer, + ctx.Request.Body, + maxLoginFormBytes, + ) + if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) { + h.renderError(ctx, http.StatusForbidden, "请求已失效", "请刷新 ERP 连接页面后重试。") + return + } + ticket := strings.TrimSpace(ctx.PostForm("captcha_ticket")) + code := strings.TrimSpace(ctx.PostForm("captcha_code")) + service := h.service.(ERPConnectionService) + if !validToken(ticket) || !validERPCaptchaCode(code) { + status, _ := service.ERPConnectionStatus(ctx.Request.Context()) + h.renderERPConnection( + ctx, + http.StatusUnprocessableEntity, + status, + "请重新获取验证码后输入验证码。", + "", + ) + return + } + status, err := service.LoginERP(ctx.Request.Context(), ERPLoginInput{ + CaptchaTicket: ticket, + CaptchaCode: code, + }) + if err != nil { + h.renderERPConnection( + ctx, + erpConnectionErrorStatus(err), + status, + erpConnectionErrorMessage(err), + "", + ) + return + } + ctx.Redirect(http.StatusSeeOther, "/erp?notice=login-succeeded") +} + +func (h *Handler) renderERPConnection( + ctx *gin.Context, + statusCode int, + status ERPConnectionStatus, + errorMessage, notice string, +) { + token, err := csrfToken(ctx) + if err != nil { + h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") + return + } + h.render(ctx, statusCode, "erp-connection", erpConnectionPage{ + Page: pageView{ + Title: "ERP 连接", + ERPCurrent: true, + CSRFToken: token, + }, + Status: status, + Error: errorMessage, + Notice: notice, + }) +} + +func validERPCaptchaCode(value string) bool { + if value == "" || len([]byte(value)) > 64 || !utf8.ValidString(value) { + return false + } + for _, character := range value { + if character < 32 || character == 127 { + return false + } + } + return true +} + +func erpConnectionErrorStatus(err error) int { + switch { + case errors.Is(err, ErrERPNotConfigured): + return http.StatusUnprocessableEntity + case errors.Is(err, ErrERPSessionNeeded), + errors.Is(err, ErrERPCaptchaInvalid): + return http.StatusConflict + case errors.Is(err, ErrERPLoginRejected): + return http.StatusUnprocessableEntity + case errors.Is(err, ErrERPProtocol): + return http.StatusBadGateway + case errors.Is(err, ErrUnavailable): + return http.StatusServiceUnavailable + default: + return http.StatusInternalServerError + } +} + +func erpConnectionErrorMessage(err error) string { + switch { + case errors.Is(err, ErrERPNotConfigured): + return "ERP 服务账号尚未在后端启动环境中配置。" + case errors.Is(err, ErrERPSessionNeeded), errors.Is(err, ErrERPCaptchaInvalid): + return "验证码已失效,请重新获取后再登录。" + case errors.Is(err, ErrERPLoginRejected): + return "验证码不正确或 ERP 拒绝登录,请重新获取验证码后重试。" + case errors.Is(err, ErrERPProtocol): + return "ERP 返回格式无法确认,请稍后重试。" + case errors.Is(err, ErrUnavailable): + return "ERP 暂时不可用,请稍后重试。" + default: + return "ERP 连接操作失败,请稍后重试。" + } +} + +func erpConnectionNotice(value string) string { + switch value { + case "captcha-ready": + return "验证码已获取,请人工读取并提交。" + case "login-succeeded": + return "ERP 会话已建立,可以返回货运导入。" + default: + return "" + } +} + func (h *Handler) ListFreight(ctx *gin.Context) { service := h.service.(FreightService) orders, err := service.ListFreightOrders( @@ -1087,6 +1282,7 @@ type pageView struct { TasksCurrent bool NewCurrent bool FreightCurrent bool + ERPCurrent bool CSRFToken string } @@ -1113,6 +1309,13 @@ type freightDetailPage struct { Notice string } +type erpConnectionPage struct { + Page pageView + Status ERPConnectionStatus + Error string + Notice string +} + type statusOption struct { Value string Label string diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go index 344c7bb..b12ac15 100644 --- a/backend-api/internal/transport/webui/handler_test.go +++ b/backend-api/internal/transport/webui/handler_test.go @@ -1095,6 +1095,105 @@ func TestFreightSyncToNowIgnoresPrefilledManualDates(t *testing.T) { } } +func TestERPConnectionPageUsesCaptchaOnlyAndPreservesNoCredentials(t *testing.T) { + ticket := mustToken(t) + service := &fakeERPService{ + fakeService: &fakeService{}, + status: ERPConnectionStatus{ + Configured: true, + }, + image: ERPCaptchaImage{ + Content: []byte("captcha-image"), + ContentType: "image/png", + }, + ticket: ticket, + } + router := newTestRouter(t, service) + page := performRequest(t, router, http.MethodGet, "/erp", nil, "") + if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "获取验证码") || + strings.Contains(page.Body.String(), "private-password") { + t.Fatalf("ERP page = %d / %s", page.Code, page.Body) + } + assertSecurityHeaders(t, page) + cookie := csrfCookie(t, page) + values := url.Values{"csrf_token": {cookie.Value}} + request := httptest.NewRequest( + http.MethodPost, + "/erp/captcha", + strings.NewReader(values.Encode()), + ) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(cookie) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusSeeOther || + response.Header().Get("Location") != "/erp?notice=captcha-ready" || + service.captchaRequests != 1 { + t.Fatalf("captcha response/calls = %d / %q / %d", response.Code, response.Header().Get("Location"), service.captchaRequests) + } + service.status.CaptchaReady = true + service.status.CaptchaTicket = ticket + page = performRequest(t, router, http.MethodGet, "/erp", nil, "") + if page.Code != http.StatusOK || + !strings.Contains(page.Body.String(), "/erp/captcha/"+ticket) || + !strings.Contains(page.Body.String(), `name="captcha_code"`) || + strings.Contains(page.Body.String(), "password") { + t.Fatalf("captcha page = %d / %s", page.Code, page.Body) + } + image := performRequest(t, router, http.MethodGet, "/erp/captcha/"+ticket, nil, "") + if image.Code != http.StatusOK || image.Header().Get("Cache-Control") != "no-store" || + image.Body.String() != "captcha-image" { + t.Fatalf("captcha image = %d / %q / %s", image.Code, image.Header(), image.Body) + } + cookie = csrfCookie(t, page) + values = url.Values{ + "csrf_token": {cookie.Value}, + "captcha_ticket": {ticket}, + "captcha_code": {"1234"}, + } + request = httptest.NewRequest( + http.MethodPost, + "/erp/login", + strings.NewReader(values.Encode()), + ) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(cookie) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusSeeOther || + response.Header().Get("Location") != "/erp?notice=login-succeeded" || + service.login.CaptchaTicket != ticket || service.login.CaptchaCode != "1234" { + t.Fatalf("login response/input = %d / %q / %+v", response.Code, response.Header().Get("Location"), service.login) + } + service.status = ERPConnectionStatus{ + Configured: true, + CaptchaReady: true, + CaptchaTicket: ticket, + } + service.err = ErrERPLoginRejected + page = performRequest(t, router, http.MethodGet, "/erp", nil, "") + cookie = csrfCookie(t, page) + values = url.Values{ + "csrf_token": {cookie.Value}, + "captcha_ticket": {ticket}, + "captcha_code": {"1234"}, + } + request = httptest.NewRequest( + http.MethodPost, + "/erp/login", + strings.NewReader(values.Encode()), + ) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(cookie) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusUnprocessableEntity || + !strings.Contains(response.Body.String(), "验证码不正确或 ERP 拒绝登录") || + strings.Contains(response.Body.String(), "private") { + t.Fatalf("rejected login page = %d / %s", response.Code, response.Body) + } +} + func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) { const itemID = "00000000-0000-4000-8000-000000000002" service := &fakeProcurementService{ @@ -1206,6 +1305,51 @@ type fakeFreightService struct { err error } +type fakeERPService struct { + *fakeService + status ERPConnectionStatus + image ERPCaptchaImage + ticket string + captchaRequests int + login ERPLoginInput + err error +} + +func (service *fakeERPService) ERPConnectionStatus( + context.Context, +) (ERPConnectionStatus, error) { + return service.status, service.err +} + +func (service *fakeERPService) RequestERPCaptcha( + context.Context, +) (ERPConnectionStatus, error) { + service.captchaRequests++ + service.status.CaptchaReady = true + service.status.CaptchaTicket = service.ticket + return service.status, service.err +} + +func (service *fakeERPService) OpenERPCaptcha( + context.Context, + string, +) (ERPCaptchaImage, error) { + return service.image, service.err +} + +func (service *fakeERPService) LoginERP( + _ context.Context, + input ERPLoginInput, +) (ERPConnectionStatus, error) { + service.login = input + if service.err == nil { + service.status.Authenticated = true + service.status.CaptchaReady = false + service.status.CaptchaTicket = "" + } + return service.status, service.err +} + type fakeProcurementService struct { *fakeFreightService createRequestInput CreateProcurementRequestInput diff --git a/backend-api/internal/transport/webui/static/admin.css b/backend-api/internal/transport/webui/static/admin.css index 4ed46a7..17202cf 100644 --- a/backend-api/internal/transport/webui/static/admin.css +++ b/backend-api/internal/transport/webui/static/admin.css @@ -258,6 +258,20 @@ select { min-height: 44px; } +.erp-captcha-image { + display: block; + width: 180px; + height: 64px; + margin: 12px 0; + border: 1px solid var(--line-strong); + background: var(--surface); + object-fit: contain; +} + +.compact-form { + margin-top: 12px; +} + a, button, input, diff --git a/backend-api/internal/transport/webui/templates/erp-connection.gohtml b/backend-api/internal/transport/webui/templates/erp-connection.gohtml new file mode 100644 index 0000000..b89db9c --- /dev/null +++ b/backend-api/internal/transport/webui/templates/erp-connection.gohtml @@ -0,0 +1,56 @@ +{{define "erp-connection"}} + + +
+建立本次服务进程内的顺运宝会话
+当前后端进程持有受控 ERP 会话。重启后需要重新获取验证码登录。
+请在后端启动环境中配置顺运宝账号和密码后重启服务。
+