From 13a1355e0d0209dac720a3e618a0a91ed8a99557 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 29 Jul 2026 10:14:30 +0800 Subject: [PATCH] refactor(t228): remove Python ERP connector --- .gitignore | 3 +- backend-api/internal/config/config.go | 143 ++--- backend-api/internal/config/config_test.go | 38 -- .../internal/platform/erpconnector/client.go | 148 ----- .../platform/erpconnector/client_test.go | 223 ------- .../sqlite/freight_repository_test.go | 2 +- docs/00-ai-start-here.md | 7 +- docs/03-tech-stack.md | 15 +- docs/04-architecture.md | 4 +- docs/05-coding-rules.md | 3 +- docs/api.md | 3 +- docs/current-state.md | 32 +- docs/integrations/shunyunbao-contract.md | 10 +- docs/tasks/T-228.md | 29 +- erp-connector/README.md | 67 --- erp-connector/pyproject.toml | 38 -- erp-connector/src/shunyunbaoerp/__init__.py | 25 - erp-connector/src/shunyunbaoerp/api.py | 167 ------ erp-connector/src/shunyunbaoerp/cli.py | 99 ---- erp-connector/src/shunyunbaoerp/client.py | 558 ------------------ erp-connector/src/shunyunbaoerp/constants.py | 131 ---- erp-connector/src/shunyunbaoerp/errors.py | 29 - erp-connector/src/shunyunbaoerp/normalizer.py | 179 ------ erp-connector/tests/test_api.py | 111 ---- erp-connector/tests/test_client.py | 242 -------- erp-connector/tests/test_normalizer.py | 160 ----- scripts/start-erp-connector.bat | 26 - start-backend.bat | 6 - 28 files changed, 104 insertions(+), 2394 deletions(-) delete mode 100644 backend-api/internal/platform/erpconnector/client.go delete mode 100644 backend-api/internal/platform/erpconnector/client_test.go delete mode 100644 erp-connector/README.md delete mode 100644 erp-connector/pyproject.toml delete mode 100644 erp-connector/src/shunyunbaoerp/__init__.py delete mode 100644 erp-connector/src/shunyunbaoerp/api.py delete mode 100644 erp-connector/src/shunyunbaoerp/cli.py delete mode 100644 erp-connector/src/shunyunbaoerp/client.py delete mode 100644 erp-connector/src/shunyunbaoerp/constants.py delete mode 100644 erp-connector/src/shunyunbaoerp/errors.py delete mode 100644 erp-connector/src/shunyunbaoerp/normalizer.py delete mode 100644 erp-connector/tests/test_api.py delete mode 100644 erp-connector/tests/test_client.py delete mode 100644 erp-connector/tests/test_normalizer.py delete mode 100644 scripts/start-erp-connector.bat diff --git a/.gitignore b/.gitignore index d2af3af..d9d3040 100644 --- a/.gitignore +++ b/.gitignore @@ -44,9 +44,8 @@ coverage.out captcha.jpg captcha.png freight_detail.json -erp-connector/.env* -# Python connector outputs +# Python local-tool outputs __pycache__/ *.py[cod] .pytest_cache/ diff --git a/backend-api/internal/config/config.go b/backend-api/internal/config/config.go index 89cb018..e76b615 100644 --- a/backend-api/internal/config/config.go +++ b/backend-api/internal/config/config.go @@ -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 == "" || diff --git a/backend-api/internal/config/config_test.go b/backend-api/internal/config/config_test.go index c9b6288..07d1f62 100644 --- a/backend-api/internal/config/config_test.go +++ b/backend-api/internal/config/config_test.go @@ -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{ diff --git a/backend-api/internal/platform/erpconnector/client.go b/backend-api/internal/platform/erpconnector/client.go deleted file mode 100644 index 4b2d512..0000000 --- a/backend-api/internal/platform/erpconnector/client.go +++ /dev/null @@ -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 -} diff --git a/backend-api/internal/platform/erpconnector/client_test.go b/backend-api/internal/platform/erpconnector/client_test.go deleted file mode 100644 index 6154432..0000000 --- a/backend-api/internal/platform/erpconnector/client_test.go +++ /dev/null @@ -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) - } -} diff --git a/backend-api/internal/repository/sqlite/freight_repository_test.go b/backend-api/internal/repository/sqlite/freight_repository_test.go index 9eec2c5..b2e97db 100644 --- a/backend-api/internal/repository/sqlite/freight_repository_test.go +++ b/backend-api/internal/repository/sqlite/freight_repository_test.go @@ -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) diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 2e3026e..39b1ba9 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -62,9 +62,10 @@ T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207 结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹、T-215 Admin 候选确认、不可变待投递授权、T-216 设备命令可靠投递及 T-217 已授权商品 重新定位与订单 dry-run、T-218 单次订单提交围栏和订单回读,以及 T-219 Admin -待付款提醒与端到端验收均已完成。采购任务来源现扩展为顺运宝 ERP:T-220 至 -T-224 将依次完成安全字段契约、精确单号 Connector、货运信息存储/Admin、待采购 -需求生成和日期增量同步;完成后再进入 T-301 P0 UI 完整交互验收。 +待付款提醒与端到端验收均已完成。采购任务来源已扩展为顺运宝 ERP:T-220 至 T-224 +完成字段契约、货运信息存储/Admin、采购需求生成和日期增量同步;T-225 至 T-228 已将 +验证码登录和查询统一迁移到 Go API 进程,删除 Python 服务、loopback 端口和共享 API Key。 +完成后再进入 T-301 P0 UI 完整交互验收。 不得直接把候选链接或列表 ordinal 当成授权。 手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。 T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 41c4e04..b47c843 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -21,7 +21,7 @@ | 数据访问 | 标准库 `database/sql` | MVP 已定 | 领域层通过仓储接口访问,避免先引入 ORM 和代码生成复杂度。 | | 数据迁移 | Goose v3.26.0,使用嵌入式 SQL migration | 已验证 | v3.26.0 是已核实仍声明 Go 1.23.0 的最高版本;v3.27.x 要求 Go 1.25。 | | 管理 Web | Gin + `html/template` + `embed` + 少量原生 JS/CSS | MVP 已定 | 不单独引入 SPA 工程,模板和静态资源随服务构建。 | -| ERP 直连适配 | Go 标准库 `net/http`、`net/http/cookiejar` | T-225 契约已冻结,尚未切换运行时 | Go 侧固定顺运宝请求、分页、详情批量和最小字段归一化;当前 Python loopback Connector 仅在 T-228 前临时保留。 | +| ERP 直连适配 | Go 标准库 `net/http`、`net/http/cookiejar` | T-227 已验证 | Go 侧固定顺运宝验证码、登录、会话校验、分页、详情批量和最小字段归一化;不需要 Python 服务或共享 API Key。 | | 数据库 | SQLite | MVP 已定 | 单服务、单设备验证足够;多实例或并发提升前迁移 PostgreSQL。 | | 图片/截图 | 后端受控本地文件目录 + `golang.org/x/image` v0.28.0 | 已验证 | JPEG/PNG/WebP 真解码后白底缩放并编码为 JPEG;数据库只存元数据和随机相对键。 | | 管理鉴权 | bcrypt + 8 小时 opaque 服务端会话 Cookie | T-204 已验证 | `authctl` 预置 ADMIN;数据库只存密码 hash 与 session SHA-256,完整 RBAC 为 V2。 | @@ -122,12 +122,10 @@ backend-api/ internal/config/ # 环境变量和安全默认 internal/platform/database/ # SQLite 打开、pragma 和生命周期 internal/platform/migration/ # Goose provider 封装 + internal/platform/shunyunbao/ # ERP 会话、协议、直连查询与 allowlist internal/transport/httpapi/ # Gin 路由、健康检查和 HTTP Server migrations/ # 嵌入式 Goose SQL migration var/ # 本地运行数据,必须忽略 -erp-connector/ - src/shunyunbaoerp/ # 顺运宝登录、查询、规范化与内部 HTTP 适配 - tests/ # 伪响应和脱敏结构测试,不访问线上 ERP docs/ ``` @@ -167,8 +165,7 @@ T-205 已在该分层上增加独立 `LifecycleService` 与 SQLite immediate tra PowerShell 等价环境变量,确保依赖没有暗中要求更高 Go 版本。 - handler 不直接写 SQL,repository 不依赖 Gin,domain/usecase 不导入具体数据库驱动。 - 密钥和设备令牌通过环境变量或本地忽略配置注入,不进入 Git。 -- T-225 后 Go `internal/platform/shunyunbao` 固定同一套脱敏协议契约;T-226 以前不装配 - 直接会话,运行时仍使用临时 Connector。T-226 的会话只在进程内,不引入 Redis。 -- ERP 账号、密码、Cookie、JWT、验证码和 Connector 服务密钥只通过环境变量/受控秘密 - 注入;不得进入 SQLite、浏览器、VLM、fixture 或普通日志。Go 适配器不得记录完整 - 响应或 PII。 +- Go `internal/platform/shunyunbao` 固定同一套脱敏协议契约,并在 API 进程内装配受锁 + 会话与 `FreightSource`;会话只在进程内,不引入 Redis。 +- ERP 账号、密码、Cookie、JWT 和验证码只通过后端环境变量或受控人工输入流转;不得进入 + SQLite、浏览器、VLM、fixture 或普通日志。Go 适配器不得记录完整响应或 PII。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 2d98318..70cef0a 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -161,8 +161,8 @@ T-225 冻结了 `internal/platform/shunyunbao` 的协议常量、请求 header 启动环境读取。T-227 将此会话直接作为 `FreightSource`:每次同步先校验会话,再以最多 100 条、每页 20 条和每批最多 100 个详情 ID 查询;响应不完整、身份冲突或会话失效均使 整批失败。进程重启即失去会话,不用 Redis 或持久化 Cookie。货运用例只识别来源中立的 -“未配置、会话失效、未找到、协议异常、暂时不可用”错误。T-228 再删除未参与运行的旧 -Python Connector、loopback 端口和服务密钥。 +“未配置、会话失效、未找到、协议异常、暂时不可用”错误。T-228 已删除旧 Python +Connector、loopback 端口和服务密钥;仓库运行时仅保留 Go API 进程。 模式在 execution 开始时固定并写入结果;AI 失败后只能由人员明确切换,不能静默降级。 App 同时固定 provider ID、model、prompt/schema version 和证据 SHA-256,作为非秘密 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index 8f6ba68..0e42ae7 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -113,7 +113,8 @@ recovery 不得把 Authorization、Cookie、panic 或请求正文写普通日志。 - SQLite 数据文件必须位于被忽略目录,启用 foreign keys、有限 busy timeout 和 WAL;连接由进程入口显式关闭,migration 使用固定版本和受控 SQL 文件。 -- ERP adapter 只能把版本化 allowlist 归一化对象交给 `FreightSource`;收件人、电话、 +- ERP adapter 只能把版本化 allowlist 归一化对象交给 `FreightSource`;其 HTTP 会话只在 + Go API 进程内。收件人、电话、 地址、完整响应、Cookie、JWT、账号、密码和验证码不得进入领域对象、错误、日志、 fixture、SQLite、浏览器或 VLM。验证码必须由人员输入,禁止 OCR、猜测或重放。 diff --git a/docs/api.md b/docs/api.md index bba78b2..ce0a026 100644 --- a/docs/api.md +++ b/docs/api.md @@ -276,8 +276,7 @@ T-227 的后台 worker 使用当前 Go 内存会话,按 `listTotal -> list 分 查询。每次先校验会话;列表最多 100 条、每页 20 条,详情每批最多 100 个外部 stock ID。 未配置、未登录、找不到货运单、响应协议错误和暂时不可用分别落为 `ERP_NOT_CONFIGURED`、`ERP_SESSION_REQUIRED`、`ERP_FREIGHT_NOT_FOUND`、 -`ERP_RESPONSE_INVALID` 和 `ERP_UNAVAILABLE`,不返回 ERP 原始错误 body。旧 Python -Connector 仅在仓库中等待 T-228 删除,不再是运行时依赖。 +`ERP_RESPONSE_INVALID` 和 `ERP_UNAVAILABLE`,不返回 ERP 原始错误 body。 ### `POST /api/v1/freight-items/{item_id}/procurement-request` diff --git a/docs/current-state.md b/docs/current-state.md index 12c5f96..5254e6d 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,7 +5,7 @@ ## 当前快照 - 日期:2026-07-29 -- 阶段:T-227 Go 直连 ERP 查询已完成;T-228 待删除遗留 Python Connector +- 阶段:T-228 已完成单 Go 后端 ERP 切换;等待真实凭证下的人工验证码登录 smoke - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219 均按文档提交、实现提交的顺序纳入历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 @@ -22,7 +22,8 @@ 已增加受锁保护的 Go 内存 Cookie jar、验证码 ticket、登录和用户校验,以及 ADMIN 的 `/erp` 页面/API;账号密码仅由 `CMROUBAO_SHUNYUNBAO_*` 启动环境读取,重启后需人工 重新登录。T-227 已将其作为 `FreightSource`,查询先校验会话、再执行有界分页/详情 - 批量并返回 allowlist;未访问真实 ERP。旧 Python Connector 仅待 T-228 删除。 + 批量并返回 allowlist;T-228 已删除旧 Python Connector、loopback 端口和共享 API Key; + 未访问真实 ERP。 - ERP 增量同步:v14 支持 Asia/Shanghai 创建日期闭区间和“同步至现在”,source 单窗 最多 7 天,后端对较长水位范围切窗并从成功水位前 10 分钟所在自然日回看。 货运落库、同步成功和水位推进同事务完成;失败与较旧范围成功不推进水位。Admin @@ -34,12 +35,12 @@ - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过; Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110 -- 后端测试:T-226 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...` 和三个 Go - 入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、详情 - allowlist 和稳定错误码覆盖,均未访问真实 ERP; +- 后端测试:T-226 至 T-228 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...` + 和三个 Go 入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、 + 详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准 + 验证也通过,均未访问真实 ERP; 覆盖 v14 上下迁移、7 天切窗、水位重叠、中途失败、空窗口、重复页、来源 revision、 - 水位事务/不回退、Admin API/SSR 和旧 Connector 严格响应窗口;Python 22 项伪响应 - 测试保留至 T-228 删除前,未访问真实 ERP + 水位事务/不回退、Admin API/SSR 和 Go source 严格响应窗口;未访问真实 ERP - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright 以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、 脚本错误或外部请求,Android 可见交互控件均不小于 44px @@ -103,10 +104,9 @@ 对账和唯一对账成功;成功态展示完整订单号、平台时间、授权 SKU/数量/金额及受控 证据,只提醒采购员打开拼多多人工核对付款。Roubao 显示相同终态;Web/App 均没有 付款、重复提交或自动支付动作。隔离数据库真实 SSR 已通过三个响应式视口验收。 -- T-220/T-221 ERP 来源:字段契约已固定 stock/detail/item 外部身份、PII 最小化和 - 缺图边界;Python Connector 只监听 loopback,服务密钥鉴权,受控验证码登录后按 - 完整单号读取全部商品并输出 allowlist schema。16 项伪响应测试和真实 HTTP 健康 - smoke 通过,未访问线上 ERP。 +- T-220 至 T-228 ERP 来源:字段契约已固定 stock/detail/item 外部身份、PII 最小化和 + 缺图边界;Go API 以受锁内存会话完成受控验证码登录,并直接按完整单号或日期范围查询 + allowlist schema。旧 Python Connector、8091 端口和共享服务密钥已删除;未访问线上 ERP。 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步 - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture @@ -182,23 +182,23 @@ | `docs/tasks/T-225.md` | DONE | 冻结 Go 直连 ERP 协议与安全边界 | | `docs/tasks/T-226.md` | DONE | Go ERP 会话、验证码登录与 Admin 连接页 | | `docs/tasks/T-227.md` | DONE | Go 直连顺运宝查询接入货运同步 | +| `docs/tasks/T-228.md` | DONE | 移除 Python Connector 并完成 Go 切换 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | | `android-buyer/task-contract/` | 已有 | Android/CLI 共享 ProbeTask 与 TaskSource | | `android-buyer/tools/shopee-importer/` | 已有 | 开发机私有 fixture 导入 CLI | | `backend-api/` | 已有 | Go-Gin 任务 API、SQLite、图片存储、管理 Web 和 migration | -| `erp-connector/` | 已有 | 顺运宝 loopback Python Connector 与无网络伪响应测试 | | `init.ps1` / `init.sh` | 已验证/待跨平台 | Windows 同时验证 Android/Go;Unix 入口待 Linux/WSL 复核 | ## 任务摘要 - 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。 -- 已完成:另含 T-220 至 T-227 ERP 契约、货运存储、采购需求生成、日期增量同步、Go - 直连协议、人工验证码会话和直连 `FreightSource`。 +- 已完成:另含 T-220 至 T-228 ERP 契约、货运存储、采购需求生成、日期增量同步、Go + 直连协议、人工验证码会话、直连 `FreightSource` 和旧 Connector 清理。 - 进行中:无。 -- 下一步:T-228 删除旧 Python Connector、loopback 端口/服务密钥及其文档;真实 ERP - 上线前仍需确认开放 API、数据使用权限并由人员完成验证码登录。 +- 下一步:真实 ERP 上线前确认开放 API、数据使用权限,并由人员在 `/erp` 完成验证码 + 登录后以受控单号执行一次不记录订单内容的 smoke。 ## 当前可运行内容 diff --git a/docs/integrations/shunyunbao-contract.md b/docs/integrations/shunyunbao-contract.md index 1755da4..11ad088 100644 --- a/docs/integrations/shunyunbao-contract.md +++ b/docs/integrations/shunyunbao-contract.md @@ -23,22 +23,22 @@ ## Go 直连迁移契约(T-225) -T-225 在 `backend-api/internal/platform/shunyunbao` 用脱敏 fixture 固定以下内容,尚未 -改变当前 Python Connector 运行时路径: +T-225 至 T-227 在 `backend-api/internal/platform/shunyunbao` 用脱敏 fixture 固定以下 +内容,并由 API 进程内的 Go source 直接执行: - 基础请求 header 固定 `Accept`、`Origin`、`Referer`、`User-Agent` 和 `X-Requested-With`;base URL 必须是无 userinfo、query、fragment 或路径的 origin。 - 单号条件为 `t_stock.allcode`、`op=6`、`type=0`、`optType=1`;日期范围为 `t_stock.created`、`op=0`、`type=3`、`optType=0`,单个 ERP 窗口最多 7 个自然日。 - `listTotal`、`list` 共享 `history=0`、`length`、`start`、`pageIndex`、`store=false`、 - 已验证 columns 和单一 `queries`;每页最多 500 条。 + 已验证 columns 和单一 `queries`;运行时每页固定 20 条、单次最多 100 条。 - `listByStock?hist=0` 每批最多 100 个去重后的正整数 stock id,详情 `id` 必须与列表 `stock.id` 一致。 - 所有结果必须经 Go allowlist 归一化;fixture 专门含收件信息、Cookie/JWT 标记值, 测试断言它们不会出现在输出或错误里。 -Go 直连的验证码、登录和 Cookie jar 属于 T-226。不得用本地 OCR 或 Redis 取代人工 -验证码流程;真实线上请求不属于自动化测试。 +Go 直连的验证码、登录、Cookie jar 和查询 source 都在 API 进程内。不得用本地 OCR 或 +Redis 取代人工验证码流程;真实线上请求不属于自动化测试。 ## 身份和规范字段 diff --git a/docs/tasks/T-228.md b/docs/tasks/T-228.md index b08f06a..cdf836f 100644 --- a/docs/tasks/T-228.md +++ b/docs/tasks/T-228.md @@ -4,7 +4,7 @@ title: 移除 Python ERP Connector 并完成 Go 切换 phase: 2 deps: - T-227 -status: TODO +status: DONE created: 2026-07-29 context_ref: d62a4af work_branch: null @@ -18,11 +18,13 @@ write_paths: - docs/api.md - docs/current-state.md - docs/integrations/** + - .gitignore - README.md - backend-api/README.md - backend-api/cmd/api/** - backend-api/internal/config/** - backend-api/internal/platform/erpconnector/** + - backend-api/internal/repository/sqlite/** - erp-connector/** - scripts/start-erp-connector.bat - start-backend.bat @@ -55,11 +57,11 @@ Go 直连 source 经 T-227 验证后,保留 Python Connector、8091 loopback ## 验收要点 -- [ ] 仓库不再包含 Python Connector、8091 监听、Connector API Key 或其启动命令。 -- [ ] Go 后端在 ERP 凭证缺失、未登录和伪 ERP 成功路径下均返回稳定、可操作状态。 -- [ ] `rg` 证明已跟踪文件无 Connector 运行时引用、真实凭证、Cookie、JWT 或订单样本。 -- [ ] `go test ./...`、`go test -race ./...`、`go vet ./...` 和根验证通过。 -- [ ] 文档只说明单 Go 后端、人工验证码和外部 ERP 风险;真实线上 smoke 结果明确记录。 +- [x] 仓库不再包含 Python Connector、8091 监听、Connector API Key 或其启动命令。 +- [x] Go 后端在 ERP 凭证缺失、未登录和伪 ERP 成功路径下均返回稳定、可操作状态。 +- [x] `rg` 证明已跟踪文件无 Connector 运行时引用、真实凭证、Cookie、JWT 或订单样本。 +- [x] `go test ./...`、`go test -race ./...`、`go vet ./...` 和根验证通过。 +- [x] 文档只说明单 Go 后端、人工验证码和外部 ERP 风险;真实线上 smoke 结果明确记录。 ## 边界 @@ -70,3 +72,18 @@ Go 直连 source 经 T-227 验证后,保留 Python Connector、8091 loopback ## 执行记录 - 2026-07-29:由 T-227 依赖创建,等待 Go 查询链路验收后进行删除。 +- 2026-07-29:开始实施;T-227 已由 `a822edd` 完成。确认外部原始研究脚本不在本次 + 删除范围内,清理仅作用于仓库内旧 Connector、端口、共享服务密钥及其文档。 +- 2026-07-29:删除 `erp-connector/`、`internal/platform/erpconnector/` 和 + `scripts/start-erp-connector.bat`,同时移除 `CMROUBAO_ERP_CONNECTOR_*`、8091 默认值、 + 共享 API Key 兼容和旧错误码。外部 + `D:\chengma\shunyunbaoerp\shunyunbaoerp_single.py` 未读取、未修改、未删除。 +- 2026-07-29:`start-backend.bat` 只启动 Go 后端;ERP 未配置时 `/erp` 给出可修复状态, + 同步 run 使用 `ERP_NOT_CONFIGURED` 或 `ERP_SESSION_REQUIRED`,不再依赖第二进程。 +- 验证:在 `backend-api/` 执行 `$env:GOTOOLCHAIN='local'; go test ./...; go test -race ./...;` + `go vet ./...; go build ./cmd/api; go build ./cmd/migrate; go build ./cmd/authctl`,全部通过。 + 根目录执行 `./init.ps1`,Android 测试/Debug APK 和 Go 标准验证全部通过。 +- 扫描:排除历史任务执行记录后,`rg` 未找到 Connector 运行时引用、8091、共享 API Key、 + 真实凭证、Cookie、JWT 或订单样本;工作区内 `erp-connector/` 已不存在。 +- 未运行:未配置真实 ERP 凭证,未执行线上验证码/受控单号 smoke;上线前仍需人员完成 + `/erp` 人工验证码登录,并确认厂商开放 API、数据使用和个人信息处理授权。 diff --git a/erp-connector/README.md b/erp-connector/README.md deleted file mode 100644 index 466eddd..0000000 --- a/erp-connector/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# 顺运宝 ERP Connector - -内部只读适配器:保持顺运宝验证码/Cookie 会话,按完整单号或最多 7 个自然日的 -创建日期闭区间查询货运列表和详情,再输出不含收件人、电话、地址、Cookie、JWT -或原始响应的规范化 JSON。 - -协议客户端基于同一开发机上已经用本地 HAR 验证的 `shunyunbaoerp 0.1.0` 代码收敛; -本目录不包含 HAR、真实响应、账号、密码或订单号。 - -## 安装和测试 - -```powershell -Set-Location erp-connector -python -m pip install -e ".[api,dev]" -pytest -``` - -测试只使用伪响应,不访问线上 ERP。 - -## 启动 - -```powershell -$env:SHUNYUNBAO_USERNAME = "your_username" -$env:SHUNYUNBAO_PASSWORD = "your_password" -$env:SHUNYUNBAO_SERVICE_API_KEY = "at-least-32-utf8-bytes" -..\scripts\start-erp-connector.bat -``` - -服务固定监听 `127.0.0.1:8091` 并关闭访问日志。不要把环境变量写入脚本、Git 或命令行 -参数;生产前应改用受控秘密注入。 - -## 受控会话 - -1. `GET /v1/session/captcha`,请求头携带 `X-API-Key`。 -2. 人员查看验证码后调用 `POST /v1/session/login`: - - ```json - {"captcha_code":"abcd"} - ``` - -3. 调用 `POST /v1/freight/query`: - - ```json - {"mode":"ORDER_NUMBER","order_number":"完整单号"} - ``` - - 日期查询严格使用 Asia/Shanghai 的 `YYYY-MM-DD`: - - ```json - { - "mode":"CREATED_RANGE", - "created_from":"2026-07-22", - "created_to":"2026-07-28" - } - ``` - -Connector 不自动 OCR 绕过验证码。服务重启或 ERP 会话过期后需重新执行前两步。 - -## 输出边界 - -响应只包含: - -- `external_stock_id`、受控来源单号、店铺、ERP 时间和原始状态。 -- 全部商品的 `external_item_id`、标题、规格、SKU、数量、`productThumb` 引用和状态。 - -`productThumb` 当前可能是数字引用,不是公开图片 URL。下游必须保持 `NEEDS_IMAGE`, -不能拼接 URL 或抓取未知主机。 diff --git a/erp-connector/pyproject.toml b/erp-connector/pyproject.toml deleted file mode 100644 index 6a741a8..0000000 --- a/erp-connector/pyproject.toml +++ /dev/null @@ -1,38 +0,0 @@ -[build-system] -requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" - -[project] -name = "shunyunbaoerp" -version = "0.1.0" -description = "顺运宝 ERP 货运信息查询客户端" -readme = "README.md" -requires-python = ">=3.10" -dependencies = [ - "requests>=2.31,<3", -] - -[project.optional-dependencies] -api = [ - "fastapi>=0.110,<1", - "uvicorn[standard]>=0.27,<1", -] -redis = [ - "redis>=5,<7", -] -dev = [ - "pytest>=8,<9", -] - -[project.scripts] -shunyunbaoerp = "shunyunbaoerp.cli:main" - -[tool.setuptools] -package-dir = {"" = "src"} - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.pytest.ini_options] -testpaths = ["tests"] -addopts = "-q" diff --git a/erp-connector/src/shunyunbaoerp/__init__.py b/erp-connector/src/shunyunbaoerp/__init__.py deleted file mode 100644 index b1e9523..0000000 --- a/erp-connector/src/shunyunbaoerp/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -"""顺运宝 ERP 客户端。""" - -from .client import ERPClient -from .errors import ( - ERPAPIError, - ERPAuthenticationError, - ERPError, - ERPNotFoundError, - ERPProtocolError, - ERPTransportError, -) -from .normalizer import normalize_freight_result - -__all__ = [ - "ERPClient", - "ERPError", - "ERPTransportError", - "ERPProtocolError", - "ERPAPIError", - "ERPAuthenticationError", - "ERPNotFoundError", - "normalize_freight_result", -] - -__version__ = "0.1.0" diff --git a/erp-connector/src/shunyunbaoerp/api.py b/erp-connector/src/shunyunbaoerp/api.py deleted file mode 100644 index 2b23c4c..0000000 --- a/erp-connector/src/shunyunbaoerp/api.py +++ /dev/null @@ -1,167 +0,0 @@ -"""可选 FastAPI 包装层,供其他项目通过 HTTP 调用。""" - -from __future__ import annotations - -import os -import secrets -import threading -from typing import Annotated - -from fastapi import Depends, FastAPI, Header, HTTPException, Response, status -from pydantic import BaseModel, Field - -from .client import ERPClient -from .errors import ( - ERPAPIError, - ERPAuthenticationError, - ERPNotFoundError, - ERPProtocolError, - ERPTransportError, -) -from .normalizer import normalize_freight_result - -app = FastAPI( - title="顺运宝 ERP 货运查询服务", - version="0.1.0", - description="单租户、受控会话的 ERP 查询适配层", -) - -_client: ERPClient | None = None -_client_lock = threading.Lock() - - -class LoginRequest(BaseModel): - captcha_code: str = Field(min_length=1, max_length=16) - - -class FreightQueryRequest(BaseModel): - mode: str = Field(default="ORDER_NUMBER", max_length=32) - order_number: str | None = Field(default=None, min_length=1, max_length=128) - created_from: str | None = Field(default=None, min_length=10, max_length=10) - created_to: str | None = Field(default=None, min_length=10, max_length=10) - - -def get_client() -> ERPClient: - global _client - with _client_lock: - if _client is None: - _client = ERPClient( - os.getenv( - "SHUNYUNBAO_BASE_URL", - "https://www.shunyunbaoerp.com", - ) - ) - return _client - - -def require_api_key( - x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None, -) -> None: - expected = os.getenv("SHUNYUNBAO_SERVICE_API_KEY") - if not expected or len(expected.encode("utf-8")) < 32: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="CONNECTOR_API_KEY_NOT_CONFIGURED", - ) - if not x_api_key or not secrets.compare_digest(x_api_key, expected): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="CONNECTOR_API_KEY_INVALID", - ) - - -APIKeyDependency = Annotated[None, Depends(require_api_key)] - - -@app.get("/health") -def health() -> dict[str, object]: - client = get_client() - expires = client.token_expires_at - return { - "status": "ok", - "loggedIn": client.is_logged_in, - "tokenExpiresAt": expires.isoformat() if expires else None, - } - - -@app.get("/v1/session/captcha") -def captcha(_: APIKeyDependency) -> Response: - try: - client = get_client() - content = client.fetch_captcha() - return Response( - content=content, - media_type=client.last_captcha_content_type, - headers={"Cache-Control": "no-store"}, - ) - except (ERPTransportError, ERPProtocolError) as exc: - raise HTTPException( - status_code=502, - detail="ERP_CAPTCHA_UNAVAILABLE", - ) from exc - - -@app.post("/v1/session/login") -def login(body: LoginRequest, _: APIKeyDependency) -> dict[str, object]: - username = os.getenv("SHUNYUNBAO_USERNAME") - password = os.getenv("SHUNYUNBAO_PASSWORD") - if not username or not password: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="ERP_CREDENTIALS_NOT_CONFIGURED", - ) - try: - get_client().login(username, password, body.captcha_code) - return {"status": "ok"} - except ERPAuthenticationError as exc: - raise HTTPException(status_code=401, detail="ERP_LOGIN_FAILED") from exc - except (ERPTransportError, ERPProtocolError) as exc: - raise HTTPException( - status_code=502, - detail="ERP_UPSTREAM_UNAVAILABLE", - ) from exc - - -@app.post("/v1/freight/query") -def query_freight( - body: FreightQueryRequest, - _: APIKeyDependency, -) -> dict[str, object]: - try: - if body.mode == "ORDER_NUMBER" and body.order_number: - if body.created_from is not None or body.created_to is not None: - raise ValueError("ORDER_NUMBER 不能包含日期范围") - result = get_client().get_freight_details(body.order_number) - elif ( - body.mode == "CREATED_RANGE" - and body.order_number is None - and body.created_from - and body.created_to - ): - result = get_client().get_freight_details_by_created_range( - body.created_from, - body.created_to, - ) - else: - raise ValueError("查询模式与参数不匹配") - return normalize_freight_result(result) - except ERPAuthenticationError as exc: - raise HTTPException( - status_code=401, - detail="ERP_SESSION_REQUIRED", - ) from exc - except ERPNotFoundError as exc: - raise HTTPException( - status_code=404, - detail="ERP_FREIGHT_NOT_FOUND", - ) from exc - except ValueError as exc: - raise HTTPException( - status_code=422, - detail="ERP_QUERY_INVALID", - ) from exc - except (ERPTransportError, ERPProtocolError, ERPAPIError) as exc: - raise HTTPException( - status_code=502, - detail="ERP_UPSTREAM_UNAVAILABLE", - ) from exc diff --git a/erp-connector/src/shunyunbaoerp/cli.py b/erp-connector/src/shunyunbaoerp/cli.py deleted file mode 100644 index 48a4214..0000000 --- a/erp-connector/src/shunyunbaoerp/cli.py +++ /dev/null @@ -1,99 +0,0 @@ -"""命令行入口,用于人工验证码登录和查询验证。""" - -from __future__ import annotations - -import argparse -import getpass -import json -import os -import sys -import webbrowser -from pathlib import Path - -from .client import ERPClient -from .errors import ERPError -from .normalizer import normalize_freight_result - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="顺运宝 ERP 货运查询") - parser.add_argument( - "--base-url", - default=os.getenv("SHUNYUNBAO_BASE_URL", "https://www.shunyunbaoerp.com"), - help="ERP 根地址", - ) - parser.add_argument( - "--username", - default=os.getenv("SHUNYUNBAO_USERNAME"), - help="ERP 用户名;默认读取 SHUNYUNBAO_USERNAME", - ) - parser.add_argument( - "--captcha-file", - type=Path, - default=Path(".local/shunyunbao/captcha.png"), - help="验证码图片保存路径", - ) - parser.add_argument( - "--open-captcha", - action="store_true", - help="保存后使用系统默认图片查看器打开验证码", - ) - - subparsers = parser.add_subparsers(dest="command", required=True) - subparsers.add_parser("login", help="验证登录链路") - query = subparsers.add_parser("query", help="按单号查询货运详情") - query.add_argument("order_number", help="ERP 页面“全部单号”查询框中的单号") - query.add_argument( - "--compact", - action="store_true", - help="输出紧凑 JSON", - ) - return parser - - -def _credentials(args: argparse.Namespace) -> tuple[str, str]: - username = args.username or input("ERP 用户名: ").strip() - password = os.getenv("SHUNYUNBAO_PASSWORD") or getpass.getpass("ERP 密码: ") - if not username or not password: - raise ValueError("用户名和密码不能为空") - return username, password - - -def _interactive_login(client: ERPClient, args: argparse.Namespace) -> None: - image = client.fetch_captcha() - args.captcha_file.parent.mkdir(parents=True, exist_ok=True) - args.captcha_file.write_bytes(image) - absolute_path = args.captcha_file.resolve() - print(f"验证码已保存到: {absolute_path}", file=sys.stderr) - if args.open_captcha: - webbrowser.open(absolute_path.as_uri()) - - username, password = _credentials(args) - code = input("验证码: ").strip() - client.login(username, password, code) - - -def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) - try: - with ERPClient(args.base_url) as client: - _interactive_login(client, args) - if args.command == "login": - expires = client.token_expires_at - suffix = f",JWT 提示过期时间 {expires.isoformat()}" if expires else "" - print(f"登录成功{suffix}") - return 0 - - result = normalize_freight_result( - client.get_freight_details(args.order_number) - ) - indent = None if args.compact else 2 - print(json.dumps(result, ensure_ascii=False, indent=indent, default=str)) - return 0 - except (ERPError, ValueError, OSError) as exc: - print(f"错误: {exc}", file=sys.stderr) - return 2 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/erp-connector/src/shunyunbaoerp/client.py b/erp-connector/src/shunyunbaoerp/client.py deleted file mode 100644 index cdb806f..0000000 --- a/erp-connector/src/shunyunbaoerp/client.py +++ /dev/null @@ -1,558 +0,0 @@ -"""顺运宝 ERP 登录与货运详情查询客户端。""" - -from __future__ import annotations - -import base64 -import json -import random -import threading -import time -from collections.abc import Iterable, Mapping -from datetime import date, datetime, timedelta, timezone -from typing import Any -from urllib.parse import urljoin, urlparse - -import requests - -from .constants import ( - CAPTCHA_PATH, - LOGIN_PATH, - STOCK_DETAIL_PATH, - STOCK_LIST_PATH, - STOCK_LIST_TOTAL_PATH, - created_range_query, - order_number_query, - stock_columns, -) -from .errors import ( - ERPAPIError, - ERPAuthenticationError, - ERPNotFoundError, - ERPProtocolError, - ERPTransportError, -) - - -class ERPClient: - """保持 Cookie 会话的同步客户端。 - - 一个实例对应一个 ERP 登录会话。requests.Session 不是线程安全的, - 因此所有会话操作都由实例锁串行化。 - """ - - RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504}) - - def __init__( - self, - base_url: str = "https://www.shunyunbaoerp.com", - *, - timeout: tuple[float, float] = (5.0, 30.0), - max_retries: int = 2, - page_size: int = 20, - max_matches: int = 100, - session: requests.Session | None = None, - allow_insecure_http: bool = False, - ) -> None: - self.base_url = base_url.rstrip("/") + "/" - parsed = urlparse(self.base_url) - is_local = parsed.hostname in {"localhost", "127.0.0.1", "::1"} - if parsed.scheme != "https" and not (allow_insecure_http or is_local): - raise ValueError("ERP 登录包含口令,非本地地址必须使用 HTTPS") - if not parsed.netloc: - raise ValueError("base_url 必须是完整 URL") - if any(value <= 0 for value in timeout): - raise ValueError("timeout 必须为正数") - if max_retries < 0: - raise ValueError("max_retries 不能小于 0") - if not 1 <= page_size <= 500: - raise ValueError("page_size 必须在 1..500 之间") - if max_matches < 1: - raise ValueError("max_matches 必须大于 0") - - self.timeout = timeout - self.max_retries = max_retries - self.page_size = page_size - self.max_matches = max_matches - self.session = session or requests.Session() - origin = f"{parsed.scheme}://{parsed.netloc}" - self.session.headers.update( - { - "Accept": "application/json, text/plain, */*", - "Origin": origin, - "Referer": urljoin(self.base_url, "sys/login"), - "User-Agent": "shunyunbaoerp-client/0.1", - "X-Requested-With": "XMLHttpRequest", - } - ) - - self._lock = threading.RLock() - self._captcha_fetched = False - self._token: str | None = None - self._token_claims: dict[str, Any] = {} - self._user: dict[str, Any] | None = None - self.last_captcha_content_type = "image/png" - - @property - def user(self) -> dict[str, Any] | None: - """登录用户的副本,不包含口令。""" - - return dict(self._user) if self._user else None - - @property - def is_logged_in(self) -> bool: - """本地是否持有登录结果;最终有效性仍由服务端会话决定。""" - - return self._user is not None - - @property - def token_expires_at(self) -> datetime | None: - """JWT 中的提示性过期时间;不用于验证签名或替代 Cookie。""" - - exp = self._token_claims.get("exp") - if not isinstance(exp, (int, float)): - return None - try: - return datetime.fromtimestamp(exp, tz=timezone.utc) - except (OverflowError, OSError, ValueError): - return None - - def fetch_captcha(self) -> bytes: - """获取验证码图片,并在当前 Session 中保留服务端 Cookie。""" - - with self._lock: - url = self._url(CAPTCHA_PATH) - try: - response = self.session.get( - url, - params={"_": int(time.time() * 1000)}, - timeout=self.timeout, - ) - response.raise_for_status() - except requests.RequestException as exc: - raise ERPTransportError(f"获取验证码失败: {exc}") from exc - - content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip() - if not content_type.startswith("image/"): - raise ERPProtocolError( - f"验证码接口返回了非图片内容: {content_type or 'unknown'}" - ) - if not response.content: - raise ERPProtocolError("验证码接口返回了空图片") - - self.last_captcha_content_type = content_type - self._captcha_fetched = True - return response.content - - def login( - self, - username: str, - password: str, - captcha_code: str, - *, - require_fetched_captcha: bool = True, - ) -> dict[str, Any]: - """使用同一 Session 中取得的验证码登录。 - - 登录失败后验证码通常失效,调用方应重新 fetch_captcha。 - """ - - username = username.strip() - captcha_code = captcha_code.strip() - if not username: - raise ValueError("username 不能为空") - if not password: - raise ValueError("password 不能为空") - if not captcha_code: - raise ValueError("captcha_code 不能为空") - - with self._lock: - if require_fetched_captcha and not self._captcha_fetched: - raise ERPProtocolError("必须先用同一个 ERPClient 实例获取验证码") - try: - data = self._request_json( - "POST", - LOGIN_PATH, - json_body={ - "username": username, - "password": password, - "code": captcha_code, - }, - retryable=False, - authentication_request=True, - ) - finally: - # 验证码按一次性使用处理,无论成功失败都要求重新获取。 - self._captcha_fetched = False - - if not isinstance(data, Mapping): - raise ERPProtocolError("登录成功响应缺少 data 对象") - user = data.get("user") - token = data.get("token") - if not isinstance(user, Mapping): - raise ERPProtocolError("登录成功响应缺少 user 对象") - - self._user = dict(user) - self._token = token if isinstance(token, str) else None - self._token_claims = self._decode_jwt_claims(self._token) - return dict(self._user) - - def query_stock_by_order_number(self, order_number: str) -> list[dict[str, Any]]: - """按 HAR 中的“全部单号”条件查询货运列表。 - - 注意:HAR 样本的输入值匹配返回字段 code,而不是 orderCode。 - """ - - normalized = self._validate_order_number(order_number) - return self._query_stock(order_number_query(normalized)) - - def query_stock_by_created_range( - self, - created_from: date | str, - created_to: date | str, - ) -> list[dict[str, Any]]: - """按 Asia/Shanghai 自然日创建时间闭区间查询货运列表。""" - - start, end = self._validate_created_range(created_from, created_to) - return self._query_stock(created_range_query(start.isoformat(), end.isoformat())) - - def _query_stock(self, query: Mapping[str, Any]) -> list[dict[str, Any]]: - with self._lock: - first_payload = self._stock_payload(query, start=0, page_index=1) - total_raw = self._request_json( - "POST", - STOCK_LIST_TOTAL_PATH, - json_body=first_payload, - retryable=True, - ) - try: - total = int(total_raw or 0) - except (TypeError, ValueError) as exc: - raise ERPProtocolError("listTotal 返回值不是整数") from exc - if total < 0: - raise ERPProtocolError("listTotal 返回了负数") - if total == 0: - return [] - if total > self.max_matches: - raise ERPProtocolError( - f"单号查询返回 {total} 条,超过安全上限 {self.max_matches};" - "请确认查询条件或显式调高 max_matches" - ) - - rows: list[dict[str, Any]] = [] - for start in range(0, total, self.page_size): - page_index = start // self.page_size + 1 - payload = self._stock_payload( - query, - start=start, - page_index=page_index, - ) - page_data = self._request_json( - "POST", - STOCK_LIST_PATH, - json_body=payload, - retryable=True, - ) - page_rows = self._extract_list(page_data, endpoint=STOCK_LIST_PATH) - rows.extend(page_rows) - if not page_rows: - break - - # 服务端分页变化时避免重复记录,并将数量严格限制在已报告总数内。 - unique: list[dict[str, Any]] = [] - seen_ids: set[object] = set() - for row in rows: - row_id = row.get("id") - key = row_id if row_id is not None else json.dumps(row, sort_keys=True, default=str) - if key not in seen_ids: - seen_ids.add(key) - unique.append(row) - return unique[:total] - - def get_stock_details(self, stock_ids: Iterable[int]) -> list[dict[str, Any]]: - """批量查询货运详情;HAR 证明详情 id 与列表 id 一致。""" - - normalized_ids: list[int] = [] - seen: set[int] = set() - for value in stock_ids: - if isinstance(value, bool): - raise ValueError("stock id 必须是正整数") - try: - stock_id = int(value) - except (TypeError, ValueError) as exc: - raise ValueError("stock id 必须是正整数") from exc - if stock_id <= 0: - raise ValueError("stock id 必须是正整数") - if stock_id not in seen: - seen.add(stock_id) - normalized_ids.append(stock_id) - - if not normalized_ids: - return [] - - details: list[dict[str, Any]] = [] - with self._lock: - for offset in range(0, len(normalized_ids), 100): - chunk = normalized_ids[offset : offset + 100] - data = self._request_json( - "POST", - STOCK_DETAIL_PATH, - params={"hist": 0}, - json_body={"ids": chunk}, - retryable=True, - ) - details.extend(self._extract_list(data, endpoint=STOCK_DETAIL_PATH)) - return details - - def get_freight_details(self, order_number: str) -> dict[str, Any]: - """查询单号并合并列表概要和详情,返回可直接 JSON 序列化的对象。""" - - normalized = self._validate_order_number(order_number) - with self._lock: - stocks = self.query_stock_by_order_number(normalized) - if not stocks: - raise ERPNotFoundError("未找到对应货运记录") - return self._join_freight_details( - stocks, - { - "mode": "ORDER_NUMBER", - "orderNumber": normalized, - "matchField": "allcode", - }, - ) - - def get_freight_details_by_created_range( - self, - created_from: date | str, - created_to: date | str, - ) -> dict[str, Any]: - """查询一个最多七天的创建日期窗口并合并详情。""" - - start, end = self._validate_created_range(created_from, created_to) - with self._lock: - stocks = self.query_stock_by_created_range(start, end) - return self._join_freight_details( - stocks, - { - "mode": "CREATED_RANGE", - "createdFrom": start.isoformat(), - "createdTo": end.isoformat(), - }, - ) - - def _join_freight_details( - self, - stocks: list[dict[str, Any]], - query: Mapping[str, Any], - ) -> dict[str, Any]: - ids = [row.get("id") for row in stocks if row.get("id") is not None] - if len(ids) != len(stocks): - raise ERPProtocolError("货运列表存在缺少 id 的记录") - details = self.get_stock_details(ids) - details_by_id: dict[object, dict[str, Any]] = {} - for item in details: - item_id = item.get("id") - if item_id is None: - raise ERPProtocolError("货运详情存在缺少 id 的记录") - existing = details_by_id.get(item_id) - if existing is not None and existing != item: - raise ERPProtocolError("货运详情存在冲突的重复 id") - details_by_id[item_id] = item - - records = [ - { - "stock": stock, - "detail": details_by_id.get(stock["id"]), - } - for stock in stocks - ] - return { - "query": dict(query), - "count": len(records), - "records": records, - } - - def close(self) -> None: - self.session.close() - - def __enter__(self) -> "ERPClient": - return self - - def __exit__(self, *_: object) -> None: - self.close() - - def _stock_payload( - self, - query: Mapping[str, Any], - *, - start: int, - page_index: int, - ) -> dict[str, Any]: - return { - "history": 0, - "length": self.page_size, - "start": start, - "pageTotal": 0, - "pageIndex": page_index, - "store": False, - "columns": stock_columns(), - "queries": [dict(query)], - } - - def _request_json( - self, - method: str, - path: str, - *, - params: Mapping[str, Any] | None = None, - json_body: Mapping[str, Any] | None = None, - retryable: bool, - authentication_request: bool = False, - ) -> Any: - url = self._url(path) - attempts = self.max_retries + 1 if retryable else 1 - response: requests.Response | None = None - - for attempt in range(attempts): - try: - response = self.session.request( - method, - url, - params=params, - json=json_body, - timeout=self.timeout, - ) - except requests.RequestException as exc: - if attempt + 1 >= attempts: - raise ERPTransportError(f"ERP 请求失败: {exc}") from exc - self._backoff(attempt, retry_after=None) - continue - - if response.status_code in self.RETRYABLE_STATUS_CODES and attempt + 1 < attempts: - self._backoff(attempt, retry_after=response.headers.get("Retry-After")) - continue - try: - response.raise_for_status() - except requests.RequestException as exc: - raise ERPTransportError( - f"ERP 返回 HTTP {response.status_code}: {path}" - ) from exc - break - - if response is None: - raise ERPTransportError("ERP 请求未产生响应") - - refreshed_token = response.headers.get("X-Requested-With") - if refreshed_token and refreshed_token.count(".") == 2: - self._token = refreshed_token - self._token_claims = self._decode_jwt_claims(refreshed_token) - - try: - envelope = response.json() - except (requests.JSONDecodeError, ValueError) as exc: - raise ERPProtocolError(f"ERP 返回了非 JSON 内容: {path}") from exc - if not isinstance(envelope, Mapping): - raise ERPProtocolError(f"ERP JSON 顶层不是对象: {path}") - if envelope.get("status") is not True: - code = envelope.get("code") - message = str(envelope.get("msg") or "ERP 请求失败") - is_auth_error = ( - authentication_request - or str(code) == "-2" - or "未登录" in message - or "登录过期" in message - ) - error_type = ERPAuthenticationError if is_auth_error else ERPAPIError - if not authentication_request and is_auth_error: - self._user = None - self._token = None - self._token_claims = {} - raise error_type(message, code=code) - if "data" not in envelope: - raise ERPProtocolError(f"ERP 成功响应缺少 data: {path}") - return envelope["data"] - - @staticmethod - def _extract_list(data: Any, *, endpoint: str) -> list[dict[str, Any]]: - if not isinstance(data, Mapping): - raise ERPProtocolError(f"{endpoint} 的 data 不是对象") - values = data.get("list") - if not isinstance(values, list): - raise ERPProtocolError(f"{endpoint} 的 data.list 不是数组") - if not all(isinstance(value, Mapping) for value in values): - raise ERPProtocolError(f"{endpoint} 的 data.list 含非对象元素") - return [dict(value) for value in values] - - def _url(self, path: str) -> str: - return urljoin(self.base_url, path.lstrip("/")) - - @staticmethod - def _validate_order_number(value: str) -> str: - if not isinstance(value, str): - raise ValueError("order_number 必须是字符串") - normalized = value.strip() - if not normalized: - raise ValueError("order_number 不能为空") - if len(normalized) > 128: - raise ValueError("order_number 不能超过 128 个字符") - if any(ord(char) < 32 or ord(char) == 127 for char in normalized): - raise ValueError("order_number 不能包含控制字符") - return normalized - - @staticmethod - def _validate_created_range( - created_from: date | str, - created_to: date | str, - ) -> tuple[date, date]: - def parse(value: date | str, field: str) -> date: - if isinstance(value, datetime): - raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期") - if isinstance(value, date): - return value - if not isinstance(value, str): - raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期") - try: - parsed = date.fromisoformat(value) - except ValueError as exc: - raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期") from exc - if parsed.isoformat() != value: - raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期") - return parsed - - start = parse(created_from, "created_from") - end = parse(created_to, "created_to") - if end < start: - raise ValueError("created_to 不能早于 created_from") - if end - start > timedelta(days=6): - raise ValueError("创建日期闭区间不能超过 7 天") - return start, end - - @staticmethod - def _decode_jwt_claims(token: str | None) -> dict[str, Any]: - """仅解码 JWT payload 供过期时间展示,不验证其真实性。""" - - if not token: - return {} - parts = token.split(".") - if len(parts) != 3: - return {} - payload = parts[1] + "=" * (-len(parts[1]) % 4) - try: - decoded = base64.urlsafe_b64decode(payload.encode("ascii")) - claims = json.loads(decoded.decode("utf-8")) - except (ValueError, UnicodeDecodeError, json.JSONDecodeError): - return {} - return claims if isinstance(claims, dict) else {} - - @staticmethod - def _backoff(attempt: int, *, retry_after: str | None) -> None: - delay: float - if retry_after: - try: - delay = min(max(float(retry_after), 0.0), 10.0) - except ValueError: - delay = 0.0 - else: - delay = 0.0 - if delay == 0.0: - delay = min(0.5 * (2**attempt) + random.uniform(0.0, 0.25), 5.0) - time.sleep(delay) diff --git a/erp-connector/src/shunyunbaoerp/constants.py b/erp-connector/src/shunyunbaoerp/constants.py deleted file mode 100644 index 9a51ec5..0000000 --- a/erp-connector/src/shunyunbaoerp/constants.py +++ /dev/null @@ -1,131 +0,0 @@ -"""从 HAR 提取的稳定接口常量和列表列定义。""" - -from __future__ import annotations - -from typing import Any - - -CAPTCHA_PATH = "/api/p/code1" -LOGIN_PATH = "/am/auth/login" -STOCK_LIST_TOTAL_PATH = "/am/stock/listTotal" -STOCK_LIST_PATH = "/am/stock/list" -STOCK_DETAIL_PATH = "/am/stock/detail/listByStock" - -# (tableName, colName, fieldName, hasAlias, tableAlias) -_STOCK_COLUMN_SPECS = ( - ("t_stock", "created", "created", 0, "t"), - ("t_stock", "order_code", "orderCode", 0, "t"), - ("t_stock", "printer", "printer", 0, "t"), - ("t_stock", "weight_time", "weightTime", 0, "t"), - ("t_stock", "weight_inputer", "weightInputer", 0, "t"), - ("t_stock", "pkg_time", "pkgTime", 0, "t"), - ("t_stock", "code", "code", 0, "t"), - ("t_stock", "status", "status", 0, "t"), - ("t_stock", "order_status", "orderStatus", 0, "t"), - ("t_stock", "purchase_status", "purchaseStatus", 0, "t"), - ("t_stock", "exp_code", "expCode", 0, "t"), - ("t_stock", "exp_page_code", "expPageCode", 0, "t"), - ("t_stock", "exp_allow_print", "expAllowPrint", 0, "t"), - ("t_stock", "exp_page_status", "expPageStatus", 0, "t"), - ("t_stock", "page_id", "pageId", 0, "t"), - ("t_stock", "upload_time", "uploadTime", 0, "t"), - ("t_stock", "pay_time", "payTime", 0, "t"), - ("t_stock", "shop_day_to_ship", "shopDayToShip", 0, "t"), - ("t_stock", "remark9", "tsremark9", 1, "t"), - ("t_stock", "remark9", "remark9", 0, "t"), - ("t_stock", "detail_qty", "detailQty", 0, "t"), - ("t_stock", "order_qty", "orderQty", 0, "t"), - ("t_stock_detail", "inner_exp_code", "innerExpCode", 0, "t7"), - ("t_stock_detail", "shelf_code", "shelfCode", 0, "t7"), - ("t_stock", "shelf_code", "tsshelfCode", 1, "t"), - ("t_stock", "store_type", "storeType", 0, "t"), - ("t_stock", "order_bag_code", "orderBagCode", 0, "t"), - ("t_stock", "weight_cust_pkg", "weightCustPkg", 0, "t"), - ("t_stock", "weight_consign", "weightConsign", 0, "t"), - ("t_stock", "amt_order", "amtOrder", 0, "t"), - ("t_stock_append", "offline_amount", "offlineAmount", 0, "t8"), - ("t_stock_append", "escrow_amount", "escrowAmount", 0, "t8"), - ("t_stock", "exp_cod", "expCod", 0, "t"), - ("t_stock", "exp_company", "expCompany", 0, "t"), - ("t_stock", "transport", "transport", 0, "t"), - ("t_stock", "order_origin", "orderOrigin", 0, "t"), - ("t_stock", "exp_out_type", "expOutType", 0, "t"), - ("t_stock", "order_platform", "orderPlatform", 0, "t"), - ("t_stock", "exp_pkg_type", "expPkgType", 0, "t"), - ("t_stock", "exp_pkg_code", "expPkgCode", 0, "t"), - ("t_stock", "exp_batch", "expBatch", 0, "t"), - ("t_stock", "exp_ti_huo", "expTiHuo", 0, "t"), - ("t_stock", "print_time", "printTime", 0, "t"), - ("t_stock", "receiver", "receiver", 0, "t"), - ("t_stock", "receiver_tel", "receiverTel", 0, "t"), - ("t_stock", "receiver_addr", "receiverAddr", 0, "t"), - ("t_stock", "receiver_shop_name", "receiverShopName", 0, "t"), - ("t_stock", "receiver_shop_code", "receiverShopCode", 0, "t"), - ("t_stock", "product_name", "productName", 0, "t"), - ("t_stock", "is_cancel", "isCancel", 0, "t"), - ("t_stock", "err_msg", "errMsg", 0, "t"), - ("t_stock", "remark2", "remark2", 0, "t"), - ("t_stock", "remark1", "remark1", 0, "t"), - ("t_stock", "note", "note", 0, "t"), - ("t_store", "name", "name", 0, "t1"), - ("sys_user", "fullname", "sufullname", 1, "t3"), - ("sys_user", "dept_label_path", "deptLabelPath", 0, "t3"), - ("t_stock", "shop_name", "shopName", 0, "t"), - ("t_shop", "shop_id", "shopId", 0, "t6"), - ("t_stock", "remark4", "remark4", 0, "t"), - ("t_stock", "remark7", "remark7", 0, "t"), - ("t_stock", "package_time", "packageTime", 0, "t"), - ("t_stock", "packer", "packer", 0, "t"), - ("t_stock", "pack_type", "packType", 0, "t"), - ("t_stock", "track_status", "trackStatus", 0, "t"), - ("t_stock", "track_desc", "trackDesc", 0, "t"), - ("t_stock", "err_status", "errStatus", 0, "t"), - ("t_stock", "err_time", "errTime", 0, "t"), - ("t_stock", "err_msg", "tserrMsg", 1, "t"), - ("t_stock", "remark2", "tsremark2", 1, "t"), - ("t_stock", "remark1", "tsremark1", 1, "t"), - ("t_stock", "product_volume_str", "productVolumeStr", 0, "t"), -) - - -def stock_columns() -> list[dict[str, Any]]: - """返回新的列定义列表,避免调用方修改全局模板。""" - - return [ - { - "tableName": table_name, - "colName": column_name, - "fieldName": field_name, - "hasAlias": has_alias, - "tableAlias": table_alias, - } - for table_name, column_name, field_name, has_alias, table_alias in _STOCK_COLUMN_SPECS - ] - - -def order_number_query(order_number: str) -> dict[str, Any]: - """生成 HAR 中“全部单号”精确查询条件。""" - - return { - "dvalue": order_number, - "tableName": "t_stock", - "colName": "allcode", - "op": 6, - "type": 0, - "tableAlias": "t", - "optType": 1, - } - - -def created_range_query(created_from: str, created_to: str) -> dict[str, Any]: - """生成 HAR 中“创建时间”闭区间查询条件。""" - - return { - "dvalue": f"{created_from},{created_to}", - "tableName": "t_stock", - "colName": "created", - "op": 0, - "type": 3, - "tableAlias": "t", - "optType": 0, - } diff --git a/erp-connector/src/shunyunbaoerp/errors.py b/erp-connector/src/shunyunbaoerp/errors.py deleted file mode 100644 index fa5e7d8..0000000 --- a/erp-connector/src/shunyunbaoerp/errors.py +++ /dev/null @@ -1,29 +0,0 @@ -"""客户端异常定义。""" - - -class ERPError(RuntimeError): - """所有顺运宝客户端异常的基类。""" - - -class ERPTransportError(ERPError): - """网络、超时或 HTTP 状态异常。""" - - -class ERPProtocolError(ERPError): - """ERP 响应不符合预期协议。""" - - -class ERPAPIError(ERPError): - """ERP 返回 status=false。""" - - def __init__(self, message: str, *, code: object = None) -> None: - super().__init__(message) - self.code = code - - -class ERPAuthenticationError(ERPAPIError): - """登录失败、验证码错误或会话过期。""" - - -class ERPNotFoundError(ERPError): - """未查到对应货运记录。""" diff --git a/erp-connector/src/shunyunbaoerp/normalizer.py b/erp-connector/src/shunyunbaoerp/normalizer.py deleted file mode 100644 index 4f00161..0000000 --- a/erp-connector/src/shunyunbaoerp/normalizer.py +++ /dev/null @@ -1,179 +0,0 @@ -"""把 ERP 原始对象缩减为下游允许保存的货运字段。""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any - -from .errors import ERPProtocolError - - -def normalize_freight_result(result: Mapping[str, Any]) -> dict[str, Any]: - normalized_query = _normalize_query(result.get("query")) - records = result.get("records") - if not isinstance(records, list): - raise ERPProtocolError("货运查询结果缺少 records 数组") - - orders: list[dict[str, Any]] = [] - seen_orders: dict[str, dict[str, Any]] = {} - for record in records: - if not isinstance(record, Mapping): - raise ERPProtocolError("货运查询结果包含非对象记录") - stock = record.get("stock") - detail = record.get("detail") - if not isinstance(stock, Mapping) or not isinstance(detail, Mapping): - raise ERPProtocolError("货运记录缺少 stock 或 detail 对象") - - stock_id = _external_id(stock.get("id"), "货运单") - detail_id = _external_id(detail.get("id"), "货运详情") - if stock_id != detail_id: - raise ERPProtocolError("货运列表和详情身份不一致") - - raw_items = detail.get("details") - if not isinstance(raw_items, list): - raise ERPProtocolError("货运详情缺少 details 数组") - item_by_id: dict[str, dict[str, Any]] = {} - for raw_item in raw_items: - if not isinstance(raw_item, Mapping): - raise ERPProtocolError("货运商品明细包含非对象元素") - item = _normalize_item(raw_item) - existing = item_by_id.get(item["external_item_id"]) - if existing is not None and existing != item: - raise ERPProtocolError("货运商品明细存在冲突的重复身份") - item_by_id[item["external_item_id"]] = item - - order = { - "external_stock_id": stock_id, - "source_code": _text(stock.get("code")), - "platform_order_no": _optional_text(stock.get("orderCode")), - "shop_name": _optional_text( - detail.get("shopName") - if _text(detail.get("shopName")) - else stock.get("shopName") - ), - "source_created_at": _optional_text( - detail.get("created") - if _text(detail.get("created")) - else stock.get("created") - ), - "order_status": _optional_scalar( - stock.get("orderStatus") - if stock.get("orderStatus") is not None - else detail.get("status") - ), - "purchase_status": _optional_scalar(stock.get("purchaseStatus")), - "is_canceled": _optional_bool(stock.get("isCancel")), - "items": sorted( - item_by_id.values(), - key=lambda value: _identity_sort_key( - value["external_item_id"] - ), - ), - } - existing_order = seen_orders.get(stock_id) - if existing_order is not None and existing_order != order: - raise ERPProtocolError("货运查询结果存在冲突的重复货运单") - seen_orders[stock_id] = order - - orders.extend( - sorted( - seen_orders.values(), - key=lambda value: _identity_sort_key( - value["external_stock_id"] - ), - ) - ) - return { - "schema_version": 1, - "query": normalized_query, - "orders": orders, - } - - -def _normalize_query(value: Any) -> dict[str, Any]: - if not isinstance(value, Mapping): - raise ERPProtocolError("货运查询结果缺少 query 对象") - mode = value.get("mode", "ORDER_NUMBER") - if mode == "ORDER_NUMBER": - return {"mode": "ORDER_NUMBER"} - if mode != "CREATED_RANGE": - raise ERPProtocolError("货运查询模式无效") - created_from = _text(value.get("createdFrom")) - created_to = _text(value.get("createdTo")) - if len(created_from) != 10 or len(created_to) != 10: - raise ERPProtocolError("货运日期范围无效") - return { - "mode": "CREATED_RANGE", - "created_from": created_from, - "created_to": created_to, - } - - -def _normalize_item(item: Mapping[str, Any]) -> dict[str, Any]: - title = _text(item.get("productTitle")) - if not title: - title = _text(item.get("detailProductName")) - sku = _text(item.get("sku")) - if not sku: - sku = _text(item.get("variationSku")) - return { - "external_item_id": _external_id(item.get("id"), "货运商品"), - "title": title, - "product_spec": _text(item.get("productSpec")), - "sku": sku, - "quantity": _positive_int_or_none(item.get("productQty")), - "product_thumb_ref": _optional_scalar(item.get("productThumb")), - "purchase_status": _optional_scalar(item.get("purchaseStatus")), - } - - -def _external_id(value: Any, label: str) -> str: - if isinstance(value, bool): - raise ERPProtocolError(f"{label}外部身份无效") - try: - normalized = int(value) - except (TypeError, ValueError) as exc: - raise ERPProtocolError(f"{label}外部身份无效") from exc - if normalized <= 0: - raise ERPProtocolError(f"{label}外部身份无效") - return str(normalized) - - -def _positive_int_or_none(value: Any) -> int | None: - if isinstance(value, bool): - return None - try: - normalized = int(value) - except (TypeError, ValueError): - return None - return normalized if normalized > 0 else None - - -def _text(value: Any) -> str: - return value.strip() if isinstance(value, str) else "" - - -def _optional_text(value: Any) -> str | None: - normalized = _text(value) - return normalized or None - - -def _optional_scalar(value: Any) -> str | None: - if value is None or isinstance(value, (dict, list, tuple, set)): - return None - normalized = str(value).strip() - return normalized or None - - -def _optional_bool(value: Any) -> bool | None: - if isinstance(value, bool): - return value - if value in (0, "0"): - return False - if value in (1, "1"): - return True - return None - - -def _identity_sort_key(value: str) -> tuple[int, str]: - return (int(value), value) diff --git a/erp-connector/tests/test_api.py b/erp-connector/tests/test_api.py deleted file mode 100644 index 1f3db25..0000000 --- a/erp-connector/tests/test_api.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -import json - -import pytest -from fastapi import HTTPException - -from shunyunbaoerp import api - -from test_normalizer import raw_result - - -VALID_KEY = "k" * 32 - - -class FakeClient: - is_logged_in = True - token_expires_at = None - - def get_freight_details(self, order_number: str) -> dict: - assert order_number == "SOURCE-12" - return raw_result() - - def get_freight_details_by_created_range( - self, - created_from: str, - created_to: str, - ) -> dict: - assert (created_from, created_to) == ("2026-07-22", "2026-07-28") - result = raw_result() - result["query"] = { - "mode": "CREATED_RANGE", - "createdFrom": created_from, - "createdTo": created_to, - } - return result - - -def test_query_requires_configured_service_key(monkeypatch) -> None: - monkeypatch.delenv("SHUNYUNBAO_SERVICE_API_KEY", raising=False) - - with pytest.raises(HTTPException) as raised: - api.require_api_key(VALID_KEY) - - assert raised.value.status_code == 503 - assert raised.value.detail == "CONNECTOR_API_KEY_NOT_CONFIGURED" - - -def test_query_rejects_wrong_service_key(monkeypatch) -> None: - monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY) - - with pytest.raises(HTTPException) as raised: - api.require_api_key("x" * 32) - - assert raised.value.status_code == 401 - assert raised.value.detail == "CONNECTOR_API_KEY_INVALID" - - -def test_query_returns_only_normalized_fields(monkeypatch) -> None: - monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY) - monkeypatch.setattr(api, "get_client", lambda: FakeClient()) - api.require_api_key(VALID_KEY) - response = api.query_freight( - api.FreightQueryRequest(order_number="SOURCE-12"), - None, - ) - - assert len(response["orders"][0]["items"]) == 2 - encoded = json.dumps(response, ensure_ascii=False) - assert "receiverTel" not in encoded - assert "receiverAddr" not in encoded - assert "PRIVATE-QUERY" not in encoded - - -def test_created_range_query_returns_allowlisted_range(monkeypatch) -> None: - monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY) - monkeypatch.setattr(api, "get_client", lambda: FakeClient()) - - response = api.query_freight( - api.FreightQueryRequest( - mode="CREATED_RANGE", - created_from="2026-07-22", - created_to="2026-07-28", - ), - None, - ) - - assert response["query"] == { - "mode": "CREATED_RANGE", - "created_from": "2026-07-22", - "created_to": "2026-07-28", - } - - -def test_created_range_rejects_mixed_mode_parameters(monkeypatch) -> None: - monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY) - monkeypatch.setattr(api, "get_client", lambda: FakeClient()) - - with pytest.raises(HTTPException) as raised: - api.query_freight( - api.FreightQueryRequest( - mode="CREATED_RANGE", - order_number="SOURCE-12", - created_from="2026-07-22", - created_to="2026-07-28", - ), - None, - ) - - assert raised.value.status_code == 422 - assert raised.value.detail == "ERP_QUERY_INVALID" diff --git a/erp-connector/tests/test_client.py b/erp-connector/tests/test_client.py deleted file mode 100644 index fa52758..0000000 --- a/erp-connector/tests/test_client.py +++ /dev/null @@ -1,242 +0,0 @@ -from __future__ import annotations - -import base64 -import json -from datetime import date -from urllib.parse import urlparse - -import pytest -import requests - -from shunyunbaoerp import ( - ERPAuthenticationError, - ERPClient, - ERPNotFoundError, - ERPProtocolError, -) - - -class FakeResponse: - def __init__( - self, - payload=None, - *, - status_code: int = 200, - content: bytes = b"", - headers: dict[str, str] | None = None, - ) -> None: - self._payload = payload - self.status_code = status_code - self.content = content - self.headers = headers or {"Content-Type": "application/json"} - - def json(self): - return self._payload - - def raise_for_status(self) -> None: - if self.status_code >= 400: - raise requests.HTTPError(str(self.status_code)) - - -class FakeSession: - def __init__(self, responses: list[FakeResponse]) -> None: - self.responses = list(responses) - self.headers: dict[str, str] = {} - self.calls: list[dict[str, object]] = [] - self.closed = False - - def get(self, url: str, **kwargs): - self.calls.append({"method": "GET", "url": url, **kwargs}) - return self.responses.pop(0) - - def request(self, method: str, url: str, **kwargs): - self.calls.append({"method": method, "url": url, **kwargs}) - return self.responses.pop(0) - - def close(self) -> None: - self.closed = True - - -def envelope(data, *, status=True, msg="成功", code=None): - return {"status": status, "msg": msg, "data": data, "code": code} - - -def fake_jwt(exp: int = 2_000_000_000) -> str: - encode = lambda value: base64.urlsafe_b64encode( - json.dumps(value, separators=(",", ":")).encode() - ).decode().rstrip("=") - return f"{encode({'alg': 'none'})}.{encode({'exp': exp, 'username': 'demo'})}.signature" - - -def test_login_keeps_captcha_and_login_in_same_session() -> None: - session = FakeSession( - [ - FakeResponse( - content=b"image", - headers={"Content-Type": "image/png", "Set-Cookie": "omitted"}, - ), - FakeResponse( - envelope( - { - "user": {"id": 1, "username": "demo"}, - "token": fake_jwt(), - } - ) - ), - ] - ) - client = ERPClient(session=session) - - assert client.fetch_captcha() == b"image" - user = client.login("demo", "secret", "abcd") - - assert user["id"] == 1 - assert client.is_logged_in is True - assert client.token_expires_at is not None - assert urlparse(session.calls[0]["url"]).path == "/api/p/code1" - assert urlparse(session.calls[1]["url"]).path == "/am/auth/login" - assert session.calls[1]["json"] == { - "username": "demo", - "password": "secret", - "code": "abcd", - } - - -def test_login_requires_captcha_from_same_client() -> None: - client = ERPClient(session=FakeSession([])) - - with pytest.raises(ERPProtocolError): - client.login("demo", "secret", "abcd") - - -def test_query_and_detail_reproduce_har_contract() -> None: - stock = { - "id": 99001122, - "code": "FREIGHT-001", - "orderCode": "PLATFORM-001", - } - detail = { - "id": 99001122, - "code": "FREIGHT-001", - "details": [{"id": 2, "productQty": 1}], - } - session = FakeSession( - [ - FakeResponse(envelope(1)), - FakeResponse(envelope({"total": 1, "list": [stock]})), - FakeResponse(envelope({"total": 1, "list": [detail]})), - ] - ) - client = ERPClient(session=session) - - result = client.get_freight_details("FREIGHT-001") - - assert result["count"] == 1 - assert result["records"][0] == {"stock": stock, "detail": detail} - assert [urlparse(call["url"]).path for call in session.calls] == [ - "/am/stock/listTotal", - "/am/stock/list", - "/am/stock/detail/listByStock", - ] - - query = session.calls[0]["json"]["queries"][0] - assert query == { - "dvalue": "FREIGHT-001", - "tableName": "t_stock", - "colName": "allcode", - "op": 6, - "type": 0, - "tableAlias": "t", - "optType": 1, - } - assert len(session.calls[0]["json"]["columns"]) == 72 - assert session.calls[2]["params"] == {"hist": 0} - assert session.calls[2]["json"] == {"ids": [99001122]} - - -def test_created_range_query_reproduces_har_contract_and_deduplicates() -> None: - stock = {"id": 99001122, "code": "FREIGHT-001"} - detail = {"id": 99001122, "details": []} - session = FakeSession( - [ - FakeResponse(envelope(2)), - FakeResponse(envelope({"total": 2, "list": [stock, stock]})), - FakeResponse(envelope({"total": 1, "list": [detail]})), - ] - ) - client = ERPClient(session=session) - - result = client.get_freight_details_by_created_range( - date(2026, 7, 22), - date(2026, 7, 28), - ) - - assert result["count"] == 1 - assert result["query"] == { - "mode": "CREATED_RANGE", - "createdFrom": "2026-07-22", - "createdTo": "2026-07-28", - } - assert session.calls[0]["json"]["queries"] == [ - { - "dvalue": "2026-07-22,2026-07-28", - "tableName": "t_stock", - "colName": "created", - "op": 0, - "type": 3, - "tableAlias": "t", - "optType": 0, - } - ] - - -def test_created_range_rejects_more_than_seven_inclusive_days() -> None: - client = ERPClient(session=FakeSession([])) - - with pytest.raises(ValueError, match="7 天"): - client.query_stock_by_created_range("2026-07-21", "2026-07-28") - - -def test_empty_created_range_does_not_request_details() -> None: - session = FakeSession([FakeResponse(envelope(0))]) - client = ERPClient(session=session) - - result = client.get_freight_details_by_created_range( - "2026-07-28", - "2026-07-28", - ) - - assert result["records"] == [] - assert len(session.calls) == 1 - - -def test_not_found_stops_before_detail_request() -> None: - session = FakeSession([FakeResponse(envelope(0))]) - client = ERPClient(session=session) - - with pytest.raises(ERPNotFoundError): - client.get_freight_details("missing") - - assert len(session.calls) == 1 - - -def test_expired_session_becomes_authentication_error() -> None: - session = FakeSession( - [ - FakeResponse( - envelope(None, status=False, msg="未登录或登录过期", code="-2") - ) - ] - ) - client = ERPClient(session=session) - - with pytest.raises(ERPAuthenticationError): - client.query_stock_by_order_number("FREIGHT-001") - - -def test_rejects_unexpected_bulk_match() -> None: - session = FakeSession([FakeResponse(envelope(101))]) - client = ERPClient(session=session, max_matches=100) - - with pytest.raises(ERPProtocolError, match="安全上限"): - client.query_stock_by_order_number("too-broad") diff --git a/erp-connector/tests/test_normalizer.py b/erp-connector/tests/test_normalizer.py deleted file mode 100644 index 5ac0124..0000000 --- a/erp-connector/tests/test_normalizer.py +++ /dev/null @@ -1,160 +0,0 @@ -from __future__ import annotations - -import json - -import pytest - -from shunyunbaoerp import ERPProtocolError, normalize_freight_result - - -def raw_result() -> dict: - return { - "count": 1, - "query": { - "orderNumber": "PRIVATE-QUERY", - "matchField": "allcode", - }, - "records": [ - { - "stock": { - "id": 12, - "code": "SOURCE-12", - "orderCode": "PLATFORM-12", - "shopName": "来源店铺", - "created": "2026-07-28 10:00:00", - "orderStatus": 2, - "purchaseStatus": 0, - "isCancel": 0, - "receiver": "不得返回", - "receiverTel": "不得返回", - "receiverAddr": "不得返回", - }, - "detail": { - "id": 12, - "shopName": "来源店铺", - "created": "2026-07-28 10:00:00", - "details": [ - { - "id": 102, - "productTitle": "第二件商品", - "productSpec": "蓝色,L", - "sku": "", - "variationSku": "BLUE-L", - "productQty": 2, - "productThumb": 190000002, - "purchaseStatus": 0, - "cost": "不得返回", - }, - { - "id": 101, - "productTitle": "第一件商品", - "productSpec": "灰色,2XL", - "sku": "GRAY-2XL", - "variationSku": "IGNORED", - "productQty": 1, - "productThumb": 190000001, - "purchaseStatus": 0, - }, - ], - "receiver": "不得返回", - }, - } - ], - } - - -def test_normalizes_all_items_and_excludes_private_fields() -> None: - normalized = normalize_freight_result(raw_result()) - - assert normalized["schema_version"] == 1 - assert normalized["query"] == {"mode": "ORDER_NUMBER"} - assert len(normalized["orders"]) == 1 - order = normalized["orders"][0] - assert order["external_stock_id"] == "12" - assert [item["external_item_id"] for item in order["items"]] == [ - "101", - "102", - ] - assert order["items"][1]["sku"] == "BLUE-L" - assert order["items"][1]["quantity"] == 2 - assert order["items"][0]["product_thumb_ref"] == "190000001" - - encoded = json.dumps(normalized, ensure_ascii=False) - for forbidden in ( - "PRIVATE-QUERY", - "receiver", - "receiverTel", - "receiverAddr", - "cost", - "不得返回", - ): - assert forbidden not in encoded - - -def test_invalid_procurement_fields_remain_reviewable() -> None: - source = raw_result() - item = source["records"][0]["detail"]["details"][0] - item["productTitle"] = None - item["detailProductName"] = None - item["sku"] = "" - item["variationSku"] = "" - item["productQty"] = 0 - item["productThumb"] = None - - normalized = normalize_freight_result(source) - result = normalized["orders"][0]["items"][1] - - assert result["title"] == "" - assert result["sku"] == "" - assert result["quantity"] is None - assert result["product_thumb_ref"] is None - - -def test_normalizes_created_range_without_exposing_raw_query() -> None: - source = raw_result() - source["query"] = { - "mode": "CREATED_RANGE", - "createdFrom": "2026-07-22", - "createdTo": "2026-07-28", - "private": "must-not-leak", - } - - normalized = normalize_freight_result(source) - - assert normalized["query"] == { - "mode": "CREATED_RANGE", - "created_from": "2026-07-22", - "created_to": "2026-07-28", - } - assert "must-not-leak" not in json.dumps(normalized) - - -def test_conflicting_duplicate_item_is_rejected() -> None: - source = raw_result() - duplicate = dict(source["records"][0]["detail"]["details"][0]) - duplicate["productTitle"] = "冲突内容" - source["records"][0]["detail"]["details"].append(duplicate) - - with pytest.raises(ERPProtocolError, match="重复身份"): - normalize_freight_result(source) - - -@pytest.mark.parametrize( - "mutation", - [ - lambda source: source["records"][0].update(detail=None), - lambda source: source["records"][0]["detail"].update(id=99), - lambda source: source["records"][0]["detail"].update(details=None), - lambda source: source["records"][0]["detail"]["details"][0].update( - id=None - ), - ], -) -def test_identity_or_detail_shape_failure_rejects_whole_batch( - mutation, -) -> None: - source = raw_result() - mutation(source) - - with pytest.raises(ERPProtocolError): - normalize_freight_result(source) diff --git a/scripts/start-erp-connector.bat b/scripts/start-erp-connector.bat deleted file mode 100644 index a7f03f3..0000000 --- a/scripts/start-erp-connector.bat +++ /dev/null @@ -1,26 +0,0 @@ -@echo off -setlocal - -if "%SHUNYUNBAO_USERNAME%"=="" ( - echo Missing SHUNYUNBAO_USERNAME. - exit /b 1 -) -if "%SHUNYUNBAO_PASSWORD%"=="" ( - echo Missing SHUNYUNBAO_PASSWORD. - exit /b 1 -) -if "%SHUNYUNBAO_SERVICE_API_KEY%"=="" ( - echo Missing SHUNYUNBAO_SERVICE_API_KEY. - exit /b 1 -) - -set "CONNECTOR_ROOT=%~dp0..\erp-connector" -pushd "%CONNECTOR_ROOT%" || exit /b 1 -python -m uvicorn shunyunbaoerp.api:app ^ - --app-dir src ^ - --host 127.0.0.1 ^ - --port 8091 ^ - --no-access-log -set "EXIT_CODE=%ERRORLEVEL%" -popd -exit /b %EXIT_CODE% diff --git a/start-backend.bat b/start-backend.bat index 6cd6467..1caf467 100644 --- a/start-backend.bat +++ b/start-backend.bat @@ -7,12 +7,6 @@ set "LOCAL_TLS_DIR=%PROJECT_ROOT%.local\cmroubao-tls" set "LOCAL_TLS_CERT=%LOCAL_TLS_DIR%\server.crt" set "LOCAL_TLS_KEY=%LOCAL_TLS_DIR%\server.key" -if not defined CMROUBAO_ERP_CONNECTOR_API_KEY ( - if defined SHUNYUNBAO_SERVICE_API_KEY ( - set "CMROUBAO_ERP_CONNECTOR_API_KEY=%SHUNYUNBAO_SERVICE_API_KEY%" - ) -) - if not defined CMROUBAO_HTTP_ADDR ( if exist "%LOCAL_TLS_CERT%" if exist "%LOCAL_TLS_KEY%" ( set "CMROUBAO_HTTP_ADDR=0.0.0.0:8080"