From a5a61c05d011193fdb99211159ac776c7620abe9 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 29 Jul 2026 09:51:41 +0800 Subject: [PATCH] feat(t226): add Go ERP session login --- backend-api/cmd/api/main.go | 12 + backend-api/cmd/api/main_test.go | 12 +- backend-api/internal/config/config.go | 69 +++ backend-api/internal/config/config_test.go | 43 ++ .../internal/platform/shunyunbao/session.go | 402 ++++++++++++++++++ .../platform/shunyunbao/session_test.go | 194 +++++++++ .../transport/httpapi/admin_handlers.go | 5 + .../transport/httpapi/admin_handlers_test.go | 112 +++++ .../transport/httpapi/auth_handlers.go | 3 +- .../transport/httpapi/erp_handlers.go | 166 ++++++++ .../internal/transport/webui/auth_handler.go | 3 +- .../internal/transport/webui/handler.go | 203 +++++++++ .../internal/transport/webui/handler_test.go | 144 +++++++ .../internal/transport/webui/static/admin.css | 14 + .../webui/templates/erp-connection.gohtml | 56 +++ .../transport/webui/templates/partials.gohtml | 1 + backend-api/internal/transport/webui/types.go | 41 +- .../transport/webui/usecase_adapter.go | 94 ++++ docs/04-architecture.md | 12 +- docs/api.md | 28 ++ docs/current-state.md | 24 +- docs/routes.md | 4 +- docs/tasks/T-226.md | 24 +- 23 files changed, 1633 insertions(+), 33 deletions(-) create mode 100644 backend-api/internal/platform/shunyunbao/session.go create mode 100644 backend-api/internal/platform/shunyunbao/session_test.go create mode 100644 backend-api/internal/transport/httpapi/erp_handlers.go create mode 100644 backend-api/internal/transport/webui/templates/erp-connection.gohtml 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"}} + + + + {{.Page.Title}} - 采购任务管理 + {{template "document-head" .}} + + + {{template "site-header" .}} +
+
+
+

ERP 连接

+

建立本次服务进程内的顺运宝会话

+
+ 返回导入 +
+ {{if .Error}}{{end}} + {{if .Notice}}
{{.Notice}}
{{end}} + {{if .Status.Authenticated}} +
+

已连接

+

当前后端进程持有受控 ERP 会话。重启后需要重新获取验证码登录。

+
+ {{else if not .Status.Configured}} +
+

尚未配置

+

请在后端启动环境中配置顺运宝账号和密码后重启服务。

+
+ {{else if .Status.CaptchaReady}} +
+

输入验证码

+ ERP 验证码 +
+ + +
+ + +
+ +
+
+ {{else}} +
+ +

获取验证码

+

验证码一次有效,仅用于当前后端进程中的 ERP 会话。

+ +
+ {{end}} +
+ + +{{end}} diff --git a/backend-api/internal/transport/webui/templates/partials.gohtml b/backend-api/internal/transport/webui/templates/partials.gohtml index c9ca760..e70a123 100644 --- a/backend-api/internal/transport/webui/templates/partials.gohtml +++ b/backend-api/internal/transport/webui/templates/partials.gohtml @@ -18,6 +18,7 @@ 任务列表 新建任务 ERP 货运 + ERP 连接 {{if .Page.CSRFToken}}
diff --git a/backend-api/internal/transport/webui/types.go b/backend-api/internal/transport/webui/types.go index 4ab6f06..c93a1de 100644 --- a/backend-api/internal/transport/webui/types.go +++ b/backend-api/internal/transport/webui/types.go @@ -8,12 +8,17 @@ import ( ) var ( - ErrNotFound = errors.New("resource not found") - ErrForbidden = errors.New("resource forbidden") - ErrConflict = errors.New("resource conflict") - ErrValidation = errors.New("validation failed") - ErrInvalidFile = errors.New("invalid file") - ErrUnavailable = errors.New("service unavailable") + ErrNotFound = errors.New("resource not found") + ErrForbidden = errors.New("resource forbidden") + ErrConflict = errors.New("resource conflict") + ErrValidation = errors.New("validation failed") + ErrInvalidFile = errors.New("invalid file") + ErrUnavailable = errors.New("service unavailable") + ErrERPNotConfigured = errors.New("ERP is not configured") + ErrERPSessionNeeded = errors.New("ERP session is required") + ErrERPCaptchaInvalid = errors.New("ERP captcha is invalid") + ErrERPLoginRejected = errors.New("ERP login was rejected") + ErrERPProtocol = errors.New("ERP protocol is invalid") ) // Service is the application boundary required by the server-rendered admin UI. @@ -38,6 +43,30 @@ type FreightService interface { ) (FreightSync, error) } +type ERPConnectionService interface { + ERPConnectionStatus(context.Context) (ERPConnectionStatus, error) + RequestERPCaptcha(context.Context) (ERPConnectionStatus, error) + OpenERPCaptcha(context.Context, string) (ERPCaptchaImage, error) + LoginERP(context.Context, ERPLoginInput) (ERPConnectionStatus, error) +} + +type ERPConnectionStatus struct { + Configured bool + Authenticated bool + CaptchaReady bool + CaptchaTicket string +} + +type ERPCaptchaImage struct { + Content []byte + ContentType string +} + +type ERPLoginInput struct { + CaptchaTicket string + CaptchaCode string +} + type ProcurementService interface { CreateProcurementRequest( context.Context, diff --git a/backend-api/internal/transport/webui/usecase_adapter.go b/backend-api/internal/transport/webui/usecase_adapter.go index fd6f685..b642333 100644 --- a/backend-api/internal/transport/webui/usecase_adapter.go +++ b/backend-api/internal/transport/webui/usecase_adapter.go @@ -7,6 +7,7 @@ import ( "strings" "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/platform/shunyunbao" "cmroubao/backend-api/internal/transport/authcommon" "cmroubao/backend-api/internal/usecase" ) @@ -19,6 +20,7 @@ type UsecaseAdapter struct { authorizations *usecase.OrderAuthorizationService freight *usecase.FreightService procurement *usecase.ProcurementService + erp *shunyunbao.SessionManager } func (adapter *UsecaseAdapter) SetProcurement( @@ -27,6 +29,65 @@ func (adapter *UsecaseAdapter) SetProcurement( adapter.procurement = procurement } +func (adapter *UsecaseAdapter) SetERPConnection( + manager *shunyunbao.SessionManager, +) { + adapter.erp = manager +} + +func (adapter *UsecaseAdapter) ERPConnectionStatus( + context.Context, +) (ERPConnectionStatus, error) { + if adapter.erp == nil { + return ERPConnectionStatus{}, ErrUnavailable + } + return erpConnectionStatusFrom(adapter.erp.Status()), nil +} + +func (adapter *UsecaseAdapter) RequestERPCaptcha( + ctx context.Context, +) (ERPConnectionStatus, error) { + if adapter.erp == nil { + return ERPConnectionStatus{}, ErrUnavailable + } + status, err := adapter.erp.FetchCaptcha(ctx) + if err != nil { + return erpConnectionStatusFrom(status), mapERPError(err) + } + return erpConnectionStatusFrom(status), nil +} + +func (adapter *UsecaseAdapter) OpenERPCaptcha( + _ context.Context, + ticket string, +) (ERPCaptchaImage, error) { + if adapter.erp == nil { + return ERPCaptchaImage{}, ErrUnavailable + } + image, err := adapter.erp.OpenCaptcha(ticket) + if err != nil { + return ERPCaptchaImage{}, mapERPError(err) + } + return ERPCaptchaImage{ + Content: image.Content, + ContentType: image.ContentType, + }, nil +} + +func (adapter *UsecaseAdapter) LoginERP( + ctx context.Context, + input ERPLoginInput, +) (ERPConnectionStatus, error) { + if adapter.erp == nil { + return ERPConnectionStatus{}, ErrUnavailable + } + status, err := adapter.erp.Login(ctx, input.CaptchaTicket, input.CaptchaCode) + if err != nil { + return erpConnectionStatusFrom(status), mapERPError(err) + } + return erpConnectionStatusFrom(status), nil +} + func NewUsecaseAdapter( tasks *usecase.TaskService, assets *usecase.AssetService, @@ -840,6 +901,38 @@ func mapUsecaseError(err error) error { } } +func mapERPError(err error) error { + var public error + switch { + case errors.Is(err, domain.ErrFreightSourceNotConfigured): + public = ErrERPNotConfigured + case errors.Is(err, domain.ErrFreightSourceSessionNeeded): + public = ErrERPSessionNeeded + case errors.Is(err, shunyunbao.ErrCaptchaTicketInvalid): + public = ErrERPCaptchaInvalid + case errors.Is(err, shunyunbao.ErrLoginRejected): + public = ErrERPLoginRejected + case errors.Is(err, domain.ErrFreightSourceProtocol): + public = ErrERPProtocol + case errors.Is(err, domain.ErrFreightSourceUnavailable): + public = ErrUnavailable + default: + return err + } + return &adapterError{public: public, cause: err} +} + +func erpConnectionStatusFrom( + status shunyunbao.SessionStatus, +) ERPConnectionStatus { + return ERPConnectionStatus{ + Configured: status.Configured, + Authenticated: status.Authenticated, + CaptchaReady: status.CaptchaReady, + CaptchaTicket: status.CaptchaTicket, + } +} + type adapterError struct { public error cause error @@ -856,3 +949,4 @@ func (err *adapterError) Unwrap() []error { var _ Service = (*UsecaseAdapter)(nil) var _ FreightService = (*UsecaseAdapter)(nil) var _ ProcurementService = (*UsecaseAdapter)(nil) +var _ ERPConnectionService = (*UsecaseAdapter)(nil) diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 16e7f77..f091170 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -16,6 +16,8 @@ +--> SQLite / 文件存储 | +--> loopback ERP Connector ----> 顺运宝 ERP + | + +--> Go 内存 ERP 会话 ---------> 顺运宝 ERP ^ | Android 采购 App --------------------> VLM Provider @@ -156,10 +158,12 @@ Admin 创建 sync run 缓存和个人信息处理权限。 T-225 已在 `internal/platform/shunyunbao` 冻结直接 Go 实现的协议常量、请求 header、 -完整单号/日期范围条件、分页、详情批量和 allowlist 归一化;它尚未接入运行时。货运 -用例只识别来源中立的“未配置、会话失效、未找到、协议异常、暂时不可用”错误,不依赖 -Connector 包。T-226 负责单进程内受锁保护的验证码会话,T-227 才将异步 sync worker -从 loopback Connector 切换到 Go source,T-228 删除旧 Python 进程和服务密钥。 +完整单号/日期范围条件、分页、详情批量和 allowlist 归一化。T-226 已增加一个受互斥 +保护的 Go 内存会话:Admin 以短期 ticket 读取验证码图片并人工输入验证码,服务端才 +持有 Cookie jar;账号密码只从启动环境读取。进程重启即失去该会话,不用 Redis 或持久化 +Cookie。货运用例只识别来源中立的“未配置、会话失效、未找到、协议异常、暂时不可用” +错误,不依赖 Connector 包。T-227 才将异步 sync worker 从 loopback Connector 切换到 +Go source,T-228 删除旧 Python 进程和服务密钥。 模式在 execution 开始时固定并写入结果;AI 失败后只能由人员明确切换,不能静默降级。 App 同时固定 provider ID、model、prompt/schema version 和证据 SHA-256,作为非秘密 diff --git a/docs/api.md b/docs/api.md index b807e3e..1235d99 100644 --- a/docs/api.md +++ b/docs/api.md @@ -216,6 +216,34 @@ Connector 是 loopback 内部服务,不复用 ADMIN/BUYER 凭证。除 `/healt 不得返回 receiver、receiverTel、receiverAddr、Cookie、JWT、ERP 用户资料或完整原始 对象。多商品必须全部保留;缺失详情返回协议错误,不允许部分成功。 +### Go ERP 会话(T-226) + +以下接口均要求 ADMIN session;所有 `POST` 还要求现有 `X-CSRF-Token` 与 CSRF +Cookie。它们只管理单个后端进程内的顺运宝会话,不创建货运同步,也不下发 ERP 账号、 +密码、Cookie、JWT、完整用户资料或原始响应。 + +| 接口 | 说明 | +| --- | --- | +| `GET /api/v1/erp-session` | 返回 `configured`、`authenticated`、`captcha_ready` 三个布尔状态。 | +| `POST /api/v1/erp-session/captcha` | 同一服务端 Cookie jar 获取一次验证码,返回短期 `captcha_ticket` 和仅供当前 ADMIN 读取图片的 `captcha_url`。 | +| `GET /api/v1/erp-session/captcha/{ticket}` | 返回 `image/*` 验证码,始终 `Cache-Control: no-store`。ticket 过期或不匹配返回稳定匿名错误。 | +| `POST /api/v1/erp-session/login` | 仅接受 `{"captcha_ticket":"...","captcha_code":"..."}`,使用启动环境中的账号/密码完成登录和用户校验。 | + +成功状态响应不包含 ticket,验证码接口以外也不会返回验证码图片。稳定错误为 +`ERP_NOT_CONFIGURED`、`ERP_SESSION_REQUIRED`、`ERP_CAPTCHA_INVALID`、 +`ERP_LOGIN_REJECTED`、`ERP_RESPONSE_INVALID` 和 `ERP_UNAVAILABLE`;均不包含 ERP +正文或凭证。管理员 Web 的等价入口为 `/erp`、`/erp/captcha` 和 `/erp/login`,表单没有 +账号或密码字段。 + +启动环境变量: + +- `CMROUBAO_SHUNYUNBAO_URL`:默认 `https://www.shunyunbaoerp.com`;只接受无路径、 + userinfo、query 或 fragment 的 HTTPS origin。 +- `CMROUBAO_SHUNYUNBAO_USERNAME` 与 `CMROUBAO_SHUNYUNBAO_PASSWORD`:必须同时设置; + 为空时连接页明确显示未配置,服务进程仍可用于其他本地功能。 + +会话不写 SQLite、不加载 `.env`、不使用 OCR 或 Redis;服务重启后必须重新人工登录。 + ### `POST /api/v1/freight-syncs` ADMIN 创建异步同步记录,必须带 `Idempotency-Key`: diff --git a/docs/current-state.md b/docs/current-state.md index 1cb9cdf..4dfb3ad 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,7 +5,7 @@ ## 当前快照 - 日期:2026-07-29 -- 阶段:T-225 Go 直连 ERP 协议与安全边界已完成;运行时切换待 T-226 至 T-228 +- 阶段:T-226 Go ERP 内存会话与 Admin 人工验证码登录已完成;运行时切换待 T-227 至 T-228 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219 均按文档提交、实现提交的顺序纳入历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 @@ -18,9 +18,11 @@ 完整单号同步;v12 保存同步记录、货运头和全部明细,canonical hash 控制 revision, Admin 已有 `/freight`、`/freight/import`、`/freight/{id}` 与对应 JSON API。 - ERP Go 迁移:T-225 已用脱敏 fixture 固定 `internal/platform/shunyunbao` 的 header、 - 单号/日期查询、分页、详情批量和字段 allowlist,并使货运用例依赖来源中立错误;尚未 - 发起真实 ERP 请求,也未切换 Python Connector 运行时。T-226 将加入单进程人工验证码 - 会话,T-227 才切换异步货运 source,T-228 删除旧 Connector。 + 单号/日期查询、分页、详情批量和字段 allowlist,并使货运用例依赖来源中立错误。T-226 + 已增加受锁保护的 Go 内存 Cookie jar、验证码 ticket、登录和用户校验,以及 ADMIN 的 + `/erp` 页面/API;账号密码仅由 `CMROUBAO_SHUNYUNBAO_*` 启动环境读取,重启后需人工 + 重新登录。未访问真实 ERP,Python Connector 仍是货运同步运行时;T-227 才切换 source, + T-228 删除旧 Connector。 - ERP 增量同步:v14 支持 Asia/Shanghai 创建日期闭区间和“同步至现在”,Connector 单窗最多 7 天,后端对较长水位范围切窗并从成功水位前 10 分钟所在自然日回看。 货运落库、同步成功和水位推进同事务完成;失败与较旧范围成功不推进水位。Admin @@ -32,8 +34,9 @@ - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过; Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110 -- 后端测试:T-225 运行 `go test ./...`、`go test -race ./...`、`go vet ./...` 和三个 Go - 入口构建; +- 后端测试:T-226 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...` 和三个 Go + 入口构建;新增配置、会话并发/过期、ADMIN API、SSR 验证码页面以及隔离 Gin/Chrome + 桌面/手机视口 smoke,均只用伪 ERP 或未配置状态; 覆盖 v14 上下迁移、7 天切窗、水位重叠、中途失败、空窗口、重复页、来源 revision、 水位事务/不回退、Admin API/SSR 和 Connector 严格响应窗口;Python 22 项伪响应 测试通过,未访问真实 ERP @@ -188,11 +191,12 @@ ## 任务摘要 - 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。 -- 已完成:另含 T-220 至 T-225 ERP 契约、Connector、货运存储、采购需求生成、日期 - 增量同步和 Go 直连协议安全边界。 +- 已完成:另含 T-220 至 T-226 ERP 契约、Connector、货运存储、采购需求生成、日期 + 增量同步、Go 直连协议安全边界和人工验证码会话。 - 进行中:无。 -- 下一步:T-226 建立 Go 后端内存会话和 Admin 人工验证码登录;真实 ERP 上线前仍需 - 确认开放 API、数据使用权限并由人员完成验证码登录。 +- 下一步:T-227 将已验证 Go 会话接入 `FreightSource`,保留现有异步单号/日期同步、 + 水位、revision 和采购任务语义;真实 ERP 上线前仍需确认开放 API、数据使用权限并由 + 人员完成验证码登录。 ## 当前可运行内容 diff --git a/docs/routes.md b/docs/routes.md index ca3312a..78709ce 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -14,9 +14,10 @@ T-202 的离线 P0 页面入口见[原型索引](design/index.html)。原型仅 | `/freight` | ERP 货运列表 | 查看已导入货运单并进入全部商品明细 | US-011 | IX-012 | | `/freight/import` | ERP 货运导入 | 创建精确单号、最多 7 天日期范围或“同步至现在”任务,并查看成功水位 | US-011 | IX-012 | | `/freight/{id}` | ERP 货运详情 | 查看全部商品明细、来源变化、补图和采购任务生成状态 | US-011、US-012 | IX-012、IX-013 | +| `/erp` | ERP 连接 | 查看匿名会话状态、获取验证码并人工输入验证码;不显示或编辑 ERP 凭证 | US-011 | IX-012 | MVP 登录后默认进入 `/tasks`。未登录访问受保护页面时跳转 `/login` 并携带安全的 -站内返回路径;只接受 `/tasks`、`/freight` 及其本站子路径,拒绝绝对 URL、`//` +站内返回路径;只接受 `/tasks`、`/freight`、`/erp` 及其本站子路径,拒绝绝对 URL、`//` 和反斜杠。 不存在和无权限必须使用不同内部原因,但页面均不得泄露任务内容。 @@ -91,6 +92,7 @@ T-207 的 events/candidates/complete/fail 接受原设备对授权内已产生 | `PendingPaymentSummary` | 管理 Web/App | 只读展示对账订单与人工付款提醒,不提供付款或重提动作 | | `FreightSyncForm` | 管理 Web | 创建精确单号/日期同步记录、查看成功水位,不持有 ERP 凭证 | | `FreightItemReview` | 管理 Web | 显示规范化来源字段、缺失项和采购任务生成操作 | +| `ERPConnectionPanel` | 管理 Web | 仅展示匿名连接状态、验证码图片和验证码输入,不接收 ERP 账号或密码 | 具体状态反馈以[交互清单](08-interaction-checklist.md)为准,组件命名可在接入真实框架后 调整并同步本文。 diff --git a/docs/tasks/T-226.md b/docs/tasks/T-226.md index 96aed1a..1b4c5b4 100644 --- a/docs/tasks/T-226.md +++ b/docs/tasks/T-226.md @@ -4,7 +4,7 @@ title: Go ERP 会话管理与 Admin 验证码登录 phase: 2 deps: - T-225 -status: TODO +status: DONE created: 2026-07-29 context_ref: d62a4af work_branch: null @@ -48,10 +48,10 @@ write_paths: ## 验收要点 -- [ ] 配置缺失、验证码获取、错误/过期验证码、登录成功、会话过期和并发请求有单元/HTTP 测试。 -- [ ] Admin 页面在桌面和手机视口显示状态、验证码和可修复错误,不显示密码、Cookie、JWT 或验证码文本。 -- [ ] 会话请求和查询共享同一受控 Cookie jar;进程重启后明确要求重新登录。 -- [ ] `go test ./...`、`go vet ./...` 通过;不访问线上 ERP。 +- [x] 配置缺失、验证码获取、错误/过期验证码、登录成功、会话过期和并发请求有单元/HTTP 测试。 +- [x] Admin 页面在桌面和手机视口显示状态、验证码和可修复错误,不显示密码、Cookie、JWT 或验证码文本。 +- [x] 会话请求和查询共享同一受控 Cookie jar;进程重启后明确要求重新登录。 +- [x] `go test ./...`、`go vet ./...` 通过;未访问线上 ERP。 ## 边界 @@ -62,3 +62,17 @@ write_paths: ## 执行记录 - 2026-07-29:由 T-225 依赖创建,等待协议契约完成。 +- 2026-07-29:开始实施;T-225 已由 `c2f340d` 完成,当前仍未访问线上 ERP。 +- 2026-07-29:新增 `SessionManager`,以同一受锁 Cookie jar 完成验证码、登录和 + `/am/user/get` 校验;只保存内存会话,验证码 ticket 默认 5 分钟后失效。账号/密码仅由 + `CMROUBAO_SHUNYUNBAO_*` 环境读取,未配置不会阻止其他后端功能启动。 +- 2026-07-29:新增 ADMIN 的 `/erp` 连接页和 `/api/v1/erp-session*` 接口;所有写接口 + 继续受 ADMIN CSRF middleware 保护。浏览器页面只输入验证码,图片和 ticket 均 `no-store`, + 不返回 ERP Cookie、JWT、用户资料、密码或原始错误正文。 +- 验证:在 `backend-api/` 执行 `$env:GOTOOLCHAIN='local'; go test ./...; go test -race ./...;` + `go vet ./...; go build ./cmd/api; go build ./cmd/migrate; go build ./cmd/authctl`,全部通过。 + 会话/HTTP/SSR 使用 `httptest` 伪 ERP;未访问线上 ERP。 +- UI smoke:隔离 SQLite 上以 `smoke-admin` 登录实际 Gin 服务,Chrome 以 1440x900 和 + 390x844 验证 `/erp` 未配置状态无横向溢出;截图只写入系统临时目录,未进仓库。 +- 未验证项:真实 ERP 凭证、人工验证码和厂商授权检查有意留给部署前人员操作;T-227 + 才把会话接入货运同步 source。