feat(t226): add Go ERP session login

This commit is contained in:
QiuSW
2026-07-29 09:51:41 +08:00
parent c2f340d067
commit a5a61c05d0
23 changed files with 1633 additions and 33 deletions
+12
View File
@@ -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,
)
+7 -5
View File
@@ -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)
+69
View File
@@ -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 {
@@ -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{
@@ -0,0 +1,402 @@
package shunyunbao
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"cmroubao/backend-api/internal/domain"
)
const (
defaultSessionTimeout = 30 * time.Second
defaultCaptchaTTL = 5 * time.Minute
maxCaptchaBytes = 2 << 20
maxERPResponseBytes = 4 << 20
)
var (
ErrCaptchaTicketInvalid = errors.New("ERP captcha ticket is invalid")
ErrLoginRejected = errors.New("ERP login was rejected")
)
type SessionConfig struct {
BaseURL string
Username string
Password string
Timeout time.Duration
CaptchaTTL time.Duration
AllowInsecureHTTP bool // Used only by isolated httptest contracts.
}
type SessionStatus struct {
Configured bool
Authenticated bool
CaptchaReady bool
CaptchaTicket string
}
type CaptchaImage struct {
Content []byte
ContentType string
}
type SessionManager struct {
mu sync.Mutex
baseURL string
username string
password string
timeout time.Duration
captchaTTL time.Duration
headers http.Header
http *http.Client
authenticated bool
captchaTicket string
captchaContent []byte
captchaType string
captchaExpires time.Time
}
func NewSessionManager(config SessionConfig) (*SessionManager, error) {
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
headers, err := RequestHeaders(baseURL)
if err != nil {
return nil, errors.New("shunyunbao session configuration is invalid")
}
parsed, _ := url.Parse(baseURL)
if parsed.Scheme != "https" && !config.AllowInsecureHTTP {
return nil, errors.New("shunyunbao session requires HTTPS")
}
username := strings.TrimSpace(config.Username)
if (username == "") != (config.Password == "") ||
!utf8.ValidString(username) || hasControl(username) ||
strings.ContainsRune(config.Password, '\x00') {
return nil, errors.New("shunyunbao credentials are invalid")
}
timeout := config.Timeout
if timeout <= 0 {
timeout = defaultSessionTimeout
}
captchaTTL := config.CaptchaTTL
if captchaTTL <= 0 {
captchaTTL = defaultCaptchaTTL
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, errors.New("create shunyunbao cookie jar")
}
return &SessionManager{
baseURL: baseURL,
username: username,
password: config.Password,
timeout: timeout,
captchaTTL: captchaTTL,
headers: headers,
http: &http.Client{
Jar: jar,
Timeout: timeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}, nil
}
func (manager *SessionManager) Status() SessionStatus {
manager.mu.Lock()
defer manager.mu.Unlock()
manager.expireCaptchaLocked(time.Now())
return manager.statusLocked()
}
func (manager *SessionManager) FetchCaptcha(
ctx context.Context,
) (SessionStatus, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
if !manager.configuredLocked() {
return manager.statusLocked(), domain.ErrFreightSourceNotConfigured
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
manager.baseURL+CaptchaPath+"?_="+strconv.FormatInt(time.Now().UnixMilli(), 10),
nil,
)
if err != nil {
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
}
manager.applyHeaders(request)
response, err := manager.http.Do(request)
if err != nil {
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return manager.statusLocked(), manager.responseErrorLocked(response.StatusCode)
}
contentType := strings.TrimSpace(
strings.Split(response.Header.Get("Content-Type"), ";")[0],
)
if !strings.HasPrefix(contentType, "image/") {
return manager.statusLocked(), domain.ErrFreightSourceProtocol
}
content, err := readBounded(response.Body, maxCaptchaBytes)
if err != nil || len(content) == 0 {
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
}
ticket, err := newCaptchaTicket()
if err != nil {
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
}
manager.captchaTicket = ticket
manager.captchaContent = content
manager.captchaType = contentType
manager.captchaExpires = time.Now().Add(manager.captchaTTL)
return manager.statusLocked(), nil
}
func (manager *SessionManager) OpenCaptcha(
ticket string,
) (CaptchaImage, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
manager.expireCaptchaLocked(time.Now())
if !manager.captchaMatchesLocked(ticket) {
return CaptchaImage{}, ErrCaptchaTicketInvalid
}
return CaptchaImage{
Content: append([]byte(nil), manager.captchaContent...),
ContentType: manager.captchaType,
}, nil
}
func (manager *SessionManager) Login(
ctx context.Context,
ticket, captchaCode string,
) (SessionStatus, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
if !manager.configuredLocked() {
return manager.statusLocked(), domain.ErrFreightSourceNotConfigured
}
manager.expireCaptchaLocked(time.Now())
if !manager.captchaMatchesLocked(ticket) || !validCaptchaCode(captchaCode) {
return manager.statusLocked(), ErrCaptchaTicketInvalid
}
defer manager.clearCaptchaLocked()
manager.authenticated = false
payload, err := json.Marshal(map[string]string{
"username": manager.username,
"password": manager.password,
"code": strings.TrimSpace(captchaCode),
})
if err != nil {
return manager.statusLocked(), domain.ErrFreightSourceProtocol
}
data, err := manager.requestJSONLocked(
ctx,
http.MethodPost,
LoginPath,
payload,
true,
)
if err != nil {
return manager.statusLocked(), err
}
if !hasUser(data) {
return manager.statusLocked(), domain.ErrFreightSourceProtocol
}
if _, err := manager.validateLocked(ctx); err != nil {
return manager.statusLocked(), err
}
manager.authenticated = true
manager.clearCaptchaLocked()
return manager.statusLocked(), nil
}
func (manager *SessionManager) Validate(
ctx context.Context,
) (SessionStatus, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
if !manager.configuredLocked() {
return manager.statusLocked(), domain.ErrFreightSourceNotConfigured
}
if !manager.authenticated {
return manager.statusLocked(), domain.ErrFreightSourceSessionNeeded
}
if _, err := manager.validateLocked(ctx); err != nil {
return manager.statusLocked(), err
}
return manager.statusLocked(), nil
}
func (manager *SessionManager) validateLocked(ctx context.Context) (any, error) {
data, err := manager.requestJSONLocked(
ctx,
http.MethodGet,
UserPath,
nil,
false,
)
if err != nil {
return nil, err
}
if !hasUser(data) {
return nil, domain.ErrFreightSourceProtocol
}
return data, nil
}
func (manager *SessionManager) requestJSONLocked(
ctx context.Context,
method, path string,
body []byte,
loginRequest bool,
) (any, error) {
var content io.Reader
if body != nil {
content = bytes.NewReader(body)
}
request, err := http.NewRequestWithContext(
ctx,
method,
manager.baseURL+path,
content,
)
if err != nil {
return nil, domain.ErrFreightSourceUnavailable
}
manager.applyHeaders(request)
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := manager.http.Do(request)
if err != nil {
return nil, domain.ErrFreightSourceUnavailable
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
if loginRequest {
return nil, ErrLoginRejected
}
return nil, manager.responseErrorLocked(response.StatusCode)
}
contentBytes, err := readBounded(response.Body, maxERPResponseBytes)
if err != nil {
return nil, domain.ErrFreightSourceUnavailable
}
var envelope struct {
Status bool `json:"status"`
Data json.RawMessage `json:"data"`
}
decoder := json.NewDecoder(bytes.NewReader(contentBytes))
if err := decoder.Decode(&envelope); err != nil || len(envelope.Data) == 0 {
return nil, domain.ErrFreightSourceProtocol
}
if !envelope.Status {
if loginRequest {
return nil, ErrLoginRejected
}
manager.authenticated = false
return nil, domain.ErrFreightSourceSessionNeeded
}
var data any
dataDecoder := json.NewDecoder(bytes.NewReader(envelope.Data))
dataDecoder.UseNumber()
if err := dataDecoder.Decode(&data); err != nil {
return nil, domain.ErrFreightSourceProtocol
}
return data, nil
}
func (manager *SessionManager) applyHeaders(request *http.Request) {
for name, values := range manager.headers {
request.Header[name] = append([]string(nil), values...)
}
}
func (manager *SessionManager) responseErrorLocked(status int) error {
if status == http.StatusUnauthorized || status == http.StatusForbidden {
manager.authenticated = false
return domain.ErrFreightSourceSessionNeeded
}
return domain.ErrFreightSourceUnavailable
}
func (manager *SessionManager) configuredLocked() bool {
return manager.username != "" && manager.password != ""
}
func (manager *SessionManager) statusLocked() SessionStatus {
return SessionStatus{
Configured: manager.configuredLocked(),
Authenticated: manager.authenticated,
CaptchaReady: manager.captchaTicket != "",
CaptchaTicket: manager.captchaTicket,
}
}
func (manager *SessionManager) captchaMatchesLocked(ticket string) bool {
return ticket != "" && ticket == manager.captchaTicket
}
func (manager *SessionManager) expireCaptchaLocked(now time.Time) {
if manager.captchaTicket != "" && !now.Before(manager.captchaExpires) {
manager.clearCaptchaLocked()
}
}
func (manager *SessionManager) clearCaptchaLocked() {
manager.captchaTicket = ""
manager.captchaContent = nil
manager.captchaType = ""
manager.captchaExpires = time.Time{}
}
func hasUser(value any) bool {
data, ok := value.(map[string]any)
if !ok {
return false
}
if user, exists := data["user"]; exists {
_, ok := user.(map[string]any)
return ok
}
return data["id"] != nil || data["username"] != nil
}
func validCaptchaCode(value string) bool {
value = strings.TrimSpace(value)
return value != "" && len([]byte(value)) <= 64 && utf8.ValidString(value) &&
!hasControl(value)
}
func newCaptchaTicket() (string, error) {
value := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, value); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func readBounded(reader io.Reader, maximum int64) ([]byte, error) {
content, err := io.ReadAll(io.LimitReader(reader, maximum+1))
if err != nil || int64(len(content)) > maximum {
return nil, errors.New("response exceeds limit")
}
return content, nil
}
@@ -0,0 +1,194 @@
package shunyunbao
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"cmroubao/backend-api/internal/domain"
)
func TestSessionManagerCaptchaLoginAndValidationShareCookieJar(t *testing.T) {
var captchaCalls, loginCalls, userCalls int
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
switch request.URL.Path {
case CaptchaPath:
captchaCalls++
assertERPHeaders(t, request)
http.SetCookie(writer, &http.Cookie{Name: "captcha", Value: "ready", Path: "/"})
writer.Header().Set("Content-Type", "image/png")
_, _ = writer.Write([]byte("sanitized-captcha-image"))
case LoginPath:
loginCalls++
assertERPHeaders(t, request)
if cookie, err := request.Cookie("captcha"); err != nil || cookie.Value != "ready" {
t.Fatalf("login captcha cookie = %v / %v", cookie, err)
}
content, _ := io.ReadAll(request.Body)
if string(content) != `{"code":"1234","password":"test-password","username":"test-user"}` {
t.Fatalf("login body = %s", content)
}
http.SetCookie(writer, &http.Cookie{Name: "authenticated", Value: "yes", Path: "/"})
_, _ = writer.Write([]byte(`{"status":true,"data":{"user":{"id":12},"token":"never-exposed"}}`))
case UserPath:
userCalls++
if cookie, err := request.Cookie("authenticated"); err != nil || cookie.Value != "yes" {
t.Fatalf("user cookie = %v / %v", cookie, err)
}
_, _ = writer.Write([]byte(`{"status":true,"data":{"id":12}}`))
default:
writer.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
manager := testSessionManager(t, server.URL, "test-user", "test-password")
status, err := manager.FetchCaptcha(context.Background())
if err != nil || !status.Configured || !status.CaptchaReady || status.CaptchaTicket == "" {
t.Fatalf("FetchCaptcha() = %+v, %v", status, err)
}
image, err := manager.OpenCaptcha(status.CaptchaTicket)
if err != nil || image.ContentType != "image/png" ||
string(image.Content) != "sanitized-captcha-image" {
t.Fatalf("OpenCaptcha() = %+v, %v", image, err)
}
status, err = manager.Login(context.Background(), status.CaptchaTicket, "1234")
if err != nil || !status.Authenticated || status.CaptchaReady || status.CaptchaTicket != "" {
t.Fatalf("Login() = %+v, %v", status, err)
}
status, err = manager.Validate(context.Background())
if err != nil || !status.Authenticated || captchaCalls != 1 || loginCalls != 1 || userCalls != 2 {
t.Fatalf("Validate()/calls = %+v, %v / %d %d %d", status, err, captchaCalls, loginCalls, userCalls)
}
}
func TestSessionManagerMapsAnonymousFailuresAndExpiresState(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
switch request.URL.Path {
case CaptchaPath:
writer.Header().Set("Content-Type", "image/jpeg")
_, _ = writer.Write([]byte("captcha"))
case LoginPath:
_, _ = writer.Write([]byte(`{"status":false,"msg":"private rejected response","data":null}`))
case UserPath:
writer.WriteHeader(http.StatusUnauthorized)
_, _ = writer.Write([]byte("private session response"))
}
}))
defer server.Close()
missing := testSessionManager(t, server.URL, "", "")
if _, err := missing.FetchCaptcha(context.Background()); !errors.Is(err, domain.ErrFreightSourceNotConfigured) {
t.Fatalf("missing FetchCaptcha() error = %v", err)
}
manager := testSessionManager(t, server.URL, "test-user", "test-password")
status, err := manager.FetchCaptcha(context.Background())
if err != nil {
t.Fatalf("FetchCaptcha() error = %v", err)
}
_, err = manager.Login(context.Background(), status.CaptchaTicket, "bad")
if !errors.Is(err, ErrLoginRejected) || strings.Contains(err.Error(), "private") {
t.Fatalf("Login() error = %v", err)
}
if manager.Status().Authenticated || manager.Status().CaptchaReady {
t.Fatalf("rejected login state = %+v", manager.Status())
}
if _, err := manager.OpenCaptcha(status.CaptchaTicket); !errors.Is(err, ErrCaptchaTicketInvalid) {
t.Fatalf("OpenCaptcha() after login error = %v", err)
}
manager = testSessionManager(t, server.URL, "test-user", "test-password")
manager.captchaTTL = time.Nanosecond
status, err = manager.FetchCaptcha(context.Background())
if err != nil {
t.Fatalf("short FetchCaptcha() error = %v", err)
}
time.Sleep(time.Millisecond)
if _, err := manager.OpenCaptcha(status.CaptchaTicket); !errors.Is(err, ErrCaptchaTicketInvalid) {
t.Fatalf("expired captcha error = %v", err)
}
manager.authenticated = true
if _, err := manager.Validate(context.Background()); !errors.Is(err, domain.ErrFreightSourceSessionNeeded) ||
manager.Status().Authenticated {
t.Fatalf("expired session error/status = %v / %+v", err, manager.Status())
}
}
func TestSessionManagerSerializesCaptchaRequests(t *testing.T) {
var mutex sync.Mutex
inFlight, maximum := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
if request.URL.Path != CaptchaPath {
writer.WriteHeader(http.StatusNotFound)
return
}
mutex.Lock()
inFlight++
if inFlight > maximum {
maximum = inFlight
}
mutex.Unlock()
time.Sleep(20 * time.Millisecond)
mutex.Lock()
inFlight--
mutex.Unlock()
writer.Header().Set("Content-Type", "image/png")
_, _ = writer.Write([]byte("captcha"))
}))
defer server.Close()
manager := testSessionManager(t, server.URL, "test-user", "test-password")
var group sync.WaitGroup
for range 2 {
group.Add(1)
go func() {
defer group.Done()
if _, err := manager.FetchCaptcha(context.Background()); err != nil {
t.Errorf("FetchCaptcha() error = %v", err)
}
}()
}
group.Wait()
if maximum != 1 {
t.Fatalf("maximum concurrent ERP requests = %d", maximum)
}
}
func testSessionManager(
t *testing.T,
baseURL, username, password string,
) *SessionManager {
t.Helper()
manager, err := NewSessionManager(SessionConfig{
BaseURL: baseURL,
Username: username,
Password: password,
Timeout: time.Second,
AllowInsecureHTTP: true,
})
if err != nil {
t.Fatalf("NewSessionManager() error = %v", err)
}
return manager
}
func assertERPHeaders(t *testing.T, request *http.Request) {
t.Helper()
if request.Header.Get("Accept") != "application/json, text/plain, */*" ||
request.Header.Get("X-Requested-With") != "XMLHttpRequest" ||
request.Header.Get("Origin") == "" || request.Header.Get("Referer") == "" {
t.Fatalf("ERP headers = %#v", request.Header)
}
}
@@ -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
}
@@ -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,
@@ -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()
@@ -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{})
}
@@ -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()
@@ -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
@@ -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
@@ -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,
@@ -0,0 +1,56 @@
{{define "erp-connection"}}
<!doctype html>
<html lang="zh-CN">
<head>
<title>{{.Page.Title}} - 采购任务管理</title>
{{template "document-head" .}}
</head>
<body>
{{template "site-header" .}}
<main id="main-content" class="page narrow-page">
<div class="title-row">
<div>
<h1>ERP 连接</h1>
<p class="subtitle">建立本次服务进程内的顺运宝会话</p>
</div>
<a class="button" href="/freight/import">返回导入</a>
</div>
{{if .Error}}<div class="notice danger" role="alert">{{.Error}}</div>{{end}}
{{if .Notice}}<div class="notice success" role="status">{{.Notice}}</div>{{end}}
{{if .Status.Authenticated}}
<section class="detail-section" aria-labelledby="erp-connected-title">
<h2 id="erp-connected-title">已连接</h2>
<p>当前后端进程持有受控 ERP 会话。重启后需要重新获取验证码登录。</p>
</section>
{{else if not .Status.Configured}}
<section class="detail-section" aria-labelledby="erp-config-title">
<h2 id="erp-config-title">尚未配置</h2>
<p>请在后端启动环境中配置顺运宝账号和密码后重启服务。</p>
</section>
{{else if .Status.CaptchaReady}}
<section class="detail-section" aria-labelledby="erp-captcha-title">
<h2 id="erp-captcha-title">输入验证码</h2>
<img class="erp-captcha-image" src="/erp/captcha/{{pathPart .Status.CaptchaTicket}}"
alt="ERP 验证码" width="180" height="64">
<form class="form-panel compact-form" method="post" action="/erp/login" data-loading-form>
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
<input type="hidden" name="captcha_ticket" value="{{.Status.CaptchaTicket}}">
<div class="field">
<label for="captcha-code">验证码</label>
<input id="captcha-code" name="captcha_code" maxlength="64" autocomplete="one-time-code" required>
</div>
<button class="button primary" type="submit" data-loading-label="正在登录…">登录 ERP</button>
</form>
</section>
{{else}}
<form class="form-panel" method="post" action="/erp/captcha" data-loading-form>
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
<h2>获取验证码</h2>
<p class="secondary">验证码一次有效,仅用于当前后端进程中的 ERP 会话。</p>
<button class="button primary" type="submit" data-loading-label="正在获取…">获取验证码</button>
</form>
{{end}}
</main>
</body>
</html>
{{end}}
@@ -18,6 +18,7 @@
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
<a href="/freight" {{if .Page.FreightCurrent}}aria-current="page"{{end}}>ERP 货运</a>
<a href="/erp" {{if .Page.ERPCurrent}}aria-current="page"{{end}}>ERP 连接</a>
</nav>
{{if .Page.CSRFToken}}
<form class="logout-form" method="post" action="/logout">
+35 -6
View File
@@ -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,
@@ -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)