refactor(t228): remove Python ERP connector

This commit is contained in:
QiuSW
2026-07-29 10:14:30 +08:00
parent a822edd1b2
commit 13a1355e0d
28 changed files with 104 additions and 2394 deletions
+43 -100
View File
@@ -19,46 +19,40 @@ const (
ClaimLeaseEnvironment = "CMROUBAO_CLAIM_LEASE"
RunningLeaseEnvironment = "CMROUBAO_RUNNING_LEASE"
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"
defaultAssetDirectory = "var/assets"
defaultClaimLease = 10 * time.Minute
defaultRunningLease = 30 * time.Minute
defaultReadinessTTL = 2 * time.Minute
defaultERPConnectorURL = "http://127.0.0.1:8091"
defaultShunyunbaoURL = "https://www.shunyunbaoerp.com"
defaultHTTPAddress = "127.0.0.1:8080"
defaultDatabasePath = "var/cmroubao.db"
defaultAssetDirectory = "var/assets"
defaultClaimLease = 10 * time.Minute
defaultRunningLease = 30 * time.Minute
defaultReadinessTTL = 2 * time.Minute
defaultShunyunbaoURL = "https://www.shunyunbaoerp.com"
)
type LookupEnvironment func(string) (string, bool)
type Config struct {
HTTPAddress string
DatabasePath string
AssetDirectory string
TLSCertificate string
TLSPrivateKey string
ReadHeaderTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
ShutdownTimeout time.Duration
MaxHeaderBytes int
ClaimLease time.Duration
RunningLease time.Duration
ReadinessTTL time.Duration
ERPConnectorURL string
ERPConnectorAPIKey string
ERPConnectorTimeout time.Duration
ShunyunbaoURL string
ShunyunbaoUsername string
ShunyunbaoPassword string
ShunyunbaoTimeout time.Duration
HTTPAddress string
DatabasePath string
AssetDirectory string
TLSCertificate string
TLSPrivateKey string
ReadHeaderTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
ShutdownTimeout time.Duration
MaxHeaderBytes int
ClaimLease time.Duration
RunningLease time.Duration
ReadinessTTL time.Duration
ShunyunbaoURL string
ShunyunbaoUsername string
ShunyunbaoPassword string
ShunyunbaoTimeout time.Duration
}
func Load(lookup LookupEnvironment) (Config, error) {
@@ -152,27 +146,6 @@ func Load(lookup LookupEnvironment) (Config, error) {
if err != nil {
return Config{}, err
}
erpConnectorURL, err := environmentValue(
lookup,
ERPConnectorURLEnvironment,
defaultERPConnectorURL,
)
if err != nil {
return Config{}, err
}
if err := validateLoopbackURL(erpConnectorURL); err != nil {
return Config{}, err
}
erpConnectorAPIKey := ""
if value, exists := lookup(ERPConnectorAPIKeyEnvironment); exists {
erpConnectorAPIKey = strings.TrimSpace(value)
if len([]byte(erpConnectorAPIKey)) < 32 {
return Config{}, errors.New(
ERPConnectorAPIKeyEnvironment +
" must contain at least 32 UTF-8 bytes",
)
}
}
shunyunbaoURL, err := environmentValue(
lookup,
ShunyunbaoURLEnvironment,
@@ -206,57 +179,27 @@ func Load(lookup LookupEnvironment) (Config, error) {
}
return Config{
HTTPAddress: httpAddress,
DatabasePath: filepath.Clean(databasePath),
AssetDirectory: assetDirectory,
TLSCertificate: cleanOptionalPath(tlsCertificate),
TLSPrivateKey: cleanOptionalPath(tlsPrivateKey),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
ShutdownTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
ClaimLease: claimLease,
RunningLease: runningLease,
ReadinessTTL: readinessTTL,
ERPConnectorURL: strings.TrimRight(erpConnectorURL, "/"),
ERPConnectorAPIKey: erpConnectorAPIKey,
ERPConnectorTimeout: 90 * time.Second,
ShunyunbaoURL: strings.TrimRight(shunyunbaoURL, "/"),
ShunyunbaoUsername: shunyunbaoUsername,
ShunyunbaoPassword: shunyunbaoPassword,
ShunyunbaoTimeout: 30 * time.Second,
HTTPAddress: httpAddress,
DatabasePath: filepath.Clean(databasePath),
AssetDirectory: assetDirectory,
TLSCertificate: cleanOptionalPath(tlsCertificate),
TLSPrivateKey: cleanOptionalPath(tlsPrivateKey),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
ShutdownTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
ClaimLease: claimLease,
RunningLease: runningLease,
ReadinessTTL: readinessTTL,
ShunyunbaoURL: strings.TrimRight(shunyunbaoURL, "/"),
ShunyunbaoUsername: shunyunbaoUsername,
ShunyunbaoPassword: shunyunbaoPassword,
ShunyunbaoTimeout: 30 * time.Second,
}, nil
}
func validateLoopbackURL(value string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "http" || parsed.User != nil ||
parsed.RawQuery != "" || parsed.Fragment != "" ||
(parsed.Path != "" && parsed.Path != "/") {
return errors.New(
ERPConnectorURLEnvironment +
" must be an http loopback origin without credentials or path",
)
}
host := parsed.Hostname()
ip := net.ParseIP(host)
if !strings.EqualFold(host, "localhost") &&
(ip == nil || !ip.IsLoopback()) {
return errors.New(
ERPConnectorURLEnvironment + " must use a loopback host",
)
}
port, err := strconv.Atoi(parsed.Port())
if err != nil || port < 1 || port > 65535 {
return errors.New(
ERPConnectorURLEnvironment + " must include a valid port",
)
}
return nil
}
func validateHTTPSOrigin(value, environment string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" ||
@@ -42,16 +42,6 @@ func TestLoadUsesSafeDefaults(t *testing.T) {
if cfg.ShutdownTimeout > 30*time.Second {
t.Fatalf("ShutdownTimeout = %s", cfg.ShutdownTimeout)
}
if cfg.ERPConnectorURL != "http://127.0.0.1:8091" ||
cfg.ERPConnectorAPIKey != "" ||
cfg.ERPConnectorTimeout != 90*time.Second {
t.Fatalf(
"ERP connector defaults = %q / %q / %s",
cfg.ERPConnectorURL,
cfg.ERPConnectorAPIKey,
cfg.ERPConnectorTimeout,
)
}
if cfg.ShunyunbaoURL != "https://www.shunyunbaoerp.com" ||
cfg.ShunyunbaoUsername != "" || cfg.ShunyunbaoPassword != "" ||
cfg.ShunyunbaoTimeout != 30*time.Second {
@@ -75,8 +65,6 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
ClaimLeaseEnvironment: "15m",
RunningLeaseEnvironment: "45m",
ReadinessTTLEnvironment: "3m",
ERPConnectorURLEnvironment: "http://localhost:18091",
ERPConnectorAPIKeyEnvironment: "12345678901234567890123456789012",
ShunyunbaoURLEnvironment: "https://erp.example.test:8443",
ShunyunbaoUsernameEnvironment: "service-user",
ShunyunbaoPasswordEnvironment: " pass with spaces ",
@@ -114,14 +102,6 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
cfg.ReadinessTTL,
)
}
if cfg.ERPConnectorURL != values[ERPConnectorURLEnvironment] ||
cfg.ERPConnectorAPIKey != values[ERPConnectorAPIKeyEnvironment] {
t.Fatalf(
"ERP connector = %q / %q",
cfg.ERPConnectorURL,
cfg.ERPConnectorAPIKey,
)
}
if cfg.ShunyunbaoURL != values[ShunyunbaoURLEnvironment] ||
cfg.ShunyunbaoUsername != values[ShunyunbaoUsernameEnvironment] ||
cfg.ShunyunbaoPassword != values[ShunyunbaoPasswordEnvironment] {
@@ -134,24 +114,6 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
name string
values map[string]string
}{
{
name: "non-loopback ERP connector",
values: map[string]string{
ERPConnectorURLEnvironment: "http://192.0.2.10:8091",
},
},
{
name: "ERP connector path",
values: map[string]string{
ERPConnectorURLEnvironment: "http://127.0.0.1:8091/private",
},
},
{
name: "short ERP connector key",
values: map[string]string{
ERPConnectorAPIKeyEnvironment: "short",
},
},
{
name: "non HTTPS shunyunbao URL",
values: map[string]string{
@@ -1,148 +0,0 @@
package erpconnector
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"cmroubao/backend-api/internal/domain"
)
var (
// Deprecated aliases keep the temporary Connector behavior compatible while
// the source-neutral Go ERP implementation is introduced in T-225 to T-228.
ErrNotConfigured = domain.ErrFreightSourceNotConfigured
ErrSessionRequired = domain.ErrFreightSourceSessionNeeded
ErrNotFound = domain.ErrFreightSourceNotFound
ErrUnavailable = domain.ErrFreightSourceUnavailable
ErrProtocol = domain.ErrFreightSourceProtocol
)
const maxResponseBytes = 4 << 20
type Client struct {
baseURL string
apiKey string
http *http.Client
}
func New(baseURL, apiKey string, timeout time.Duration) (*Client, error) {
if strings.TrimSpace(baseURL) == "" {
baseURL = "http://127.0.0.1:8091"
}
if timeout <= 0 {
timeout = 90 * time.Second
}
if timeout <= 0 {
return nil, errors.New("ERP connector client configuration is invalid")
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: strings.TrimSpace(apiKey),
http: &http.Client{
Timeout: timeout,
CheckRedirect: func(
_ *http.Request,
_ []*http.Request,
) error {
return http.ErrUseLastResponse
},
},
}, nil
}
func (client *Client) QueryOrder(
ctx context.Context,
orderNumber string,
) (domain.FreightSourceBatch, error) {
return client.query(ctx, map[string]string{
"mode": domain.FreightSyncOrderNumber,
"order_number": orderNumber,
}, domain.FreightSyncOrderNumber, "", "")
}
func (client *Client) QueryCreatedRange(
ctx context.Context,
createdFrom, createdTo string,
) (domain.FreightSourceBatch, error) {
return client.query(ctx, map[string]string{
"mode": domain.FreightSyncCreatedRange,
"created_from": createdFrom,
"created_to": createdTo,
}, domain.FreightSyncCreatedRange, createdFrom, createdTo)
}
func (client *Client) query(
ctx context.Context,
payload map[string]string,
expectedMode, expectedFrom, expectedTo string,
) (domain.FreightSourceBatch, error) {
if client.apiKey == "" {
return domain.FreightSourceBatch{}, ErrNotConfigured
}
body, err := json.Marshal(payload)
if err != nil {
return domain.FreightSourceBatch{}, ErrProtocol
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
client.baseURL+"/v1/freight/query",
bytes.NewReader(body),
)
if err != nil {
return domain.FreightSourceBatch{}, ErrUnavailable
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-API-Key", client.apiKey)
response, err := client.http.Do(request)
if err != nil {
return domain.FreightSourceBatch{}, ErrUnavailable
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, maxResponseBytes+1)
content, err := io.ReadAll(limited)
if err != nil || len(content) > maxResponseBytes {
return domain.FreightSourceBatch{}, ErrUnavailable
}
if response.StatusCode != http.StatusOK {
switch response.StatusCode {
case http.StatusUnauthorized:
return domain.FreightSourceBatch{}, ErrSessionRequired
case http.StatusNotFound:
return domain.FreightSourceBatch{}, ErrNotFound
default:
return domain.FreightSourceBatch{}, ErrUnavailable
}
}
var result domain.FreightSourceBatch
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&result); err != nil {
return domain.FreightSourceBatch{}, ErrProtocol
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return domain.FreightSourceBatch{}, ErrProtocol
}
if result.SchemaVersion != 1 || result.Query.Mode != expectedMode ||
result.Orders == nil {
return domain.FreightSourceBatch{}, ErrProtocol
}
if expectedMode == domain.FreightSyncOrderNumber {
if result.Query.CreatedFrom != nil || result.Query.CreatedTo != nil {
return domain.FreightSourceBatch{}, ErrProtocol
}
} else if result.Query.CreatedFrom == nil ||
result.Query.CreatedTo == nil ||
*result.Query.CreatedFrom != expectedFrom ||
*result.Query.CreatedTo != expectedTo {
return domain.FreightSourceBatch{}, ErrProtocol
}
return result, nil
}
@@ -1,223 +0,0 @@
package erpconnector
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestQueryOrderAcceptsAllowlistResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
if request.Header.Get("X-API-Key") != "12345678901234567890123456789012" {
t.Fatal("service key was not forwarded")
}
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{
"schema_version":1,
"query":{"mode":"ORDER_NUMBER"},
"orders":[{
"external_stock_id":"12",
"source_code":"SOURCE-12",
"platform_order_no":null,
"shop_name":"测试店铺",
"source_created_at":"2026-07-28 08:00:00",
"order_status":"0",
"purchase_status":"1",
"is_canceled":false,
"items":[{
"external_item_id":"88",
"title":"商品",
"product_spec":"黑色,L",
"sku":"BLACK-L",
"quantity":2,
"product_thumb_ref":"190",
"purchase_status":"0"
}]
}]
}`))
}))
defer server.Close()
client, err := New(
server.URL,
"12345678901234567890123456789012",
time.Second,
)
if err != nil {
t.Fatalf("New() error = %v", err)
}
result, err := client.QueryOrder(context.Background(), "SOURCE-12")
if err != nil {
t.Fatalf("QueryOrder() error = %v", err)
}
if len(result.Orders) != 1 || len(result.Orders[0].Items) != 1 ||
result.Orders[0].Items[0].SKU != "BLACK-L" {
t.Fatalf("result = %+v", result)
}
}
func TestQueryCreatedRangeUsesStrictContract(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
var body map[string]string
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body["mode"] != "CREATED_RANGE" ||
body["created_from"] != "2026-07-22" ||
body["created_to"] != "2026-07-28" ||
len(body) != 3 {
t.Fatalf("request body = %#v", body)
}
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{
"schema_version":1,
"query":{
"mode":"CREATED_RANGE",
"created_from":"2026-07-22",
"created_to":"2026-07-28"
},
"orders":[]
}`))
}))
defer server.Close()
client, _ := New(
server.URL,
"12345678901234567890123456789012",
time.Second,
)
result, err := client.QueryCreatedRange(
context.Background(),
"2026-07-22",
"2026-07-28",
)
if err != nil || result.Query.CreatedFrom == nil ||
*result.Query.CreatedFrom != "2026-07-22" {
t.Fatalf("QueryCreatedRange() = %+v, %v", result, err)
}
}
func TestQueryCreatedRangeRejectsMismatchedResponseWindow(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{
"schema_version":1,
"query":{
"mode":"CREATED_RANGE",
"created_from":"2026-07-21",
"created_to":"2026-07-28"
},
"orders":[]
}`))
}))
defer server.Close()
client, _ := New(
server.URL,
"12345678901234567890123456789012",
time.Second,
)
_, err := client.QueryCreatedRange(
context.Background(),
"2026-07-22",
"2026-07-28",
)
if !errors.Is(err, ErrProtocol) {
t.Fatalf("QueryCreatedRange() error = %v", err)
}
}
func TestQueryOrderRejectsUnexpectedPIIField(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{
"schema_version":1,
"query":{"mode":"ORDER_NUMBER"},
"orders":[],
"receiverTel":"private"
}`))
}))
defer server.Close()
client, _ := New(server.URL, "12345678901234567890123456789012", time.Second)
_, err := client.QueryOrder(context.Background(), "SOURCE-12")
if !errors.Is(err, ErrProtocol) {
t.Fatalf("QueryOrder() error = %v, want protocol error", err)
}
}
func TestQueryOrderMapsStableErrorsWithoutReadingDetails(t *testing.T) {
tests := []struct {
status int
want error
}{
{http.StatusUnauthorized, ErrSessionRequired},
{http.StatusNotFound, ErrNotFound},
{http.StatusBadGateway, ErrUnavailable},
}
for _, test := range tests {
t.Run(http.StatusText(test.status), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.WriteHeader(test.status)
_, _ = writer.Write([]byte(`{"detail":"private upstream text"}`))
}))
defer server.Close()
client, _ := New(
server.URL,
"12345678901234567890123456789012",
time.Second,
)
_, err := client.QueryOrder(context.Background(), "SOURCE-12")
if !errors.Is(err, test.want) {
t.Fatalf("QueryOrder() error = %v, want %v", err, test.want)
}
})
}
}
func TestQueryOrderDoesNotFollowRedirects(t *testing.T) {
followed := false
target := httptest.NewServer(http.HandlerFunc(func(
http.ResponseWriter,
*http.Request,
) {
followed = true
}))
defer target.Close()
redirect := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.Header().Set("Location", target.URL)
writer.WriteHeader(http.StatusTemporaryRedirect)
}))
defer redirect.Close()
client, _ := New(
redirect.URL,
"12345678901234567890123456789012",
time.Second,
)
_, err := client.QueryOrder(context.Background(), "SOURCE-12")
if !errors.Is(err, ErrUnavailable) || followed {
t.Fatalf("redirect error/followed = %v / %t", err, followed)
}
}
@@ -214,7 +214,7 @@ func TestFreightDateSyncAdvancesWatermarkOnlyOnWholeBatchSuccess(
if err := store.FailFreightSync(
ctx,
failed.ID,
"ERP_CONNECTOR_UNAVAILABLE",
"ERP_UNAVAILABLE",
now.Add(time.Hour+time.Minute),
); err != nil {
t.Fatalf("FailFreightSync() error = %v", err)