feat(t226): add Go ERP session login
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user