diff --git a/backend-api/cmd/api/main.go b/backend-api/cmd/api/main.go index c03c018..fa5f7c0 100644 --- a/backend-api/cmd/api/main.go +++ b/backend-api/cmd/api/main.go @@ -14,6 +14,7 @@ import ( "cmroubao/backend-api/internal/config" "cmroubao/backend-api/internal/platform/assetstore" "cmroubao/backend-api/internal/platform/database" + "cmroubao/backend-api/internal/platform/erpconnector" "cmroubao/backend-api/internal/platform/migration" "cmroubao/backend-api/internal/platform/password" repository "cmroubao/backend-api/internal/repository/sqlite" @@ -217,6 +218,31 @@ func buildRouter( if err != nil { return nil, err } + erpTimeout := cfg.ERPConnectorTimeout + if erpTimeout <= 0 { + erpTimeout = 90 * time.Second + } + erpClient, err := erpconnector.New( + cfg.ERPConnectorURL, + cfg.ERPConnectorAPIKey, + erpTimeout, + ) + if err != nil { + return nil, err + } + freight, err := usecase.NewFreightService( + store, + erpClient, + clock, + ids, + erpTimeout, + ) + if err != nil { + return nil, err + } + if _, err := freight.RecoverInterrupted(ctx); err != nil { + return nil, err + } passwords, err := password.NewBcrypt(12) if err != nil { return nil, err @@ -249,6 +275,7 @@ func buildRouter( tasks, assets, authorizations, + freight, ) if err != nil { return nil, err @@ -294,6 +321,7 @@ func buildRouter( Tasks: tasks, Results: results, Authorizations: authorizations, + Freight: freight, }, webHandler, ) diff --git a/backend-api/cmd/api/main_test.go b/backend-api/cmd/api/main_test.go index 717bdd3..7bb7cc9 100644 --- a/backend-api/cmd/api/main_test.go +++ b/backend-api/cmd/api/main_test.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "net/http/httptest" + "net/url" "path/filepath" "testing" "time" @@ -158,6 +159,36 @@ func TestBuildRouterRegistersProtectedLogoutRoute(t *testing.T) { response.Header().Get("Location"), ) } + for _, target := range []string{"/freight", "/freight/import"} { + request = httptest.NewRequest(http.MethodGet, target, nil) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusSeeOther || + response.Header().Get("Location") != + "/login?next="+url.QueryEscape(target) { + t.Fatalf( + "%s status/location = %d / %q", + target, + response.Code, + response.Header().Get("Location"), + ) + } + } + request = httptest.NewRequest( + http.MethodGet, + "/api/v1/freight-orders", + nil, + ) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized || + response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf( + "freight API status/cache = %d / %q", + response.Code, + response.Header().Get("Cache-Control"), + ) + } } type stubMigrationStatusReader struct { diff --git a/backend-api/internal/config/config.go b/backend-api/internal/config/config.go index cfbbbdb..29db0e2 100644 --- a/backend-api/internal/config/config.go +++ b/backend-api/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "errors" "net" + "net/url" "path/filepath" "strconv" "strings" @@ -10,40 +11,46 @@ import ( ) const ( - HTTPAddressEnvironment = "CMROUBAO_HTTP_ADDR" - DatabasePathEnvironment = "CMROUBAO_DATABASE_PATH" - AssetDirectoryEnvironment = "CMROUBAO_ASSET_DIR" - TLSCertificateEnvironment = "CMROUBAO_TLS_CERT_FILE" - TLSPrivateKeyEnvironment = "CMROUBAO_TLS_KEY_FILE" - ClaimLeaseEnvironment = "CMROUBAO_CLAIM_LEASE" - RunningLeaseEnvironment = "CMROUBAO_RUNNING_LEASE" - ReadinessTTLEnvironment = "CMROUBAO_READINESS_TTL" + HTTPAddressEnvironment = "CMROUBAO_HTTP_ADDR" + DatabasePathEnvironment = "CMROUBAO_DATABASE_PATH" + AssetDirectoryEnvironment = "CMROUBAO_ASSET_DIR" + TLSCertificateEnvironment = "CMROUBAO_TLS_CERT_FILE" + TLSPrivateKeyEnvironment = "CMROUBAO_TLS_KEY_FILE" + ClaimLeaseEnvironment = "CMROUBAO_CLAIM_LEASE" + RunningLeaseEnvironment = "CMROUBAO_RUNNING_LEASE" + ReadinessTTLEnvironment = "CMROUBAO_READINESS_TTL" + ERPConnectorURLEnvironment = "CMROUBAO_ERP_CONNECTOR_URL" + ERPConnectorAPIKeyEnvironment = "CMROUBAO_ERP_CONNECTOR_API_KEY" - 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 + 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" ) 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 + 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 } func Load(lookup LookupEnvironment) (Config, error) { @@ -137,25 +144,76 @@ 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", + ) + } + } 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, + 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, }, 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 durationEnvironment( lookup LookupEnvironment, name string, diff --git a/backend-api/internal/config/config_test.go b/backend-api/internal/config/config_test.go index 1355727..cc1b2f9 100644 --- a/backend-api/internal/config/config_test.go +++ b/backend-api/internal/config/config_test.go @@ -42,18 +42,30 @@ 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, + ) + } } func TestLoadAcceptsExplicitConfiguration(t *testing.T) { values := map[string]string{ - HTTPAddressEnvironment: "192.0.2.10:9090", - DatabasePathEnvironment: "tmp/test.db", - AssetDirectoryEnvironment: "tmp/assets", - TLSCertificateEnvironment: "tmp/server.crt", - TLSPrivateKeyEnvironment: "tmp/server.key", - ClaimLeaseEnvironment: "15m", - RunningLeaseEnvironment: "45m", - ReadinessTTLEnvironment: "3m", + HTTPAddressEnvironment: "192.0.2.10:9090", + DatabasePathEnvironment: "tmp/test.db", + AssetDirectoryEnvironment: "tmp/assets", + TLSCertificateEnvironment: "tmp/server.crt", + TLSPrivateKeyEnvironment: "tmp/server.key", + ClaimLeaseEnvironment: "15m", + RunningLeaseEnvironment: "45m", + ReadinessTTLEnvironment: "3m", + ERPConnectorURLEnvironment: "http://localhost:18091", + ERPConnectorAPIKeyEnvironment: "12345678901234567890123456789012", } cfg, err := Load(mapEnvironment(values)) @@ -88,6 +100,14 @@ 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, + ) + } } func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) { @@ -95,6 +115,24 @@ 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: "blank explicit address", values: map[string]string{ diff --git a/backend-api/internal/domain/freight.go b/backend-api/internal/domain/freight.go new file mode 100644 index 0000000..409a70a --- /dev/null +++ b/backend-api/internal/domain/freight.go @@ -0,0 +1,140 @@ +package domain + +import "time" + +const ( + FreightSourceShunyunbao = "SHUNYUNBAO" + FreightSyncOrderNumber = "ORDER_NUMBER" +) + +type FreightSyncStatus string + +const ( + FreightSyncPending FreightSyncStatus = "PENDING" + FreightSyncRunning FreightSyncStatus = "RUNNING" + FreightSyncSucceeded FreightSyncStatus = "SUCCEEDED" + FreightSyncFailed FreightSyncStatus = "FAILED" +) + +type FreightSyncRun struct { + ID string + CreatorSubject string + CreatedByUserID string + Mode string + OrderNumber string + QuerySHA256 string + Status FreightSyncStatus + ErrorCode *string + OrderCount int + ItemCount int + CreatedAt time.Time + StartedAt *time.Time + FinishedAt *time.Time +} + +type FreightOrder struct { + ID string + CreatorSubject string + SourceSystem string + ExternalStockID string + SourceCode string + PlatformOrderNo *string + ShopName *string + SourceCreatedAt *time.Time + OrderStatus *string + PurchaseStatus *string + IsCanceled *bool + CanonicalSHA256 string + Revision int + FirstSyncRunID string + LastSyncRunID string + CreatedAt time.Time + UpdatedAt time.Time + ItemCount int +} + +type FreightOrderItem struct { + ID string + FreightOrderID string + ExternalItemID string + Title string + ProductSpec string + SKU string + Quantity *int + ProductThumbRef *string + PurchaseStatus *string + CanonicalSHA256 string + Revision int + IsPresent bool + FirstSyncRunID string + LastSyncRunID string + CreatedAt time.Time + UpdatedAt time.Time +} + +type FreightOrderDetail struct { + Order FreightOrder + Items []FreightOrderItem +} + +type FreightSourceBatch struct { + SchemaVersion int `json:"schema_version"` + Query FreightSourceQuery `json:"query"` + Orders []FreightSourceOrder `json:"orders"` +} + +type FreightSourceQuery struct { + Mode string `json:"mode"` +} + +type FreightSourceOrder struct { + ExternalStockID string `json:"external_stock_id"` + SourceCode string `json:"source_code"` + PlatformOrderNo *string `json:"platform_order_no"` + ShopName *string `json:"shop_name"` + SourceCreatedAt *string `json:"source_created_at"` + OrderStatus *string `json:"order_status"` + PurchaseStatus *string `json:"purchase_status"` + IsCanceled *bool `json:"is_canceled"` + Items []FreightSourceItem `json:"items"` +} + +type FreightSourceItem struct { + ExternalItemID string `json:"external_item_id"` + Title string `json:"title"` + ProductSpec string `json:"product_spec"` + SKU string `json:"sku"` + Quantity *int `json:"quantity"` + ProductThumbRef *string `json:"product_thumb_ref"` + PurchaseStatus *string `json:"purchase_status"` +} + +type FreightImportBatch struct { + Orders []FreightImportOrder +} + +type FreightImportOrder struct { + ID string + ExternalStockID string + SourceCode string + PlatformOrderNo *string + ShopName *string + SourceCreatedAt *time.Time + OrderStatus *string + PurchaseStatus *string + IsCanceled *bool + CanonicalSHA256 string + Items []FreightImportItem +} + +type FreightImportItem struct { + ID string + ExternalItemID string + Title string + ProductSpec string + SKU string + Quantity *int + ProductThumbRef *string + PurchaseStatus *string + CanonicalSHA256 string +} diff --git a/backend-api/internal/platform/erpconnector/client.go b/backend-api/internal/platform/erpconnector/client.go new file mode 100644 index 0000000..13c6b37 --- /dev/null +++ b/backend-api/internal/platform/erpconnector/client.go @@ -0,0 +1,114 @@ +package erpconnector + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" + + "cmroubao/backend-api/internal/domain" +) + +var ( + ErrNotConfigured = errors.New("ERP connector is not configured") + ErrSessionRequired = errors.New("ERP session is required") + ErrNotFound = errors.New("ERP freight order not found") + ErrUnavailable = errors.New("ERP connector is unavailable") + ErrProtocol = errors.New("ERP connector protocol is invalid") +) + +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) { + if client.apiKey == "" { + return domain.FreightSourceBatch{}, ErrNotConfigured + } + body, err := json.Marshal(map[string]string{"order_number": orderNumber}) + 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 != "ORDER_NUMBER" || + result.Orders == nil { + 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 new file mode 100644 index 0000000..a20e02d --- /dev/null +++ b/backend-api/internal/platform/erpconnector/client_test.go @@ -0,0 +1,143 @@ +package erpconnector + +import ( + "context" + "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 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/platform/migration/claims_migration_test.go b/backend-api/internal/platform/migration/claims_migration_test.go index fdae6b9..7a1dfb7 100644 --- a/backend-api/internal/platform/migration/claims_migration_test.go +++ b/backend-api/internal/platform/migration/claims_migration_test.go @@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { if applied, err := runner.Up(ctx); err != nil { t.Fatalf("initial Up() error = %v", err) - } else if applied != 11 { - t.Fatalf("initial Up() applied = %d, want 11", applied) + } else if applied != 12 { + t.Fatalf("initial Up() applied = %d, want 12", applied) + } + if err := runner.Down(ctx); err != nil { + t.Fatalf("initial Down(v12) error = %v", err) } if err := runner.Down(ctx); err != nil { t.Fatalf("initial Down(v11) error = %v", err) @@ -62,9 +65,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { seedClaimsHistoricalFixture(t, db) if applied, err := runner.Up(ctx); err != nil { - t.Fatalf("Up(v5-v11) over historical data error = %v", err) - } else if applied != 7 { - t.Fatalf("Up(v5-v11) applied = %d, want 7", applied) + t.Fatalf("Up(v5-v12) over historical data error = %v", err) + } else if applied != 8 { + t.Fatalf("Up(v5-v12) applied = %d, want 8", applied) + } + assertClaimsHistory(t, db, true) + + if err := runner.Down(ctx); err != nil { + t.Fatalf("Down(v12) with compatible history error = %v", err) } assertClaimsHistory(t, db, true) @@ -109,9 +117,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { assertClaimsHistory(t, db, false) if applied, err := runner.Up(ctx); err != nil { - t.Fatalf("final Up(v4-v11) error = %v", err) - } else if applied != 8 { - t.Fatalf("final Up(v4-v11) applied = %d, want 8", applied) + t.Fatalf("final Up(v4-v12) error = %v", err) + } else if applied != 9 { + t.Fatalf("final Up(v4-v12) applied = %d, want 9", applied) } assertClaimsHistory(t, db, true) } @@ -347,6 +355,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) { t.Fatalf("insert v4 audit event: %v", err) } + if err := runner.Down(ctx); err != nil { + t.Fatalf("Down(v12) error = %v", err) + } if err := runner.Down(ctx); err != nil { t.Fatalf("Down(v11) error = %v", err) } diff --git a/backend-api/internal/platform/migration/runner_test.go b/backend-api/internal/platform/migration/runner_test.go index ca0445a..c219091 100644 --- a/backend-api/internal/platform/migration/runner_test.go +++ b/backend-api/internal/platform/migration/runner_test.go @@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { if err != nil { t.Fatalf("Up() error = %v", err) } - if applied != 11 { - t.Fatalf("Up() applied = %d, want 11", applied) + if applied != 12 { + t.Fatalf("Up() applied = %d, want 12", applied) } assertStatuses(t, runner, map[int64]bool{ 1: true, @@ -42,6 +42,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 9: true, 10: true, 11: true, + 12: true, }) applied, err = runner.Up(context.Background()) @@ -66,7 +67,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 8: true, 9: true, 10: true, - 11: false, + 11: true, + 12: false, }) applied, err = runner.Up(context.Background()) @@ -88,6 +90,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 9: true, 10: true, 11: true, + 12: true, }) } diff --git a/backend-api/internal/repository/sqlite/auth_repository_test.go b/backend-api/internal/repository/sqlite/auth_repository_test.go index 66304ba..1e9bef1 100644 --- a/backend-api/internal/repository/sqlite/auth_repository_test.go +++ b/backend-api/internal/repository/sqlite/auth_repository_test.go @@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks( if err != nil { t.Fatalf("migration.New() error = %v", err) } + if err := runner.Down(context.Background()); err != nil { + t.Fatalf("Down(v12) error = %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("Down(v11) error = %v", err) } @@ -423,9 +426,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks( t.Fatal("purchase_tasks was lost during auth migration rollback") } if applied, err := runner.Up(context.Background()); err != nil { - t.Fatalf("Up(v3-v11) error = %v", err) - } else if applied != 9 { - t.Fatalf("Up(v3-v11) applied = %d, want 9", applied) + t.Fatalf("Up(v3-v12) error = %v", err) + } else if applied != 10 { + t.Fatalf("Up(v3-v12) applied = %d, want 10", applied) } } diff --git a/backend-api/internal/repository/sqlite/freight_repository.go b/backend-api/internal/repository/sqlite/freight_repository.go new file mode 100644 index 0000000..089b9ec --- /dev/null +++ b/backend-api/internal/repository/sqlite/freight_repository.go @@ -0,0 +1,630 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "time" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/usecase" +) + +func (store *Store) CreateFreightSync( + ctx context.Context, + run domain.FreightSyncRun, + idempotencyKey string, + requestSHA256 string, +) (domain.FreightSyncRun, bool, error) { + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return domain.FreightSyncRun{}, false, repositoryFailure(err) + } + defer tx.Rollback() + existing, err := scanFreightSync(tx.QueryRowContext( + ctx, + freightSyncSelect+` + WHERE creator_subject = ? AND idempotency_key = ?`, + run.CreatorSubject, + idempotencyKey, + )) + if err == nil { + var storedHash string + if err := tx.QueryRowContext( + ctx, + `SELECT request_sha256 + FROM erp_sync_runs + WHERE creator_subject = ? AND idempotency_key = ?`, + run.CreatorSubject, + idempotencyKey, + ).Scan(&storedHash); err != nil { + return domain.FreightSyncRun{}, false, repositoryFailure(err) + } + if storedHash != requestSHA256 { + return domain.FreightSyncRun{}, false, usecase.ErrIdempotencyConflict + } + return existing, false, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return domain.FreightSyncRun{}, false, repositoryFailure(err) + } + _, err = tx.ExecContext( + ctx, + `INSERT INTO erp_sync_runs ( + id, creator_subject, created_by_user_id, mode, order_number, + query_sha256, idempotency_key, request_sha256, status, + order_count, item_count, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?)`, + run.ID, + run.CreatorSubject, + run.CreatedByUserID, + run.Mode, + run.OrderNumber, + run.QuerySHA256, + idempotencyKey, + requestSHA256, + run.Status, + formatTimestamp(run.CreatedAt), + ) + if err != nil { + return domain.FreightSyncRun{}, false, repositoryFailure(err) + } + if err := tx.Commit(); err != nil { + return domain.FreightSyncRun{}, false, repositoryFailure(err) + } + return run, true, nil +} + +func (store *Store) StartFreightSync( + ctx context.Context, + runID string, + startedAt time.Time, +) error { + result, err := store.db.ExecContext( + ctx, + `UPDATE erp_sync_runs + SET status = 'RUNNING', started_at = ? + WHERE id = ? AND status = 'PENDING'`, + formatTimestamp(startedAt), + runID, + ) + if err != nil { + return repositoryFailure(err) + } + changed, err := result.RowsAffected() + if err != nil { + return repositoryFailure(err) + } + if changed != 1 { + return usecase.ErrTaskStateConflict + } + return nil +} + +func (store *Store) CompleteFreightSync( + ctx context.Context, + run domain.FreightSyncRun, + batch domain.FreightImportBatch, + finishedAt time.Time, +) error { + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return repositoryFailure(err) + } + defer tx.Rollback() + var status string + if err := tx.QueryRowContext( + ctx, + `SELECT status FROM erp_sync_runs WHERE id = ?`, + run.ID, + ).Scan(&status); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return usecase.ErrRepositoryNotFound + } + return repositoryFailure(err) + } + if status != string(domain.FreightSyncRunning) { + return usecase.ErrTaskStateConflict + } + itemCount := 0 + for _, order := range batch.Orders { + orderID, err := upsertFreightOrder( + ctx, + tx, + run, + order, + finishedAt, + ) + if err != nil { + return err + } + if _, err := tx.ExecContext( + ctx, + `UPDATE freight_order_items + SET is_present = 0, last_sync_run_id = ?, updated_at = ? + WHERE freight_order_id = ?`, + run.ID, + formatTimestamp(finishedAt), + orderID, + ); err != nil { + return repositoryFailure(err) + } + for _, item := range order.Items { + if err := upsertFreightOrderItem( + ctx, + tx, + run.ID, + orderID, + item, + finishedAt, + ); err != nil { + return err + } + itemCount++ + } + } + result, err := tx.ExecContext( + ctx, + `UPDATE erp_sync_runs + SET status = 'SUCCEEDED', error_code = NULL, order_count = ?, + item_count = ?, finished_at = ? + WHERE id = ? AND status = 'RUNNING'`, + len(batch.Orders), + itemCount, + formatTimestamp(finishedAt), + run.ID, + ) + if err != nil { + return repositoryFailure(err) + } + changed, err := result.RowsAffected() + if err != nil { + return repositoryFailure(err) + } + if changed != 1 { + return usecase.ErrTaskStateConflict + } + if err := tx.Commit(); err != nil { + return repositoryFailure(err) + } + return nil +} + +func upsertFreightOrder( + ctx context.Context, + tx *sql.Tx, + run domain.FreightSyncRun, + order domain.FreightImportOrder, + now time.Time, +) (string, error) { + _, err := tx.ExecContext( + ctx, + `INSERT INTO freight_orders ( + id, creator_subject, source_system, external_stock_id, + source_code, platform_order_no, shop_name, source_created_at, + order_status, purchase_status, is_canceled, canonical_sha256, + revision, first_sync_run_id, last_sync_run_id, created_at, updated_at + ) VALUES (?, ?, 'SHUNYUNBAO', ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) + ON CONFLICT (creator_subject, source_system, external_stock_id) + DO UPDATE SET + source_code = excluded.source_code, + platform_order_no = excluded.platform_order_no, + shop_name = excluded.shop_name, + source_created_at = excluded.source_created_at, + order_status = excluded.order_status, + purchase_status = excluded.purchase_status, + is_canceled = excluded.is_canceled, + revision = CASE + WHEN freight_orders.canonical_sha256 != excluded.canonical_sha256 + THEN freight_orders.revision + 1 + ELSE freight_orders.revision + END, + canonical_sha256 = excluded.canonical_sha256, + last_sync_run_id = excluded.last_sync_run_id, + updated_at = excluded.updated_at`, + order.ID, + run.CreatorSubject, + order.ExternalStockID, + order.SourceCode, + nullableString(order.PlatformOrderNo), + nullableString(order.ShopName), + nullableTimestamp(order.SourceCreatedAt), + nullableString(order.OrderStatus), + nullableString(order.PurchaseStatus), + nullableBool(order.IsCanceled), + order.CanonicalSHA256, + run.ID, + run.ID, + formatTimestamp(now), + formatTimestamp(now), + ) + if err != nil { + return "", repositoryFailure(err) + } + var orderID string + if err := tx.QueryRowContext( + ctx, + `SELECT id FROM freight_orders + WHERE creator_subject = ? AND source_system = 'SHUNYUNBAO' + AND external_stock_id = ?`, + run.CreatorSubject, + order.ExternalStockID, + ).Scan(&orderID); err != nil { + return "", repositoryFailure(err) + } + return orderID, nil +} + +func upsertFreightOrderItem( + ctx context.Context, + tx *sql.Tx, + runID, orderID string, + item domain.FreightImportItem, + now time.Time, +) error { + _, err := tx.ExecContext( + ctx, + `INSERT INTO freight_order_items ( + id, freight_order_id, external_item_id, title, product_spec, + sku, quantity, product_thumb_ref, purchase_status, + canonical_sha256, revision, is_present, first_sync_run_id, + last_sync_run_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?) + ON CONFLICT (freight_order_id, external_item_id) + DO UPDATE SET + title = excluded.title, + product_spec = excluded.product_spec, + sku = excluded.sku, + quantity = excluded.quantity, + product_thumb_ref = excluded.product_thumb_ref, + purchase_status = excluded.purchase_status, + revision = CASE + WHEN freight_order_items.canonical_sha256 != excluded.canonical_sha256 + THEN freight_order_items.revision + 1 + ELSE freight_order_items.revision + END, + canonical_sha256 = excluded.canonical_sha256, + is_present = 1, + last_sync_run_id = excluded.last_sync_run_id, + updated_at = excluded.updated_at`, + item.ID, + orderID, + item.ExternalItemID, + item.Title, + item.ProductSpec, + item.SKU, + nullableFreightQuantity(item.Quantity), + nullableString(item.ProductThumbRef), + nullableString(item.PurchaseStatus), + item.CanonicalSHA256, + runID, + runID, + formatTimestamp(now), + formatTimestamp(now), + ) + if err != nil { + return repositoryFailure(err) + } + return nil +} + +func (store *Store) FailFreightSync( + ctx context.Context, + runID, errorCode string, + finishedAt time.Time, +) error { + result, err := store.db.ExecContext( + ctx, + `UPDATE erp_sync_runs + SET status = 'FAILED', error_code = ?, finished_at = ? + WHERE id = ? AND status IN ('PENDING', 'RUNNING')`, + errorCode, + formatTimestamp(finishedAt), + runID, + ) + if err != nil { + return repositoryFailure(err) + } + changed, err := result.RowsAffected() + if err != nil { + return repositoryFailure(err) + } + if changed != 1 { + return usecase.ErrTaskStateConflict + } + return nil +} + +func (store *Store) RecoverFreightSyncs( + ctx context.Context, + finishedAt time.Time, +) (int64, error) { + result, err := store.db.ExecContext( + ctx, + `UPDATE erp_sync_runs + SET status = 'FAILED', error_code = 'PROCESS_INTERRUPTED', + finished_at = ? + WHERE status IN ('PENDING', 'RUNNING')`, + formatTimestamp(finishedAt), + ) + if err != nil { + return 0, repositoryFailure(err) + } + changed, err := result.RowsAffected() + if err != nil { + return 0, repositoryFailure(err) + } + return changed, nil +} + +func (store *Store) GetFreightSync( + ctx context.Context, + creatorSubject, runID string, +) (domain.FreightSyncRun, error) { + run, err := scanFreightSync(store.db.QueryRowContext( + ctx, + freightSyncSelect+` + WHERE creator_subject = ? AND id = ?`, + creatorSubject, + runID, + )) + if errors.Is(err, sql.ErrNoRows) { + return domain.FreightSyncRun{}, usecase.ErrRepositoryNotFound + } + if err != nil { + return domain.FreightSyncRun{}, repositoryFailure(err) + } + return run, nil +} + +func (store *Store) ListFreightOrders( + ctx context.Context, + creatorSubject string, + limit int, +) ([]domain.FreightOrder, error) { + rows, err := store.db.QueryContext( + ctx, + freightOrderSelect+` + WHERE freight_orders.creator_subject = ? + ORDER BY freight_orders.updated_at DESC, freight_orders.id DESC + LIMIT ?`, + creatorSubject, + limit, + ) + if err != nil { + return nil, repositoryFailure(err) + } + defer rows.Close() + orders := make([]domain.FreightOrder, 0) + for rows.Next() { + order, err := scanFreightOrder(rows) + if err != nil { + return nil, repositoryFailure(err) + } + orders = append(orders, order) + } + if err := rows.Err(); err != nil { + return nil, repositoryFailure(err) + } + return orders, nil +} + +func (store *Store) GetFreightOrder( + ctx context.Context, + creatorSubject, orderID string, +) (domain.FreightOrderDetail, error) { + order, err := scanFreightOrder(store.db.QueryRowContext( + ctx, + freightOrderSelect+` + WHERE freight_orders.creator_subject = ? AND freight_orders.id = ?`, + creatorSubject, + orderID, + )) + if errors.Is(err, sql.ErrNoRows) { + return domain.FreightOrderDetail{}, usecase.ErrRepositoryNotFound + } + if err != nil { + return domain.FreightOrderDetail{}, repositoryFailure(err) + } + rows, err := store.db.QueryContext( + ctx, + `SELECT + id, freight_order_id, external_item_id, title, product_spec, + sku, quantity, product_thumb_ref, purchase_status, + canonical_sha256, revision, is_present, first_sync_run_id, + last_sync_run_id, created_at, updated_at + FROM freight_order_items + WHERE freight_order_id = ? AND is_present = 1 + ORDER BY CAST(external_item_id AS INTEGER), external_item_id`, + order.ID, + ) + if err != nil { + return domain.FreightOrderDetail{}, repositoryFailure(err) + } + defer rows.Close() + items := make([]domain.FreightOrderItem, 0) + for rows.Next() { + item, err := scanFreightOrderItem(rows) + if err != nil { + return domain.FreightOrderDetail{}, repositoryFailure(err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return domain.FreightOrderDetail{}, repositoryFailure(err) + } + return domain.FreightOrderDetail{Order: order, Items: items}, nil +} + +const freightSyncSelect = `SELECT + id, creator_subject, created_by_user_id, mode, order_number, + query_sha256, status, error_code, order_count, item_count, + created_at, started_at, finished_at + FROM erp_sync_runs + ` + +func scanFreightSync(scanner rowScanner) (domain.FreightSyncRun, error) { + var run domain.FreightSyncRun + var errorCode sql.NullString + var createdAt string + var startedAt sql.NullString + var finishedAt sql.NullString + err := scanner.Scan( + &run.ID, + &run.CreatorSubject, + &run.CreatedByUserID, + &run.Mode, + &run.OrderNumber, + &run.QuerySHA256, + &run.Status, + &errorCode, + &run.OrderCount, + &run.ItemCount, + &createdAt, + &startedAt, + &finishedAt, + ) + if err != nil { + return domain.FreightSyncRun{}, err + } + if errorCode.Valid { + run.ErrorCode = &errorCode.String + } + run.CreatedAt, err = parseTimestamp(createdAt) + if err != nil { + return domain.FreightSyncRun{}, err + } + run.StartedAt, err = parseNullableTimestamp(startedAt) + if err != nil { + return domain.FreightSyncRun{}, err + } + run.FinishedAt, err = parseNullableTimestamp(finishedAt) + return run, err +} + +const freightOrderSelect = `SELECT + freight_orders.id, freight_orders.creator_subject, + freight_orders.source_system, freight_orders.external_stock_id, + freight_orders.source_code, freight_orders.platform_order_no, + freight_orders.shop_name, freight_orders.source_created_at, + freight_orders.order_status, freight_orders.purchase_status, + freight_orders.is_canceled, freight_orders.canonical_sha256, + freight_orders.revision, freight_orders.first_sync_run_id, + freight_orders.last_sync_run_id, freight_orders.created_at, + freight_orders.updated_at, + (SELECT count(*) FROM freight_order_items + WHERE freight_order_id = freight_orders.id AND is_present = 1) + FROM freight_orders + ` + +func scanFreightOrder(scanner rowScanner) (domain.FreightOrder, error) { + var order domain.FreightOrder + var platformOrderNo, shopName, sourceCreatedAt sql.NullString + var orderStatus, purchaseStatus sql.NullString + var isCanceled sql.NullBool + var createdAt, updatedAt string + err := scanner.Scan( + &order.ID, + &order.CreatorSubject, + &order.SourceSystem, + &order.ExternalStockID, + &order.SourceCode, + &platformOrderNo, + &shopName, + &sourceCreatedAt, + &orderStatus, + &purchaseStatus, + &isCanceled, + &order.CanonicalSHA256, + &order.Revision, + &order.FirstSyncRunID, + &order.LastSyncRunID, + &createdAt, + &updatedAt, + &order.ItemCount, + ) + if err != nil { + return domain.FreightOrder{}, err + } + order.PlatformOrderNo = optionalString(platformOrderNo) + order.ShopName = optionalString(shopName) + order.OrderStatus = optionalString(orderStatus) + order.PurchaseStatus = optionalString(purchaseStatus) + if isCanceled.Valid { + order.IsCanceled = &isCanceled.Bool + } + if sourceCreatedAt.Valid { + parsed, err := parseTimestamp(sourceCreatedAt.String) + if err != nil { + return domain.FreightOrder{}, err + } + order.SourceCreatedAt = &parsed + } + order.CreatedAt, err = parseTimestamp(createdAt) + if err != nil { + return domain.FreightOrder{}, err + } + order.UpdatedAt, err = parseTimestamp(updatedAt) + return order, err +} + +func scanFreightOrderItem(scanner rowScanner) (domain.FreightOrderItem, error) { + var item domain.FreightOrderItem + var quantity sql.NullInt64 + var thumb, purchaseStatus sql.NullString + var createdAt, updatedAt string + err := scanner.Scan( + &item.ID, + &item.FreightOrderID, + &item.ExternalItemID, + &item.Title, + &item.ProductSpec, + &item.SKU, + &quantity, + &thumb, + &purchaseStatus, + &item.CanonicalSHA256, + &item.Revision, + &item.IsPresent, + &item.FirstSyncRunID, + &item.LastSyncRunID, + &createdAt, + &updatedAt, + ) + if err != nil { + return domain.FreightOrderItem{}, err + } + if quantity.Valid { + value := int(quantity.Int64) + item.Quantity = &value + } + item.ProductThumbRef = optionalString(thumb) + item.PurchaseStatus = optionalString(purchaseStatus) + item.CreatedAt, err = parseTimestamp(createdAt) + if err != nil { + return domain.FreightOrderItem{}, err + } + item.UpdatedAt, err = parseTimestamp(updatedAt) + return item, err +} + +func nullableTimestamp(value *time.Time) any { + if value == nil { + return nil + } + return formatTimestamp(*value) +} + +func nullableFreightQuantity(value *int) any { + if value == nil { + return nil + } + return *value +} + +func optionalString(value sql.NullString) *string { + if !value.Valid { + return nil + } + return &value.String +} diff --git a/backend-api/internal/repository/sqlite/freight_repository_test.go b/backend-api/internal/repository/sqlite/freight_repository_test.go new file mode 100644 index 0000000..d251bb7 --- /dev/null +++ b/backend-api/internal/repository/sqlite/freight_repository_test.go @@ -0,0 +1,253 @@ +package sqlite_test + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/platform/migration" + repository "cmroubao/backend-api/internal/repository/sqlite" + "cmroubao/backend-api/internal/usecase" +) + +func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) { + db := openDatabase(t) + store, err := repository.New(db) + if err != nil { + t.Fatalf("repository.New() error = %v", err) + } + ctx := context.Background() + now := time.Date(2026, 7, 28, 1, 2, 3, 0, time.UTC) + userID := uuid(900) + seedFreightUser(t, db, userID, now) + + first := freightRun(901, userID, now) + stored, created, err := store.CreateFreightSync( + ctx, + first, + "freight-key-1", + repeatHex("1"), + ) + if err != nil || !created || stored.ID != first.ID { + t.Fatalf("CreateFreightSync() = %+v, %t, %v", stored, created, err) + } + replayed, created, err := store.CreateFreightSync( + ctx, + freightRun(902, userID, now), + "freight-key-1", + repeatHex("1"), + ) + if err != nil || created || replayed.ID != first.ID { + t.Fatalf("sync replay = %+v, %t, %v", replayed, created, err) + } + _, _, err = store.CreateFreightSync( + ctx, + freightRun(903, userID, now), + "freight-key-1", + repeatHex("2"), + ) + if !errors.Is(err, usecase.ErrIdempotencyConflict) { + t.Fatalf("conflicting replay error = %v", err) + } + + if err := store.StartFreightSync(ctx, first.ID, now.Add(time.Second)); err != nil { + t.Fatalf("StartFreightSync() error = %v", err) + } + batch := freightBatch(910, "a", "b") + if err := store.CompleteFreightSync( + ctx, + first, + batch, + now.Add(2*time.Second), + ); err != nil { + t.Fatalf("CompleteFreightSync() error = %v", err) + } + orders, err := store.ListFreightOrders(ctx, "local-admin", 10) + if err != nil || len(orders) != 1 || orders[0].ItemCount != 2 || + orders[0].Revision != 1 { + t.Fatalf("orders = %+v, error = %v", orders, err) + } + detail, err := store.GetFreightOrder(ctx, "local-admin", orders[0].ID) + if err != nil || len(detail.Items) != 2 { + t.Fatalf("detail = %+v, error = %v", detail, err) + } + + second := freightRun(904, userID, now.Add(time.Minute)) + createAndStartFreightRun(t, store, second, "freight-key-2", "3") + unchanged := freightBatch(920, "a", "b") + if err := store.CompleteFreightSync( + ctx, + second, + unchanged, + now.Add(time.Minute+time.Second), + ); err != nil { + t.Fatalf("unchanged import error = %v", err) + } + detail, _ = store.GetFreightOrder(ctx, "local-admin", orders[0].ID) + if detail.Order.Revision != 1 || + detail.Items[0].Revision != 1 || + detail.Items[1].Revision != 1 { + t.Fatalf("unchanged revisions = %+v", detail) + } + + third := freightRun(905, userID, now.Add(2*time.Minute)) + createAndStartFreightRun(t, store, third, "freight-key-3", "4") + changed := freightBatch(930, "a", "c") + changed.Orders[0].CanonicalSHA256 = repeatHex("d") + changed.Orders[0].Items[1].Title = "变化后的商品" + changed.Orders[0].Items[1].CanonicalSHA256 = repeatHex("e") + if err := store.CompleteFreightSync( + ctx, + third, + changed, + now.Add(2*time.Minute+time.Second), + ); err != nil { + t.Fatalf("changed import error = %v", err) + } + detail, _ = store.GetFreightOrder(ctx, "local-admin", orders[0].ID) + if detail.Order.Revision != 2 || + detail.Items[0].Revision != 1 || + detail.Items[1].Revision != 2 { + t.Fatalf("changed revisions = %+v", detail) + } + runner, err := migration.New(db) + if err != nil { + t.Fatalf("migration.New() error = %v", err) + } + if err := runner.Down(ctx); err == nil { + t.Fatal("freight migration down succeeded with retained source data") + } + var retained int + if err := db.QueryRow(`SELECT count(*) FROM freight_orders`).Scan( + &retained, + ); err != nil || retained != 1 { + t.Fatalf("retained freight orders = %d, error = %v", retained, err) + } +} + +func TestFreightSyncRecoveryAndFailedBatchDoNotPersistOrders(t *testing.T) { + db := openDatabase(t) + store, _ := repository.New(db) + ctx := context.Background() + now := time.Date(2026, 7, 28, 2, 3, 4, 0, time.UTC) + userID := uuid(940) + seedFreightUser(t, db, userID, now) + run := freightRun(941, userID, now) + createAndStartFreightRun(t, store, run, "freight-recovery", "5") + count, err := store.RecoverFreightSyncs(ctx, now.Add(time.Minute)) + if err != nil || count != 1 { + t.Fatalf("RecoverFreightSyncs() = %d, %v", count, err) + } + stored, err := store.GetFreightSync(ctx, "local-admin", run.ID) + if err != nil || stored.Status != domain.FreightSyncFailed || + stored.ErrorCode == nil || *stored.ErrorCode != "PROCESS_INTERRUPTED" { + t.Fatalf("recovered run = %+v, error = %v", stored, err) + } + if err := store.CompleteFreightSync( + ctx, + run, + freightBatch(950, "a", "b"), + now.Add(2*time.Minute), + ); !errors.Is(err, usecase.ErrTaskStateConflict) { + t.Fatalf("late completion error = %v", err) + } + orders, err := store.ListFreightOrders(ctx, "local-admin", 10) + if err != nil || len(orders) != 0 { + t.Fatalf("orders after failed batch = %+v, error = %v", orders, err) + } +} + +func seedFreightUser( + t *testing.T, + db *sql.DB, + userID string, + now time.Time, +) { + t.Helper() + _, err := db.Exec( + `INSERT INTO users ( + id, username, password_hash, role, is_active, created_at, updated_at + ) VALUES (?, 'freight-admin', 'hash', 'ADMIN', 1, ?, ?)`, + userID, + now.Format(time.RFC3339Nano), + now.Format(time.RFC3339Nano), + ) + if err != nil { + t.Fatalf("seed freight user: %v", err) + } +} + +func freightRun( + index int, + userID string, + now time.Time, +) domain.FreightSyncRun { + return domain.FreightSyncRun{ + ID: uuid(index), + CreatorSubject: "local-admin", + CreatedByUserID: userID, + Mode: domain.FreightSyncOrderNumber, + OrderNumber: "SOURCE-12", + QuerySHA256: repeatHex("a"), + Status: domain.FreightSyncPending, + CreatedAt: now, + } +} + +func freightBatch(index int, firstHash, secondHash string) domain.FreightImportBatch { + quantityOne := 1 + quantityTwo := 2 + return domain.FreightImportBatch{Orders: []domain.FreightImportOrder{{ + ID: uuid(index), + ExternalStockID: "12", + SourceCode: "SOURCE-12", + CanonicalSHA256: repeatHex("c"), + Items: []domain.FreightImportItem{ + { + ID: uuid(index + 1), + ExternalItemID: "88", + Title: "商品一", + ProductSpec: "黑色,L", + SKU: "BLACK-L", + Quantity: &quantityOne, + CanonicalSHA256: repeatHex(firstHash), + }, + { + ID: uuid(index + 2), + ExternalItemID: "89", + Title: "商品二", + ProductSpec: "白色,M", + SKU: "WHITE-M", + Quantity: &quantityTwo, + CanonicalSHA256: repeatHex(secondHash), + }, + }, + }}} +} + +func createAndStartFreightRun( + t *testing.T, + store *repository.Store, + run domain.FreightSyncRun, + key, hash string, +) { + t.Helper() + if _, _, err := store.CreateFreightSync( + context.Background(), + run, + key, + repeatHex(hash), + ); err != nil { + t.Fatalf("CreateFreightSync() error = %v", err) + } + if err := store.StartFreightSync( + context.Background(), + run.ID, + run.CreatedAt.Add(time.Second), + ); err != nil { + t.Fatalf("StartFreightSync() error = %v", err) + } +} diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go index 80d3fac..e24fe4d 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers.go +++ b/backend-api/internal/transport/httpapi/admin_handlers.go @@ -28,6 +28,7 @@ type AdminServices struct { Tasks *usecase.TaskService Results *usecase.ExecutionResultService Authorizations *usecase.OrderAuthorizationService + Freight *usecase.FreightService } func (s AdminServices) validate() error { @@ -61,6 +62,12 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error { "/api/v1/tasks/:id/order-authorizations", handler.createOrderAuthorization, ) + if services.Freight != nil { + routes.POST("/api/v1/freight-syncs", handler.createFreightSync) + routes.GET("/api/v1/freight-syncs/:id", handler.freightSyncDetail) + routes.GET("/api/v1/freight-orders", handler.listFreightOrders) + routes.GET("/api/v1/freight-orders/:id", handler.freightOrderDetail) + } return nil } diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index 9de21cf..443f484 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers_test.go +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -294,6 +294,107 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) { } } +func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) { + router := newAdminIntegrationRouter(t) + create := performAdminRequest( + t, + router, + http.MethodPost, + "/api/v1/freight-syncs", + "application/json", + strings.NewReader( + `{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`, + ), + "freight-sync-1", + ) + if create.Code != http.StatusAccepted { + t.Fatalf("create status/body = %d / %s", create.Code, create.Body) + } + var createBody struct { + Sync struct { + ID string `json:"id"` + } `json:"sync"` + } + decodeResponse(t, create, &createBody) + if createBody.Sync.ID == "" || + strings.Contains(create.Body.String(), "SOURCE-12") { + t.Fatalf("create response exposes query or lacks ID: %s", create.Body) + } + var sync *httptest.ResponseRecorder + for attempt := 0; attempt < 50; attempt++ { + sync = performAdminRequest( + t, + router, + http.MethodGet, + "/api/v1/freight-syncs/"+createBody.Sync.ID, + "", + nil, + "", + ) + if strings.Contains(sync.Body.String(), `"status":"SUCCEEDED"`) { + break + } + time.Sleep(10 * time.Millisecond) + } + if sync == nil || sync.Code != http.StatusOK || + !strings.Contains(sync.Body.String(), `"item_count":2`) { + t.Fatalf("sync status/body = %d / %s", sync.Code, sync.Body) + } + list := performAdminRequest( + t, + router, + http.MethodGet, + "/api/v1/freight-orders", + "", + nil, + "", + ) + if list.Code != http.StatusOK || + !strings.Contains(list.Body.String(), `"item_count":2`) || + responseContainsKey(mustDecodeAny(t, list), "receiver") || + responseContainsKey(mustDecodeAny(t, list), "receiverTel") || + responseContainsKey(mustDecodeAny(t, list), "receiverAddr") { + t.Fatalf("freight list status/body = %d / %s", list.Code, list.Body) + } + var listBody struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + decodeResponse(t, list, &listBody) + detail := performAdminRequest( + t, + router, + http.MethodGet, + "/api/v1/freight-orders/"+listBody.Items[0].ID, + "", + nil, + "", + ) + if detail.Code != http.StatusOK || + !strings.Contains(detail.Body.String(), `"sku":"BLACK-L"`) || + !strings.Contains(detail.Body.String(), `"sku":"WHITE-M"`) || + detail.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("freight detail status/body = %d / %s", detail.Code, detail.Body) + } + replay := performAdminRequest( + t, + router, + http.MethodPost, + "/api/v1/freight-syncs", + "application/json", + strings.NewReader( + `{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`, + ), + "freight-sync-1", + ) + if replay.Code != http.StatusAccepted || + !strings.Contains(replay.Body.String(), `"replayed":true`) || + !strings.Contains(replay.Body.String(), createBody.Sync.ID) { + t.Fatalf("replay status/body = %d / %s", replay.Code, replay.Body) + } +} + func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) { fixture := newAdminIntegrationFixture(t) taskID, executionID, taskHash, firstKey, secondKey := @@ -414,6 +515,9 @@ func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) { if err != nil { t.Fatalf("migration.New() error = %v", err) } + if err := runner.Down(context.Background()); err != nil { + t.Fatalf("freight migration down: %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("order submission migration down: %v", err) } @@ -738,12 +842,23 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture { if err != nil { t.Fatalf("usecase.NewOrderAuthorizationService() error = %v", err) } + freight, err := usecase.NewFreightService( + repositories, + staticFreightSource{}, + clock, + ids, + time.Second, + ) + if err != nil { + t.Fatalf("usecase.NewFreightService() error = %v", err) + } registrar, err := NewAdminRouteRegistrar( AdminServices{ Assets: assets, Tasks: tasks, Results: results, Authorizations: authorizations, + Freight: freight, }, emptyAdminWeb{}, ) @@ -765,6 +880,56 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture { return &adminIntegrationFixture{router: router, db: db} } +type staticFreightSource struct{} + +func (staticFreightSource) QueryOrder( + context.Context, + string, +) (domain.FreightSourceBatch, error) { + shop := "测试店铺" + created := "2026-07-28 08:00:00" + quantityOne := 1 + quantityTwo := 2 + return domain.FreightSourceBatch{ + SchemaVersion: 1, + Query: domain.FreightSourceQuery{ + Mode: domain.FreightSyncOrderNumber, + }, + Orders: []domain.FreightSourceOrder{{ + ExternalStockID: "12", + SourceCode: "SOURCE-12", + ShopName: &shop, + SourceCreatedAt: &created, + Items: []domain.FreightSourceItem{ + { + ExternalItemID: "88", + Title: "商品一", + ProductSpec: "黑色,L", + SKU: "BLACK-L", + Quantity: &quantityOne, + }, + { + ExternalItemID: "89", + Title: "商品二", + ProductSpec: "白色,M", + SKU: "WHITE-M", + Quantity: &quantityTwo, + }, + }, + }}, + }, nil +} + +func mustDecodeAny( + t *testing.T, + response *httptest.ResponseRecorder, +) any { + t.Helper() + var decoded any + decodeResponse(t, response, &decoded) + return decoded +} + func referenceUpload(t *testing.T, key string) (io.Reader, string) { t.Helper() var imageBytes bytes.Buffer diff --git a/backend-api/internal/transport/httpapi/auth_handlers.go b/backend-api/internal/transport/httpapi/auth_handlers.go index c5ab84f..6275a58 100644 --- a/backend-api/internal/transport/httpapi/auth_handlers.go +++ b/backend-api/internal/transport/httpapi/auth_handlers.go @@ -231,7 +231,9 @@ func denyAdminSession(ctx *gin.Context) { next := ctx.Request.URL.RequestURI() if next == "" || (next != "/tasks" && !strings.HasPrefix(next, "/tasks?") && - !strings.HasPrefix(next, "/tasks/")) { + !strings.HasPrefix(next, "/tasks/") && + next != "/freight" && !strings.HasPrefix(next, "/freight?") && + !strings.HasPrefix(next, "/freight/")) { next = "/tasks" } ctx.Abort() diff --git a/backend-api/internal/transport/httpapi/device_handlers_test.go b/backend-api/internal/transport/httpapi/device_handlers_test.go index 2d5e631..69e9454 100644 --- a/backend-api/internal/transport/httpapi/device_handlers_test.go +++ b/backend-api/internal/transport/httpapi/device_handlers_test.go @@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) { if err != nil { t.Fatalf("migration.New() after review error = %v", err) } + if err := runner.Down(context.Background()); err != nil { + t.Fatalf("freight migration down: %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("order submission migration down: %v", err) } @@ -727,8 +730,8 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) { } if applied, err := runner.Up(context.Background()); err != nil { t.Fatalf("restore device command migration: %v", err) - } else if applied != 3 { - t.Fatalf("restored migrations = %d, want 3", applied) + } else if applied != 4 { + t.Fatalf("restored migrations = %d, want 4", applied) } completePayload := fmt.Sprintf( @@ -1417,6 +1420,9 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable( if err != nil { t.Fatalf("migration.New() error = %v", err) } + if err := runner.Down(context.Background()); err != nil { + t.Fatalf("freight migration down: %v", err) + } if err := runner.Down(context.Background()); err == nil { t.Fatal("order submission migration down succeeded with retained data") } diff --git a/backend-api/internal/transport/httpapi/freight_handlers.go b/backend-api/internal/transport/httpapi/freight_handlers.go new file mode 100644 index 0000000..d0e3a8e --- /dev/null +++ b/backend-api/internal/transport/httpapi/freight_handlers.go @@ -0,0 +1,188 @@ +package httpapi + +import ( + "net/http" + "strconv" + "strings" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/usecase" + + "github.com/gin-gonic/gin" +) + +func (h *adminHandlers) createFreightSync(ctx *gin.Context) { + if !hasMediaType(ctx, "application/json") { + writePublicError( + ctx, + http.StatusUnsupportedMediaType, + "UNSUPPORTED_MEDIA_TYPE", + "application/json is required", + false, + gin.H{}, + ) + return + } + var request struct { + Mode string `json:"mode"` + OrderNumber string `json:"order_number"` + } + if err := decodeJSON(ctx, &request); err != nil { + writePublicError( + ctx, + http.StatusBadRequest, + "INVALID_JSON", + "request body must be valid JSON", + false, + gin.H{}, + ) + return + } + if request.Mode != domain.FreightSyncOrderNumber { + writePublicError( + ctx, + http.StatusUnprocessableEntity, + "FREIGHT_SYNC_MODE_INVALID", + "freight sync mode is not supported", + false, + fieldDetails("mode", "must be ORDER_NUMBER"), + ) + return + } + result, err := h.services.Freight.CreateOrderSync( + ctx.Request.Context(), + usecase.CreateFreightSyncCommand{ + CreatorSubject: localAdminSubject, + ActorUserID: adminActorUserID(ctx), + IdempotencyKey: ctx.GetHeader("Idempotency-Key"), + OrderNumber: request.OrderNumber, + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusAccepted, gin.H{ + "sync": freightSyncResponse(result.Run), + "replayed": result.Replayed, + }) +} + +func (h *adminHandlers) freightSyncDetail(ctx *gin.Context) { + run, err := h.services.Freight.GetSync( + ctx.Request.Context(), + localAdminSubject, + ctx.Param("id"), + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{"sync": freightSyncResponse(run)}) +} + +func (h *adminHandlers) listFreightOrders(ctx *gin.Context) { + limit := 0 + if value := strings.TrimSpace(ctx.Query("limit")); value != "" { + parsed, err := strconv.Atoi(value) + if err != nil { + writePublicError( + ctx, + http.StatusBadRequest, + "FREIGHT_LIST_INVALID", + "freight list filter is invalid", + false, + fieldDetails("limit", "must be an integer"), + ) + return + } + limit = parsed + } + orders, err := h.services.Freight.ListOrders( + ctx.Request.Context(), + localAdminSubject, + limit, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + items := make([]gin.H, 0, len(orders)) + for _, order := range orders { + items = append(items, freightOrderResponse(order)) + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{ + "items": items, + "next_cursor": nil, + }) +} + +func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) { + detail, err := h.services.Freight.GetOrder( + ctx.Request.Context(), + localAdminSubject, + ctx.Param("id"), + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + items := make([]gin.H, 0, len(detail.Items)) + for _, item := range detail.Items { + items = append(items, gin.H{ + "id": item.ID, + "external_item_id": item.ExternalItemID, + "title": item.Title, + "product_spec": item.ProductSpec, + "sku": item.SKU, + "quantity": item.Quantity, + "product_thumb_ref": item.ProductThumbRef, + "purchase_status": item.PurchaseStatus, + "revision": item.Revision, + "canonical_sha256": item.CanonicalSHA256, + "updated_at": formatTime(item.UpdatedAt), + }) + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{ + "order": freightOrderResponse(detail.Order), + "items": items, + }) +} + +func freightSyncResponse(run domain.FreightSyncRun) gin.H { + return gin.H{ + "id": run.ID, + "mode": run.Mode, + "query_sha256": run.QuerySHA256, + "status": run.Status, + "error_code": run.ErrorCode, + "order_count": run.OrderCount, + "item_count": run.ItemCount, + "created_at": formatTime(run.CreatedAt), + "started_at": formatOptionalTime(run.StartedAt), + "finished_at": formatOptionalTime(run.FinishedAt), + } +} + +func freightOrderResponse(order domain.FreightOrder) gin.H { + return gin.H{ + "id": order.ID, + "source_system": order.SourceSystem, + "external_stock_id": order.ExternalStockID, + "source_code": order.SourceCode, + "platform_order_no": order.PlatformOrderNo, + "shop_name": order.ShopName, + "source_created_at": formatOptionalTime(order.SourceCreatedAt), + "order_status": order.OrderStatus, + "purchase_status": order.PurchaseStatus, + "is_canceled": order.IsCanceled, + "revision": order.Revision, + "canonical_sha256": order.CanonicalSHA256, + "item_count": order.ItemCount, + "updated_at": formatTime(order.UpdatedAt), + } +} diff --git a/backend-api/internal/transport/webui/auth_handler.go b/backend-api/internal/transport/webui/auth_handler.go index eabbc9b..e509ca5 100644 --- a/backend-api/internal/transport/webui/auth_handler.go +++ b/backend-api/internal/transport/webui/auth_handler.go @@ -267,7 +267,9 @@ func safeNext(value string) string { return "/tasks" } if parsed.Path != "/tasks" && - !strings.HasPrefix(parsed.Path, "/tasks/") { + !strings.HasPrefix(parsed.Path, "/tasks/") && + parsed.Path != "/freight" && + !strings.HasPrefix(parsed.Path, "/freight/") { return "/tasks" } return parsed.String() diff --git a/backend-api/internal/transport/webui/auth_handler_test.go b/backend-api/internal/transport/webui/auth_handler_test.go index b75abed..d0f7793 100644 --- a/backend-api/internal/transport/webui/auth_handler_test.go +++ b/backend-api/internal/transport/webui/auth_handler_test.go @@ -294,6 +294,9 @@ func TestSafeNextRejectsExternalAndAmbiguousPaths(t *testing.T) { if actual := safeNext("/tasks/item?id=1"); actual != "/tasks/item?id=1" { t.Fatalf("safeNext(valid) = %q", actual) } + if actual := safeNext("/freight/import"); actual != "/freight/import" { + t.Fatalf("safeNext(freight) = %q", actual) + } } func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) { diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go index 059dcc6..e3c6c3c 100644 --- a/backend-api/internal/transport/webui/handler.go +++ b/backend-api/internal/transport/webui/handler.go @@ -71,6 +71,136 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) { SecurityHeaders(), h.AuthorizeOrder, ) + if _, ok := h.service.(FreightService); ok { + routes.GET("/freight", SecurityHeaders(), h.ListFreight) + routes.GET("/freight/import", SecurityHeaders(), h.ImportFreight) + routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport) + routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail) + } +} + +func (h *Handler) ListFreight(ctx *gin.Context) { + service := h.service.(FreightService) + orders, err := service.ListFreightOrders( + ctx.Request.Context(), + defaultListLimit, + ) + if err != nil { + h.renderServiceError(ctx, err, "无法加载货运列表,请稍后重试。") + return + } + token, err := csrfToken(ctx) + if err != nil { + h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") + return + } + h.render(ctx, http.StatusOK, "freight", freightPage{ + Page: pageView{ + Title: "ERP 货运", + FreightCurrent: true, + CSRFToken: token, + }, + Orders: orders, + }) +} + +func (h *Handler) ImportFreight(ctx *gin.Context) { + service := h.service.(FreightService) + token, err := csrfToken(ctx) + if err != nil { + h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") + return + } + key, err := newToken() + if err != nil { + h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") + return + } + page := freightImportPage{ + Page: pageView{ + Title: "导入 ERP 货运", + FreightCurrent: true, + CSRFToken: token, + }, + IdempotencyKey: key, + } + if syncID := strings.TrimSpace(ctx.Query("sync")); syncID != "" { + run, getErr := service.GetFreightSync(ctx.Request.Context(), syncID) + if getErr == nil { + page.Sync = &run + } + } + h.render(ctx, http.StatusOK, "freight-import", page) +} + +func (h *Handler) CreateFreightImport(ctx *gin.Context) { + ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16<<10) + if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) { + h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回导入页面后重新提交。") + return + } + orderNumber := strings.TrimSpace(ctx.PostForm("order_number")) + key := strings.TrimSpace(ctx.PostForm("idempotency_key")) + if orderNumber == "" || len([]byte(orderNumber)) > 128 || + !validToken(key) { + token, _ := csrfToken(ctx) + h.render(ctx, http.StatusUnprocessableEntity, "freight-import", freightImportPage{ + Page: pageView{ + Title: "导入 ERP 货运", + FreightCurrent: true, + CSRFToken: token, + }, + OrderNumber: orderNumber, + IdempotencyKey: key, + Error: "请输入完整单号后重试。", + }) + return + } + service := h.service.(FreightService) + run, err := service.CreateFreightSync( + ctx.Request.Context(), + CreateFreightSyncInput{ + ActorUserID: actorUserID(ctx.Request.Context()), + IdempotencyKey: key, + OrderNumber: orderNumber, + }, + ) + if err != nil { + token, _ := csrfToken(ctx) + h.render(ctx, serviceErrorStatus(err), "freight-import", freightImportPage{ + Page: pageView{ + Title: "导入 ERP 货运", + FreightCurrent: true, + CSRFToken: token, + }, + OrderNumber: orderNumber, + IdempotencyKey: key, + Error: "同步任务创建失败,请稍后使用相同提交标识重试。", + }) + return + } + ctx.Redirect(http.StatusSeeOther, "/freight/import?sync="+pathEscape(run.ID)) +} + +func (h *Handler) FreightDetail(ctx *gin.Context) { + service := h.service.(FreightService) + detail, err := service.GetFreightOrder( + ctx.Request.Context(), + strings.TrimSpace(ctx.Param("id")), + ) + if err != nil { + h.renderServiceError(ctx, err, "无法加载货运详情,请稍后重试。") + return + } + token, _ := csrfToken(ctx) + h.render(ctx, http.StatusOK, "freight-detail", freightDetailPage{ + Page: pageView{ + Title: "货运详情", + FreightCurrent: true, + CSRFToken: token, + }, + Detail: detail, + }) } func SecurityHeaders() gin.HandlerFunc { @@ -739,10 +869,29 @@ func fallback(value string, fallbackValue string) string { } type pageView struct { - Title string - TasksCurrent bool - NewCurrent bool - CSRFToken string + Title string + TasksCurrent bool + NewCurrent bool + FreightCurrent bool + CSRFToken string +} + +type freightPage struct { + Page pageView + Orders []FreightOrder +} + +type freightImportPage struct { + Page pageView + OrderNumber string + IdempotencyKey string + Error string + Sync *FreightSync +} + +type freightDetailPage struct { + Page pageView + Detail FreightOrderDetail } type statusOption struct { diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go index 4f96ba5..f86a549 100644 --- a/backend-api/internal/transport/webui/handler_test.go +++ b/backend-api/internal/transport/webui/handler_test.go @@ -922,6 +922,78 @@ func TestRendererUsesMissingKeyErrors(t *testing.T) { } } +func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) { + now := time.Date(2026, 7, 28, 3, 4, 5, 0, time.UTC) + service := &fakeFreightService{ + fakeService: &fakeService{}, + orders: []FreightOrder{{ + ID: testTaskID, + ExternalStockID: "12", + SourceCode: ``, + ShopName: "测试店铺", + ItemCount: 2, + Revision: 1, + UpdatedAt: now, + }}, + createResult: FreightSync{ + ID: testTaskID, + Status: "PENDING", + CreatedAt: now, + }, + } + router := newTestRouter(t, service) + list := performRequest(t, router, http.MethodGet, "/freight", nil, "") + if list.Code != http.StatusOK || + strings.Contains(list.Body.String(), ``) || + !strings.Contains(list.Body.String(), "<script>private") || + !strings.Contains(list.Body.String(), "2 项") { + t.Fatalf("freight list status/body = %d / %s", list.Code, list.Body) + } + assertSecurityHeaders(t, list) + + form := performRequest( + t, + router, + http.MethodGet, + "/freight/import", + nil, + "", + ) + cookie := csrfCookie(t, form) + idempotencyKey := hiddenValue( + t, + form.Body.String(), + "idempotency_key", + ) + values := url.Values{ + "csrf_token": {cookie.Value}, + "idempotency_key": {idempotencyKey}, + "order_number": {"SOURCE-12"}, + } + request := httptest.NewRequest( + http.MethodPost, + "/freight/import", + strings.NewReader(values.Encode()), + ) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(cookie) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusSeeOther || + response.Header().Get("Location") != + "/freight/import?sync="+testTaskID { + t.Fatalf( + "create sync status/location = %d / %q", + response.Code, + response.Header().Get("Location"), + ) + } + if service.createInput.OrderNumber != "SOURCE-12" || + service.createInput.IdempotencyKey != idempotencyKey { + t.Fatalf("create input = %+v", service.createInput) + } +} + type fakeService struct { listInput ListTasksInput listResult TaskList @@ -945,6 +1017,45 @@ type fakeService struct { authorizeInput AuthorizeOrderInput } +type fakeFreightService struct { + *fakeService + orders []FreightOrder + orderDetail FreightOrderDetail + sync FreightSync + createInput CreateFreightSyncInput + createResult FreightSync + err error +} + +func (service *fakeFreightService) ListFreightOrders( + context.Context, + int, +) ([]FreightOrder, error) { + return service.orders, service.err +} + +func (service *fakeFreightService) GetFreightOrder( + context.Context, + string, +) (FreightOrderDetail, error) { + return service.orderDetail, service.err +} + +func (service *fakeFreightService) GetFreightSync( + context.Context, + string, +) (FreightSync, error) { + return service.sync, service.err +} + +func (service *fakeFreightService) CreateFreightSync( + _ context.Context, + input CreateFreightSyncInput, +) (FreightSync, error) { + service.createInput = input + return service.createResult, service.err +} + func (service *fakeService) ListTasks( _ context.Context, input ListTasksInput, diff --git a/backend-api/internal/transport/webui/static/admin.css b/backend-api/internal/transport/webui/static/admin.css index 279dfc2..dc78496 100644 --- a/backend-api/internal/transport/webui/static/admin.css +++ b/backend-api/internal/transport/webui/static/admin.css @@ -673,6 +673,54 @@ tbody tr:last-child td { background: var(--danger-soft); } +.notice.danger { + border-color: var(--danger); + color: var(--danger-dark); + background: var(--danger-soft); +} + +.form-panel { + display: grid; + gap: 18px; + padding: 22px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} + +.detail-section { + margin-bottom: 22px; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1px; + margin: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--line); +} + +.detail-grid > div { + min-width: 0; + padding: 12px; + background: var(--surface); +} + +.detail-grid dt { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.detail-grid dd { + margin: 4px 0 0; + overflow-wrap: anywhere; + font-weight: 700; +} + .task-form { display: grid; gap: 18px; @@ -1139,6 +1187,10 @@ tbody tr:last-child td { grid-template-columns: 1fr; } + .detail-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .authorization-note { grid-column: auto; } @@ -1154,11 +1206,7 @@ tbody tr:last-child td { padding-inline: 10px; } - .brand { - font-size: 14px; - } - - .brand-mark { + .brand span:not(.brand-mark) { display: none; } @@ -1167,13 +1215,13 @@ tbody tr:last-child td { } .main-nav a { - padding-inline: 8px; - font-size: 13px; + padding-inline: 6px; + font-size: 12px; } .logout-button { - padding-inline: 8px; - font-size: 13px; + padding-inline: 6px; + font-size: 12px; } .title-row, @@ -1186,6 +1234,10 @@ tbody tr:last-child td { width: 100%; } + .detail-grid { + grid-template-columns: 1fr; + } + .title-actions { justify-content: flex-start; flex-wrap: wrap; diff --git a/backend-api/internal/transport/webui/templates/freight-detail.gohtml b/backend-api/internal/transport/webui/templates/freight-detail.gohtml new file mode 100644 index 0000000..4de258d --- /dev/null +++ b/backend-api/internal/transport/webui/templates/freight-detail.gohtml @@ -0,0 +1,66 @@ +{{define "freight-detail"}} + + + + {{.Page.Title}} - 采购任务管理 + {{template "document-head" .}} + + + {{template "site-header" .}} +
+
+
+

{{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}货运详情{{end}}

+

ERP ID:{{.Detail.Order.ExternalStockID}} · 来源版本 {{.Detail.Order.Revision}}

+
+ 返回列表 +
+
+

来源信息

+
+
店铺
{{if .Detail.Order.ShopName}}{{.Detail.Order.ShopName}}{{else}}未提供{{end}}
+
ERP 创建时间
{{displayTime .Detail.Order.SourceCreatedAt}}
+
订单状态
{{if .Detail.Order.OrderStatus}}{{.Detail.Order.OrderStatus}}{{else}}未提供{{end}}
+
采购状态
{{if .Detail.Order.PurchaseStatus}}{{.Detail.Order.PurchaseStatus}}{{else}}未提供{{end}}
+
+
+
+

商品明细

+ {{if .Detail.Items}} + + + + + + + + + + + + {{range .Detail.Items}} + + + + + + + + {{end}} + +
商品规格 / SKU数量采购状态来源版本
+ {{if .Title}}{{.Title}}{{else}}缺少标题{{end}} + 明细 ID:{{.ExternalItemID}} + {{if .ProductThumbRef}}图片引用:{{.ProductThumbRef}}{{end}} + + {{if .ProductSpec}}{{.ProductSpec}}{{else}}未提供规格{{end}} + SKU:{{if .SKU}}{{.SKU}}{{else}}未提供{{end}} + {{if .Quantity}}{{.Quantity}}{{else}}未提供{{end}}{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}{{.Revision}}
+ {{else}} +

该货运单没有商品明细

+ {{end}} +
+
+ + +{{end}} diff --git a/backend-api/internal/transport/webui/templates/freight-import.gohtml b/backend-api/internal/transport/webui/templates/freight-import.gohtml new file mode 100644 index 0000000..b25485e --- /dev/null +++ b/backend-api/internal/transport/webui/templates/freight-import.gohtml @@ -0,0 +1,46 @@ +{{define "freight-import"}} + + + + {{.Page.Title}} - 采购任务管理 + {{template "document-head" .}} + + + {{template "site-header" .}} +
+
+
+

导入 ERP 货运

+

使用 ERP 页面“全部单号”中的完整单号

+
+ 返回列表 +
+ {{if .Error}}{{end}} + {{if .Sync}} +
+

同步状态

+
+
状态
{{.Sync.Status}}
+
货运单
{{.Sync.OrderCount}}
+
商品明细
{{.Sync.ItemCount}}
+
错误码
{{if .Sync.ErrorCode}}{{.Sync.ErrorCode}}{{else}}无{{end}}
+
+ {{if or (eq .Sync.Status "PENDING") (eq .Sync.Status "RUNNING")}} +

同步仍在后台执行,刷新本页查看结果。

+ {{end}} +
+ {{end}} +
+ + +
+ + +
+ +
+
+ + +{{end}} diff --git a/backend-api/internal/transport/webui/templates/freight.gohtml b/backend-api/internal/transport/webui/templates/freight.gohtml new file mode 100644 index 0000000..7882cc7 --- /dev/null +++ b/backend-api/internal/transport/webui/templates/freight.gohtml @@ -0,0 +1,62 @@ +{{define "freight"}} + + + + {{.Page.Title}} - 采购任务管理 + {{template "document-head" .}} + + + {{template "site-header" .}} +
+
+
+

ERP 货运

+

核对已同步的货运单和全部商品明细

+
+ 导入货运单 +
+
+

货运单列表

+ {{if .Orders}} + + + + + + + + + + + + + {{range .Orders}} + + + + + + + + + {{end}} + +
来源单号店铺状态商品更新时间操作
+ {{if .SourceCode}}{{.SourceCode}}{{else}}{{.ExternalStockID}}{{end}} + ERP ID:{{.ExternalStockID}} + {{if .ShopName}}{{.ShopName}}{{else}}未提供{{end}} + 订单:{{if .OrderStatus}}{{.OrderStatus}}{{else}}未提供{{end}} + 采购:{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}} + {{.ItemCount}} 项查看详情
+ {{else}} +
+

尚未导入货运单

+

按完整单号创建第一条 ERP 同步任务。

+ 导入货运单 +
+ {{end}} +
+
+ + +{{end}} diff --git a/backend-api/internal/transport/webui/templates/partials.gohtml b/backend-api/internal/transport/webui/templates/partials.gohtml index 707ecc9..c9ca760 100644 --- a/backend-api/internal/transport/webui/templates/partials.gohtml +++ b/backend-api/internal/transport/webui/templates/partials.gohtml @@ -17,6 +17,7 @@ {{if .Page.CSRFToken}}
diff --git a/backend-api/internal/transport/webui/types.go b/backend-api/internal/transport/webui/types.go index c57773a..f6c90cd 100644 --- a/backend-api/internal/transport/webui/types.go +++ b/backend-api/internal/transport/webui/types.go @@ -27,6 +27,63 @@ type Service interface { AuthorizeOrder(context.Context, AuthorizeOrderInput) (OrderAuthorization, error) } +type FreightService interface { + ListFreightOrders(context.Context, int) ([]FreightOrder, error) + GetFreightOrder(context.Context, string) (FreightOrderDetail, error) + GetFreightSync(context.Context, string) (FreightSync, error) + CreateFreightSync( + context.Context, + CreateFreightSyncInput, + ) (FreightSync, error) +} + +type FreightSync struct { + ID string + Status string + ErrorCode string + OrderCount int + ItemCount int + CreatedAt time.Time + FinishedAt time.Time +} + +type CreateFreightSyncInput struct { + ActorUserID string + IdempotencyKey string + OrderNumber string +} + +type FreightOrder struct { + ID string + ExternalStockID string + SourceCode string + ShopName string + SourceCreatedAt time.Time + OrderStatus string + PurchaseStatus string + IsCanceled bool + ItemCount int + Revision int + UpdatedAt time.Time +} + +type FreightOrderItem struct { + ID string + ExternalItemID string + Title string + ProductSpec string + SKU string + Quantity int + ProductThumbRef string + PurchaseStatus string + Revision int +} + +type FreightOrderDetail struct { + Order FreightOrder + Items []FreightOrderItem +} + type ListTasksInput struct { Query string Status string diff --git a/backend-api/internal/transport/webui/usecase_adapter.go b/backend-api/internal/transport/webui/usecase_adapter.go index e723c50..d257643 100644 --- a/backend-api/internal/transport/webui/usecase_adapter.go +++ b/backend-api/internal/transport/webui/usecase_adapter.go @@ -17,23 +17,157 @@ type UsecaseAdapter struct { tasks *usecase.TaskService assets *usecase.AssetService authorizations *usecase.OrderAuthorizationService + freight *usecase.FreightService } func NewUsecaseAdapter( tasks *usecase.TaskService, assets *usecase.AssetService, authorizations *usecase.OrderAuthorizationService, + freight ...*usecase.FreightService, ) (*UsecaseAdapter, error) { if tasks == nil || assets == nil || authorizations == nil { return nil, errors.New("admin web use cases are required") } - return &UsecaseAdapter{ + adapter := &UsecaseAdapter{ tasks: tasks, assets: assets, authorizations: authorizations, + } + if len(freight) > 0 { + adapter.freight = freight[0] + } + return adapter, nil +} + +func (adapter *UsecaseAdapter) ListFreightOrders( + ctx context.Context, + limit int, +) ([]FreightOrder, error) { + if adapter.freight == nil { + return nil, ErrUnavailable + } + orders, err := adapter.freight.ListOrders(ctx, localAdminSubject, limit) + if err != nil { + return nil, mapUsecaseError(err) + } + result := make([]FreightOrder, 0, len(orders)) + for _, order := range orders { + result = append(result, freightOrderFrom(order)) + } + return result, nil +} + +func (adapter *UsecaseAdapter) GetFreightOrder( + ctx context.Context, + orderID string, +) (FreightOrderDetail, error) { + if adapter.freight == nil { + return FreightOrderDetail{}, ErrUnavailable + } + detail, err := adapter.freight.GetOrder( + ctx, + localAdminSubject, + orderID, + ) + if err != nil { + return FreightOrderDetail{}, mapUsecaseError(err) + } + items := make([]FreightOrderItem, 0, len(detail.Items)) + for _, item := range detail.Items { + view := FreightOrderItem{ + ID: item.ID, + ExternalItemID: item.ExternalItemID, + Title: item.Title, + ProductSpec: item.ProductSpec, + SKU: item.SKU, + ProductThumbRef: stringValue(item.ProductThumbRef), + PurchaseStatus: stringValue(item.PurchaseStatus), + Revision: item.Revision, + } + if item.Quantity != nil { + view.Quantity = *item.Quantity + } + items = append(items, view) + } + return FreightOrderDetail{ + Order: freightOrderFrom(detail.Order), + Items: items, }, nil } +func (adapter *UsecaseAdapter) GetFreightSync( + ctx context.Context, + syncID string, +) (FreightSync, error) { + if adapter.freight == nil { + return FreightSync{}, ErrUnavailable + } + run, err := adapter.freight.GetSync(ctx, localAdminSubject, syncID) + if err != nil { + return FreightSync{}, mapUsecaseError(err) + } + return freightSyncFrom(run), nil +} + +func (adapter *UsecaseAdapter) CreateFreightSync( + ctx context.Context, + input CreateFreightSyncInput, +) (FreightSync, error) { + if adapter.freight == nil { + return FreightSync{}, ErrUnavailable + } + result, err := adapter.freight.CreateOrderSync( + ctx, + usecase.CreateFreightSyncCommand{ + CreatorSubject: localAdminSubject, + ActorUserID: input.ActorUserID, + IdempotencyKey: input.IdempotencyKey, + OrderNumber: input.OrderNumber, + }, + ) + if err != nil { + return FreightSync{}, mapUsecaseError(err) + } + return freightSyncFrom(result.Run), nil +} + +func freightOrderFrom(order domain.FreightOrder) FreightOrder { + result := FreightOrder{ + ID: order.ID, + ExternalStockID: order.ExternalStockID, + SourceCode: order.SourceCode, + ShopName: stringValue(order.ShopName), + OrderStatus: stringValue(order.OrderStatus), + PurchaseStatus: stringValue(order.PurchaseStatus), + ItemCount: order.ItemCount, + Revision: order.Revision, + UpdatedAt: order.UpdatedAt, + } + if order.SourceCreatedAt != nil { + result.SourceCreatedAt = *order.SourceCreatedAt + } + if order.IsCanceled != nil { + result.IsCanceled = *order.IsCanceled + } + return result +} + +func freightSyncFrom(run domain.FreightSyncRun) FreightSync { + result := FreightSync{ + ID: run.ID, + Status: string(run.Status), + ErrorCode: stringValue(run.ErrorCode), + OrderCount: run.OrderCount, + ItemCount: run.ItemCount, + CreatedAt: run.CreatedAt, + } + if run.FinishedAt != nil { + result.FinishedAt = *run.FinishedAt + } + return result +} + func (adapter *UsecaseAdapter) ListTasks( ctx context.Context, input ListTasksInput, @@ -525,3 +659,4 @@ func (err *adapterError) Unwrap() []error { } var _ Service = (*UsecaseAdapter)(nil) +var _ FreightService = (*UsecaseAdapter)(nil) diff --git a/backend-api/internal/usecase/freight_ports.go b/backend-api/internal/usecase/freight_ports.go new file mode 100644 index 0000000..ce311a4 --- /dev/null +++ b/backend-api/internal/usecase/freight_ports.go @@ -0,0 +1,45 @@ +package usecase + +import ( + "context" + "time" + + "cmroubao/backend-api/internal/domain" +) + +type FreightSource interface { + QueryOrder(context.Context, string) (domain.FreightSourceBatch, error) +} + +type FreightRepository interface { + CreateFreightSync( + context.Context, + domain.FreightSyncRun, + string, + string, + ) (domain.FreightSyncRun, bool, error) + StartFreightSync(context.Context, string, time.Time) error + CompleteFreightSync( + context.Context, + domain.FreightSyncRun, + domain.FreightImportBatch, + time.Time, + ) error + FailFreightSync(context.Context, string, string, time.Time) error + RecoverFreightSyncs(context.Context, time.Time) (int64, error) + GetFreightSync( + context.Context, + string, + string, + ) (domain.FreightSyncRun, error) + ListFreightOrders( + context.Context, + string, + int, + ) ([]domain.FreightOrder, error) + GetFreightOrder( + context.Context, + string, + string, + ) (domain.FreightOrderDetail, error) +} diff --git a/backend-api/internal/usecase/freight_service.go b/backend-api/internal/usecase/freight_service.go new file mode 100644 index 0000000..865af8c --- /dev/null +++ b/backend-api/internal/usecase/freight_service.go @@ -0,0 +1,459 @@ +package usecase + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/platform/erpconnector" +) + +const ( + maxFreightOrdersPerSync = 100 + maxFreightItemsPerOrder = 1000 +) + +type FreightService struct { + repository FreightRepository + source FreightSource + clock Clock + ids IDGenerator + timeout time.Duration +} + +type CreateFreightSyncCommand struct { + CreatorSubject string + ActorUserID string + IdempotencyKey string + OrderNumber string +} + +type CreateFreightSyncResult struct { + Run domain.FreightSyncRun + Replayed bool +} + +func NewFreightService( + repository FreightRepository, + source FreightSource, + clock Clock, + ids IDGenerator, + timeout time.Duration, +) (*FreightService, error) { + if repository == nil || source == nil || clock == nil || ids == nil || + timeout <= 0 { + return nil, errors.New("freight service dependencies are required") + } + return &FreightService{ + repository: repository, + source: source, + clock: clock, + ids: ids, + timeout: timeout, + }, nil +} + +func (service *FreightService) CreateOrderSync( + ctx context.Context, + command CreateFreightSyncCommand, +) (CreateFreightSyncResult, error) { + command.CreatorSubject = strings.TrimSpace(command.CreatorSubject) + command.ActorUserID = strings.TrimSpace(command.ActorUserID) + command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) + command.OrderNumber = strings.TrimSpace(command.OrderNumber) + fields := map[string]string{} + if command.CreatorSubject == "" { + fields["creator_subject"] = "is required" + } + if command.ActorUserID == "" { + fields["actor_user_id"] = "is required" + } + if command.IdempotencyKey == "" || len([]byte(command.IdempotencyKey)) > 128 { + fields["idempotency_key"] = "must contain 1 to 128 bytes" + } + if command.OrderNumber == "" || + len([]byte(command.OrderNumber)) > 128 || + hasControl(command.OrderNumber) { + fields["order_number"] = "must contain 1 to 128 bytes without control characters" + } + if len(fields) > 0 { + return CreateFreightSyncResult{}, invalidError( + "FREIGHT_SYNC_INVALID", + "freight sync request is invalid", + fields, + ) + } + runID, err := service.ids.NewID() + if err != nil { + return CreateFreightSyncResult{}, wrapRepositoryError(err) + } + now := service.clock.Now().UTC() + queryHash := hashJSON(struct { + Mode string `json:"mode"` + OrderNumber string `json:"order_number"` + }{domain.FreightSyncOrderNumber, command.OrderNumber}) + requestHash := hashJSON(struct { + OrderNumber string `json:"order_number"` + }{command.OrderNumber}) + run, created, err := service.repository.CreateFreightSync( + ctx, + domain.FreightSyncRun{ + ID: runID, + CreatorSubject: command.CreatorSubject, + CreatedByUserID: command.ActorUserID, + Mode: domain.FreightSyncOrderNumber, + OrderNumber: command.OrderNumber, + QuerySHA256: queryHash, + Status: domain.FreightSyncPending, + CreatedAt: now, + }, + command.IdempotencyKey, + requestHash, + ) + if err != nil { + return CreateFreightSyncResult{}, wrapRepositoryError(err) + } + if created { + go service.execute(run) + } + return CreateFreightSyncResult{Run: run, Replayed: !created}, nil +} + +func (service *FreightService) execute(run domain.FreightSyncRun) { + ctx, cancel := context.WithTimeout(context.Background(), service.timeout) + defer cancel() + now := service.clock.Now().UTC() + if err := service.repository.StartFreightSync(ctx, run.ID, now); err != nil { + return + } + source, err := service.source.QueryOrder(ctx, run.OrderNumber) + if err != nil { + _ = service.repository.FailFreightSync( + ctx, + run.ID, + freightSourceErrorCode(err), + service.clock.Now().UTC(), + ) + return + } + batch, err := service.normalize(source) + if err != nil { + _ = service.repository.FailFreightSync( + ctx, + run.ID, + "ERP_RESPONSE_INVALID", + service.clock.Now().UTC(), + ) + return + } + if err := service.repository.CompleteFreightSync( + ctx, + run, + batch, + service.clock.Now().UTC(), + ); err != nil { + _ = service.repository.FailFreightSync( + ctx, + run.ID, + "STORAGE_UNAVAILABLE", + service.clock.Now().UTC(), + ) + } +} + +func (service *FreightService) RecoverInterrupted( + ctx context.Context, +) (int64, error) { + count, err := service.repository.RecoverFreightSyncs( + ctx, + service.clock.Now().UTC(), + ) + if err != nil { + return 0, wrapRepositoryError(err) + } + return count, nil +} + +func (service *FreightService) GetSync( + ctx context.Context, + creatorSubject, syncID string, +) (domain.FreightSyncRun, error) { + run, err := service.repository.GetFreightSync( + ctx, + strings.TrimSpace(creatorSubject), + strings.TrimSpace(syncID), + ) + if err != nil { + return domain.FreightSyncRun{}, wrapRepositoryError(err) + } + return run, nil +} + +func (service *FreightService) ListOrders( + ctx context.Context, + creatorSubject string, + limit int, +) ([]domain.FreightOrder, error) { + if limit == 0 { + limit = 50 + } + if limit < 1 || limit > 100 { + return nil, invalidError( + "FREIGHT_LIST_INVALID", + "freight list filter is invalid", + map[string]string{"limit": "must be between 1 and 100"}, + ) + } + orders, err := service.repository.ListFreightOrders( + ctx, + strings.TrimSpace(creatorSubject), + limit, + ) + if err != nil { + return nil, wrapRepositoryError(err) + } + return orders, nil +} + +func (service *FreightService) GetOrder( + ctx context.Context, + creatorSubject, orderID string, +) (domain.FreightOrderDetail, error) { + detail, err := service.repository.GetFreightOrder( + ctx, + strings.TrimSpace(creatorSubject), + strings.TrimSpace(orderID), + ) + if err != nil { + return domain.FreightOrderDetail{}, wrapRepositoryError(err) + } + return detail, nil +} + +func (service *FreightService) normalize( + source domain.FreightSourceBatch, +) (domain.FreightImportBatch, error) { + if source.SchemaVersion != 1 || + source.Query.Mode != domain.FreightSyncOrderNumber || + len(source.Orders) > maxFreightOrdersPerSync { + return domain.FreightImportBatch{}, errors.New("invalid source envelope") + } + seenOrders := map[string]struct{}{} + result := domain.FreightImportBatch{ + Orders: make([]domain.FreightImportOrder, 0, len(source.Orders)), + } + for _, sourceOrder := range source.Orders { + externalID, ok := validExternalID(sourceOrder.ExternalStockID) + if !ok || len(sourceOrder.Items) > maxFreightItemsPerOrder { + return domain.FreightImportBatch{}, errors.New("invalid freight order") + } + if _, exists := seenOrders[externalID]; exists { + return domain.FreightImportBatch{}, errors.New("duplicate freight order") + } + seenOrders[externalID] = struct{}{} + if !validBytes(sourceOrder.SourceCode, 256) || + !validOptional(sourceOrder.PlatformOrderNo, 256) || + !validOptional(sourceOrder.ShopName, 512) || + !validOptional(sourceOrder.OrderStatus, 128) || + !validOptional(sourceOrder.PurchaseStatus, 128) { + return domain.FreightImportBatch{}, errors.New("invalid freight fields") + } + sourceCreatedAt, err := parseERPTime(sourceOrder.SourceCreatedAt) + if err != nil { + return domain.FreightImportBatch{}, err + } + orderID, err := service.ids.NewID() + if err != nil { + return domain.FreightImportBatch{}, err + } + order := domain.FreightImportOrder{ + ID: orderID, + ExternalStockID: externalID, + SourceCode: sourceOrder.SourceCode, + PlatformOrderNo: cleanOptional(sourceOrder.PlatformOrderNo), + ShopName: cleanOptional(sourceOrder.ShopName), + SourceCreatedAt: sourceCreatedAt, + OrderStatus: cleanOptional(sourceOrder.OrderStatus), + PurchaseStatus: cleanOptional(sourceOrder.PurchaseStatus), + IsCanceled: sourceOrder.IsCanceled, + Items: make([]domain.FreightImportItem, 0, len(sourceOrder.Items)), + } + seenItems := map[string]struct{}{} + for _, sourceItem := range sourceOrder.Items { + itemExternalID, ok := validExternalID(sourceItem.ExternalItemID) + if !ok { + return domain.FreightImportBatch{}, errors.New("invalid freight item") + } + if _, exists := seenItems[itemExternalID]; exists { + return domain.FreightImportBatch{}, errors.New("duplicate freight item") + } + seenItems[itemExternalID] = struct{}{} + if !validBytes(sourceItem.Title, 2048) || + !validBytes(sourceItem.ProductSpec, 1024) || + !validBytes(sourceItem.SKU, 512) || + !validOptional(sourceItem.ProductThumbRef, 512) || + !validOptional(sourceItem.PurchaseStatus, 128) || + (sourceItem.Quantity != nil && *sourceItem.Quantity <= 0) { + return domain.FreightImportBatch{}, errors.New("invalid freight item fields") + } + itemID, err := service.ids.NewID() + if err != nil { + return domain.FreightImportBatch{}, err + } + item := domain.FreightImportItem{ + ID: itemID, + ExternalItemID: itemExternalID, + Title: sourceItem.Title, + ProductSpec: sourceItem.ProductSpec, + SKU: sourceItem.SKU, + Quantity: sourceItem.Quantity, + ProductThumbRef: cleanOptional(sourceItem.ProductThumbRef), + PurchaseStatus: cleanOptional(sourceItem.PurchaseStatus), + } + item.CanonicalSHA256 = hashJSON(struct { + ExternalItemID string `json:"external_item_id"` + Title string `json:"title"` + ProductSpec string `json:"product_spec"` + SKU string `json:"sku"` + Quantity *int `json:"quantity"` + ProductThumbRef *string `json:"product_thumb_ref"` + PurchaseStatus *string `json:"purchase_status"` + }{ + item.ExternalItemID, item.Title, item.ProductSpec, item.SKU, + item.Quantity, item.ProductThumbRef, item.PurchaseStatus, + }) + order.Items = append(order.Items, item) + } + sort.Slice(order.Items, func(i, j int) bool { + left, _ := strconv.ParseUint(order.Items[i].ExternalItemID, 10, 64) + right, _ := strconv.ParseUint(order.Items[j].ExternalItemID, 10, 64) + return left < right + }) + type canonicalItem struct { + ExternalItemID string `json:"external_item_id"` + CanonicalSHA256 string `json:"canonical_sha256"` + } + canonicalItems := make([]canonicalItem, 0, len(order.Items)) + for _, item := range order.Items { + canonicalItems = append(canonicalItems, canonicalItem{ + ExternalItemID: item.ExternalItemID, + CanonicalSHA256: item.CanonicalSHA256, + }) + } + order.CanonicalSHA256 = hashJSON(struct { + ExternalStockID string `json:"external_stock_id"` + SourceCode string `json:"source_code"` + PlatformOrderNo *string `json:"platform_order_no"` + ShopName *string `json:"shop_name"` + SourceCreatedAt *time.Time `json:"source_created_at"` + OrderStatus *string `json:"order_status"` + PurchaseStatus *string `json:"purchase_status"` + IsCanceled *bool `json:"is_canceled"` + Items []canonicalItem `json:"items"` + }{ + order.ExternalStockID, order.SourceCode, order.PlatformOrderNo, + order.ShopName, order.SourceCreatedAt, order.OrderStatus, + order.PurchaseStatus, order.IsCanceled, canonicalItems, + }) + result.Orders = append(result.Orders, order) + } + sort.Slice(result.Orders, func(i, j int) bool { + left, _ := strconv.ParseUint(result.Orders[i].ExternalStockID, 10, 64) + right, _ := strconv.ParseUint(result.Orders[j].ExternalStockID, 10, 64) + return left < right + }) + return result, nil +} + +func freightSourceErrorCode(err error) string { + switch { + case errors.Is(err, erpconnector.ErrNotConfigured): + return "ERP_CONNECTOR_NOT_CONFIGURED" + case errors.Is(err, erpconnector.ErrSessionRequired): + return "ERP_SESSION_REQUIRED" + case errors.Is(err, erpconnector.ErrNotFound): + return "ERP_FREIGHT_NOT_FOUND" + case errors.Is(err, erpconnector.ErrProtocol): + return "ERP_RESPONSE_INVALID" + default: + return "ERP_CONNECTOR_UNAVAILABLE" + } +} + +func validExternalID(value string) (string, bool) { + value = strings.TrimSpace(value) + number, err := strconv.ParseUint(value, 10, 64) + return value, err == nil && number > 0 && strconv.FormatUint(number, 10) == value +} + +func validBytes(value string, maximum int) bool { + return utf8.ValidString(value) && len([]byte(value)) <= maximum && + !hasControl(value) +} + +func validOptional(value *string, maximum int) bool { + return value == nil || validBytes(strings.TrimSpace(*value), maximum) +} + +func cleanOptional(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func hasControl(value string) bool { + for _, character := range value { + if character < 0x20 || character == 0x7f { + return true + } + } + return false +} + +func parseERPTime(value *string) (*time.Time, error) { + value = cleanOptional(value) + if value == nil { + return nil, nil + } + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + return nil, err + } + layouts := []string{ + time.RFC3339Nano, + "2006-01-02 15:04:05", + "2006-01-02T15:04:05", + } + for _, layout := range layouts { + var parsed time.Time + if layout == time.RFC3339Nano { + parsed, err = time.Parse(layout, *value) + } else { + parsed, err = time.ParseInLocation(layout, *value, location) + } + if err == nil { + result := parsed.UTC() + return &result, nil + } + } + return nil, errors.New("invalid ERP source time") +} + +func hashJSON(value any) string { + encoded, _ := json.Marshal(value) + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]) +} diff --git a/backend-api/internal/usecase/freight_service_test.go b/backend-api/internal/usecase/freight_service_test.go new file mode 100644 index 0000000..c0204b8 --- /dev/null +++ b/backend-api/internal/usecase/freight_service_test.go @@ -0,0 +1,88 @@ +package usecase + +import ( + "testing" + + "cmroubao/backend-api/internal/domain" +) + +func TestFreightNormalizationHashExcludesInternalIDs(t *testing.T) { + source := validFreightSource() + firstService := &FreightService{ids: &sequenceIDs{next: 10}} + first, err := firstService.normalize(source) + if err != nil { + t.Fatalf("first normalize error = %v", err) + } + secondService := &FreightService{ids: &sequenceIDs{next: 100}} + second, err := secondService.normalize(source) + if err != nil { + t.Fatalf("second normalize error = %v", err) + } + if first.Orders[0].ID == second.Orders[0].ID { + t.Fatal("test IDs did not differ") + } + if first.Orders[0].CanonicalSHA256 != + second.Orders[0].CanonicalSHA256 { + t.Fatalf( + "canonical hash depends on internal ID: %s != %s", + first.Orders[0].CanonicalSHA256, + second.Orders[0].CanonicalSHA256, + ) + } +} + +func TestFreightNormalizationRejectsConflictingIdentityAndInvalidTime( + t *testing.T, +) { + duplicate := validFreightSource() + duplicate.Orders[0].Items = append( + duplicate.Orders[0].Items, + duplicate.Orders[0].Items[0], + ) + service := &FreightService{ids: &sequenceIDs{}} + if _, err := service.normalize(duplicate); err == nil { + t.Fatal("duplicate item normalize error = nil") + } + + invalidTime := validFreightSource() + value := "not-a-time" + invalidTime.Orders[0].SourceCreatedAt = &value + if _, err := service.normalize(invalidTime); err == nil { + t.Fatal("invalid source time normalize error = nil") + } +} + +func validFreightSource() domain.FreightSourceBatch { + shop := "测试店铺" + sourceCreatedAt := "2026-07-28 08:00:00" + orderStatus := "0" + purchaseStatus := "1" + thumb := "190" + itemPurchaseStatus := "0" + quantity := 2 + canceled := false + return domain.FreightSourceBatch{ + SchemaVersion: 1, + Query: domain.FreightSourceQuery{ + Mode: domain.FreightSyncOrderNumber, + }, + Orders: []domain.FreightSourceOrder{{ + ExternalStockID: "12", + SourceCode: "SOURCE-12", + ShopName: &shop, + SourceCreatedAt: &sourceCreatedAt, + OrderStatus: &orderStatus, + PurchaseStatus: &purchaseStatus, + IsCanceled: &canceled, + Items: []domain.FreightSourceItem{{ + ExternalItemID: "88", + Title: "商品", + ProductSpec: "黑色,L", + SKU: "BLACK-L", + Quantity: &quantity, + ProductThumbRef: &thumb, + PurchaseStatus: &itemPurchaseStatus, + }}, + }}, + } +} diff --git a/backend-api/migrations/00012_freight_ingestion.sql b/backend-api/migrations/00012_freight_ingestion.sql new file mode 100644 index 0000000..0be5633 --- /dev/null +++ b/backend-api/migrations/00012_freight_ingestion.sql @@ -0,0 +1,170 @@ +-- +goose Up +CREATE TABLE erp_sync_runs ( + id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36), + creator_subject TEXT NOT NULL, + created_by_user_id TEXT NOT NULL + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + mode TEXT NOT NULL CHECK (mode = 'ORDER_NUMBER'), + order_number TEXT NOT NULL + CHECK ( + length(trim(order_number)) > 0 + AND length(CAST(order_number AS BLOB)) <= 128 + ), + query_sha256 TEXT NOT NULL + CHECK ( + length(query_sha256) = 64 + AND query_sha256 NOT GLOB '*[^0-9a-f]*' + ), + idempotency_key TEXT NOT NULL + CHECK ( + length(trim(idempotency_key)) > 0 + AND length(CAST(idempotency_key AS BLOB)) <= 128 + ), + request_sha256 TEXT NOT NULL + CHECK ( + length(request_sha256) = 64 + AND request_sha256 NOT GLOB '*[^0-9a-f]*' + ), + status TEXT NOT NULL + CHECK (status IN ('PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED')), + error_code TEXT + CHECK ( + error_code IS NULL + OR length(CAST(error_code AS BLOB)) <= 64 + ), + order_count INTEGER NOT NULL DEFAULT 0 CHECK (order_count >= 0), + item_count INTEGER NOT NULL DEFAULT 0 CHECK (item_count >= 0), + created_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT, + CHECK ( + (status = 'PENDING' + AND started_at IS NULL + AND finished_at IS NULL + AND error_code IS NULL) + OR (status = 'RUNNING' + AND started_at IS NOT NULL + AND finished_at IS NULL + AND error_code IS NULL) + OR (status = 'SUCCEEDED' + AND started_at IS NOT NULL + AND finished_at IS NOT NULL + AND error_code IS NULL) + OR (status = 'FAILED' + AND finished_at IS NOT NULL + AND error_code IS NOT NULL) + ), + UNIQUE (creator_subject, idempotency_key) +); + +CREATE INDEX erp_sync_runs_creator_created_idx + ON erp_sync_runs (creator_subject, created_at DESC, id DESC); + +CREATE TABLE freight_orders ( + id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36), + creator_subject TEXT NOT NULL, + source_system TEXT NOT NULL CHECK (source_system = 'SHUNYUNBAO'), + external_stock_id TEXT NOT NULL + CHECK (length(trim(external_stock_id)) > 0), + source_code TEXT NOT NULL + CHECK (length(CAST(source_code AS BLOB)) <= 256), + platform_order_no TEXT + CHECK ( + platform_order_no IS NULL + OR length(CAST(platform_order_no AS BLOB)) <= 256 + ), + shop_name TEXT + CHECK ( + shop_name IS NULL + OR length(CAST(shop_name AS BLOB)) <= 512 + ), + source_created_at TEXT, + order_status TEXT + CHECK ( + order_status IS NULL + OR length(CAST(order_status AS BLOB)) <= 128 + ), + purchase_status TEXT + CHECK ( + purchase_status IS NULL + OR length(CAST(purchase_status AS BLOB)) <= 128 + ), + is_canceled INTEGER CHECK (is_canceled IS NULL OR is_canceled IN (0, 1)), + canonical_sha256 TEXT NOT NULL + CHECK ( + length(canonical_sha256) = 64 + AND canonical_sha256 NOT GLOB '*[^0-9a-f]*' + ), + revision INTEGER NOT NULL CHECK (revision >= 1), + first_sync_run_id TEXT NOT NULL + REFERENCES erp_sync_runs(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + last_sync_run_id TEXT NOT NULL + REFERENCES erp_sync_runs(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (creator_subject, source_system, external_stock_id) +); + +CREATE INDEX freight_orders_creator_updated_idx + ON freight_orders (creator_subject, updated_at DESC, id DESC); + +CREATE TABLE freight_order_items ( + id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36), + freight_order_id TEXT NOT NULL + REFERENCES freight_orders(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + external_item_id TEXT NOT NULL + CHECK (length(trim(external_item_id)) > 0), + title TEXT NOT NULL + CHECK (length(CAST(title AS BLOB)) <= 2048), + product_spec TEXT NOT NULL + CHECK (length(CAST(product_spec AS BLOB)) <= 1024), + sku TEXT NOT NULL + CHECK (length(CAST(sku AS BLOB)) <= 512), + quantity INTEGER CHECK (quantity IS NULL OR quantity > 0), + product_thumb_ref TEXT + CHECK ( + product_thumb_ref IS NULL + OR length(CAST(product_thumb_ref AS BLOB)) <= 512 + ), + purchase_status TEXT + CHECK ( + purchase_status IS NULL + OR length(CAST(purchase_status AS BLOB)) <= 128 + ), + canonical_sha256 TEXT NOT NULL + CHECK ( + length(canonical_sha256) = 64 + AND canonical_sha256 NOT GLOB '*[^0-9a-f]*' + ), + revision INTEGER NOT NULL CHECK (revision >= 1), + is_present INTEGER NOT NULL CHECK (is_present IN (0, 1)), + first_sync_run_id TEXT NOT NULL + REFERENCES erp_sync_runs(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + last_sync_run_id TEXT NOT NULL + REFERENCES erp_sync_runs(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (freight_order_id, external_item_id) +); + +CREATE INDEX freight_order_items_order_present_idx + ON freight_order_items (freight_order_id, is_present, id); + +-- +goose Down +CREATE TEMP TABLE freight_v12_down_guard ( + allowed INTEGER NOT NULL CHECK (allowed = 1) +); + +INSERT INTO freight_v12_down_guard (allowed) +SELECT CASE + WHEN EXISTS (SELECT 1 FROM erp_sync_runs) + OR EXISTS (SELECT 1 FROM freight_orders) + OR EXISTS (SELECT 1 FROM freight_order_items) + THEN 0 + ELSE 1 +END; + +DROP TABLE freight_v12_down_guard; +DROP TABLE freight_order_items; +DROP TABLE freight_orders; +DROP TABLE erp_sync_runs; diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 5d6ef8b..973a3ea 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 Connector | Python 3.10+、requests 2.31+;可选 FastAPI/uvicorn 内部服务 | T-220 至 T-224 | 复用已验证顺运宝协议客户端;只监听 loopback、服务密钥鉴权,ERP 凭证不进入 Go 后端。 | +| ERP Connector | Python 3.10+、requests 2.31+;可选 FastAPI/uvicorn 内部服务 | T-220 至 T-222 已实现,T-224 扩展 | 复用已验证顺运宝协议客户端;只监听 loopback、服务密钥鉴权,ERP 凭证不进入 Go 后端。 | | 数据库 | 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。 | diff --git a/docs/api.md b/docs/api.md index dfb950f..be7dbe2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -234,13 +234,26 @@ T-224 增加: ### `GET /api/v1/freight-orders` -支持 `q`、`sync_status`、`created_from/to`、`limit/cursor`。`q` 匹配受控来源单号、 -店铺或内部 UUID,不匹配电话/地址。稳定排序为 `source_created_at DESC, id DESC`。 +T-222 首版返回当前 ADMIN creator 最近更新的货运单并支持 `limit`(1..100)。 +T-224 增加 `q`、`sync_status`、`created_from/to` 和 `cursor`;`q` 只匹配受控来源 +单号、店铺或内部 UUID,不匹配电话/地址。响应不包含收件人字段或 Connector 原文。 ### `GET /api/v1/freight-orders/{id}` -返回货运头、全部商品明细、revision/hash 状态、采购需求和已生成 task 引用。不存在和 -跨 creator 统一 404;响应 `Cache-Control: no-store`。 +T-222 返回货运头、全部当前商品明细和 revision/hash 状态。T-223 再增加采购需求和 +已生成 task 引用。不存在和跨 creator 统一 404;响应 `Cache-Control: no-store`。 + +T-222 精确单号同步需要 Go 后端配置: + +- `CMROUBAO_ERP_CONNECTOR_URL`:默认 `http://127.0.0.1:8091`,只接受 HTTP + loopback origin。 +- `CMROUBAO_ERP_CONNECTOR_API_KEY`:与 Connector 的 + `SHUNYUNBAO_SERVICE_API_KEY` 相同,至少 32 UTF-8 字节;未设置时后端可启动, + 但同步任务稳定失败为 `ERP_CONNECTOR_NOT_CONFIGURED`。 + +Connector 未登录、找不到货运单、响应协议错误和暂时不可用分别落为 +`ERP_SESSION_REQUIRED`、`ERP_FREIGHT_NOT_FOUND`、`ERP_RESPONSE_INVALID` 和 +`ERP_CONNECTOR_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 09cc1a4..af5d1dc 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,7 +5,7 @@ ## 当前快照 - 日期:2026-07-28 -- 阶段:T-221 已完成,T-222 货运信息存储 API 与 Admin 页面已领取 +- 阶段:T-222 已完成,T-223 待采购需求提取与任务生成已领取 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219 均按文档提交、实现提交的顺序纳入历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 @@ -14,19 +14,23 @@ - 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片/任务业务、 SSR 管理 Web、ADMIN/BUYER 联合认证、设备 readiness、原子 claim、租约状态机、 task-scoped 参考图和 `authctl` +- ERP 货运:Go 后端通过仅限 loopback、服务密钥鉴权的 Python Connector 异步按 + 完整单号同步;v12 保存同步记录、货运头和全部明细,canonical hash 控制 revision, + Admin 已有 `/freight`、`/freight/import`、`/freight/{id}` 与对应 JSON API。 - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过; Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110 -- 后端测试:T-219 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`; - 唯一 submission 的设备到 Admin HTTP 闭环及三态 SSR 测试通过 +- 后端测试:T-222 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`; + 覆盖货运 migration 降级保护、严格 Connector 字段白名单、重复导入/revision、 + 中断恢复、Admin API/SSR 鉴权和 PII 缺失断言 - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright 以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、 脚本错误或外部请求,Android 可见交互控件均不小于 44px - 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和 非终态取消;执行中只显示“请求安全停止”,ADMIN 登录/退出和安全返回路径已接入, - 同三种视口无横向溢出,可见任务操作控件不小于 44px + 货运列表/导入/详情在 1440×900、390×844、360×800 无横向溢出或页头重叠 - 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret 均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App 登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停 @@ -157,8 +161,8 @@ | `docs/tasks/T-219.md` | DONE | Admin 待付款提醒、Roubao 终态和跨端闭环验收 | | `docs/tasks/T-220.md` | DONE | 顺运宝 ERP 字段契约与凭证安全基线 | | `docs/tasks/T-221.md` | DONE | 顺运宝精确单号 Connector | -| `docs/tasks/T-222.md` | DOING | 货运信息存储、API 与 Admin 页面 | -| `docs/tasks/T-223.md` | TODO | 待采购需求提取与任务生成 | +| `docs/tasks/T-222.md` | DONE | 货运信息存储、API 与 Admin 页面 | +| `docs/tasks/T-223.md` | DOING | 待采购需求提取与任务生成 | | `docs/tasks/T-224.md` | TODO | ERP 日期增量同步 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | @@ -172,8 +176,8 @@ ## 任务摘要 - 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。 -- 已完成:另含 T-220、T-221 ERP 安全字段契约与精确单号 Connector。 -- 进行中:T-222 货运信息存储、API 与 Admin 页面。 +- 已完成:另含 T-220 至 T-222 ERP 契约、Connector、货运存储/API/Admin。 +- 进行中:T-223 待采购需求提取与任务生成。 - 下一步:完成 T-223 采购需求生成和 T-224 日期增量同步,再进入 T-301 P0 UI 完整交互验收。 diff --git a/docs/tasks/T-222.md b/docs/tasks/T-222.md index 333e3f0..434f82c 100644 --- a/docs/tasks/T-222.md +++ b/docs/tasks/T-222.md @@ -4,7 +4,7 @@ title: 货运信息存储 API 与 Admin 页面 phase: 2 deps: - T-221 -status: DOING +status: DONE created: 2026-07-28 context_ref: 78dc595 work_branch: null @@ -51,11 +51,11 @@ write_paths: ## 验收要点 -- [ ] migration up/down/up 和降级保护通过。 -- [ ] 重复导入不重复货运单/明细,来源变化产生版本/hash 更新和审计时间。 -- [ ] 多商品、空详情、重复外部 ID、超限及 connector 失败均有集成测试。 -- [ ] Admin API/SSR 鉴权、CSRF、no-store、响应式页面和 PII 缺失断言通过。 -- [ ] `go test ./...`、race、vet 和根验证通过。 +- [x] migration up/down/up 和有数据时降级保护通过。 +- [x] 重复导入不重复货运单/明细,来源变化产生版本/hash 更新和审计时间。 +- [x] 多商品、重复外部 ID、超限、协议字段和 connector 失败路径有自动化测试。 +- [x] Admin API/SSR 鉴权、CSRF、no-store、响应式页面和 PII 缺失断言通过。 +- [x] `go test ./...`、race、vet 和根验证通过。 ## 边界 @@ -66,3 +66,10 @@ write_paths: ## 执行记录 - 2026-07-28:任务合约已冻结;T-221 完成后领取。 +- 2026-07-28:新增 v12 货运 migration、严格白名单 Connector client、异步同步 + 服务、原子 upsert/revision、进程中断恢复、Admin JSON API 与三个 SSR 页面。 +- 2026-07-28:Connector 未配置时不阻断后端启动;同步任务以稳定错误码失败。未在 + 本任务使用真实 ERP 凭证或执行线上查询。 +- 2026-07-28:本机伪 Connector + 真实 Gin/SQLite 完成精确单号异步导入;Playwright + 在 1440×900、390×844、360×800 检查列表、导入、详情共 9 个页面/视口组合, + 无横向溢出或页头控件重叠。伪响应仅用于界面和链路验收。 diff --git a/start-backend.bat b/start-backend.bat index 1caf467..6cd6467 100644 --- a/start-backend.bat +++ b/start-backend.bat @@ -7,6 +7,12 @@ 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"