feat(t222): add freight ingestion admin flow

This commit is contained in:
QiuSW
2026-07-28 23:26:34 +08:00
parent c524e734fb
commit 6d052b0462
37 changed files with 3397 additions and 101 deletions
+100 -42
View File
@@ -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,
+46 -8
View File
@@ -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{
+140
View File
@@ -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
}
@@ -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
}
@@ -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)
}
}
@@ -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)
}
@@ -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,
})
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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
}
@@ -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
@@ -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()
@@ -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")
}
@@ -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),
}
}
@@ -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()
@@ -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) {
+153 -4
View File
@@ -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 {
@@ -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: `<script>private</script>`,
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(), `<script>private</script>`) ||
!strings.Contains(list.Body.String(), "&lt;script&gt;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,
@@ -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;
@@ -0,0 +1,66 @@
{{define "freight-detail"}}
<!doctype html>
<html lang="zh-CN">
<head>
<title>{{.Page.Title}} - 采购任务管理</title>
{{template "document-head" .}}
</head>
<body>
{{template "site-header" .}}
<main id="main-content" class="page">
<div class="title-row">
<div>
<h1>{{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}货运详情{{end}}</h1>
<p class="subtitle">ERP ID:{{.Detail.Order.ExternalStockID}} · 来源版本 {{.Detail.Order.Revision}}</p>
</div>
<a class="button" href="/freight">返回列表</a>
</div>
<section class="detail-section" aria-labelledby="freight-source-title">
<h2 id="freight-source-title">来源信息</h2>
<dl class="detail-grid">
<div><dt>店铺</dt><dd>{{if .Detail.Order.ShopName}}{{.Detail.Order.ShopName}}{{else}}未提供{{end}}</dd></div>
<div><dt>ERP 创建时间</dt><dd>{{displayTime .Detail.Order.SourceCreatedAt}}</dd></div>
<div><dt>订单状态</dt><dd>{{if .Detail.Order.OrderStatus}}{{.Detail.Order.OrderStatus}}{{else}}未提供{{end}}</dd></div>
<div><dt>采购状态</dt><dd>{{if .Detail.Order.PurchaseStatus}}{{.Detail.Order.PurchaseStatus}}{{else}}未提供{{end}}</dd></div>
</dl>
</section>
<section class="table-region" aria-labelledby="freight-items-title">
<h2 id="freight-items-title">商品明细</h2>
{{if .Detail.Items}}
<table>
<thead>
<tr>
<th scope="col">商品</th>
<th scope="col">规格 / SKU</th>
<th scope="col">数量</th>
<th scope="col">采购状态</th>
<th scope="col">来源版本</th>
</tr>
</thead>
<tbody>
{{range .Detail.Items}}
<tr>
<td data-label="商品">
<strong>{{if .Title}}{{.Title}}{{else}}缺少标题{{end}}</strong>
<span class="secondary">明细 ID:{{.ExternalItemID}}</span>
{{if .ProductThumbRef}}<span class="secondary">图片引用:{{.ProductThumbRef}}</span>{{end}}
</td>
<td data-label="规格 / SKU">
<span>{{if .ProductSpec}}{{.ProductSpec}}{{else}}未提供规格{{end}}</span>
<span class="secondary">SKU:{{if .SKU}}{{.SKU}}{{else}}未提供{{end}}</span>
</td>
<td data-label="数量">{{if .Quantity}}{{.Quantity}}{{else}}未提供{{end}}</td>
<td data-label="采购状态">{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}</td>
<td data-label="来源版本">{{.Revision}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state"><h2>该货运单没有商品明细</h2></div>
{{end}}
</section>
</main>
</body>
</html>
{{end}}
@@ -0,0 +1,46 @@
{{define "freight-import"}}
<!doctype html>
<html lang="zh-CN">
<head>
<title>{{.Page.Title}} - 采购任务管理</title>
{{template "document-head" .}}
</head>
<body>
{{template "site-header" .}}
<main id="main-content" class="page narrow-page">
<div class="title-row">
<div>
<h1>导入 ERP 货运</h1>
<p class="subtitle">使用 ERP 页面“全部单号”中的完整单号</p>
</div>
<a class="button" href="/freight">返回列表</a>
</div>
{{if .Error}}<div class="notice danger" role="alert">{{.Error}}</div>{{end}}
{{if .Sync}}
<section class="detail-section" aria-labelledby="sync-result-title">
<h2 id="sync-result-title">同步状态</h2>
<dl class="detail-grid">
<div><dt>状态</dt><dd>{{.Sync.Status}}</dd></div>
<div><dt>货运单</dt><dd>{{.Sync.OrderCount}}</dd></div>
<div><dt>商品明细</dt><dd>{{.Sync.ItemCount}}</dd></div>
<div><dt>错误码</dt><dd>{{if .Sync.ErrorCode}}{{.Sync.ErrorCode}}{{else}}无{{end}}</dd></div>
</dl>
{{if or (eq .Sync.Status "PENDING") (eq .Sync.Status "RUNNING")}}
<p class="secondary">同步仍在后台执行,刷新本页查看结果。</p>
{{end}}
</section>
{{end}}
<form class="form-panel" method="post" action="/freight/import" data-loading-form>
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
<input type="hidden" name="idempotency_key" value="{{.IdempotencyKey}}">
<div class="field">
<label for="order-number">完整单号</label>
<input id="order-number" name="order_number" value="{{.OrderNumber}}"
maxlength="128" autocomplete="off" required>
</div>
<button class="button primary" type="submit" data-loading-label="正在创建…">开始同步</button>
</form>
</main>
</body>
</html>
{{end}}
@@ -0,0 +1,62 @@
{{define "freight"}}
<!doctype html>
<html lang="zh-CN">
<head>
<title>{{.Page.Title}} - 采购任务管理</title>
{{template "document-head" .}}
</head>
<body>
{{template "site-header" .}}
<main id="main-content" class="page">
<div class="title-row">
<div>
<h1>ERP 货运</h1>
<p class="subtitle">核对已同步的货运单和全部商品明细</p>
</div>
<a class="button primary" href="/freight/import">导入货运单</a>
</div>
<section class="table-region" aria-labelledby="freight-table-title">
<h2 id="freight-table-title" class="visually-hidden">货运单列表</h2>
{{if .Orders}}
<table>
<thead>
<tr>
<th scope="col">来源单号</th>
<th scope="col">店铺</th>
<th scope="col">状态</th>
<th scope="col">商品</th>
<th scope="col">更新时间</th>
<th scope="col"><span class="visually-hidden">操作</span></th>
</tr>
</thead>
<tbody>
{{range .Orders}}
<tr>
<td data-label="来源单号">
<strong>{{if .SourceCode}}{{.SourceCode}}{{else}}{{.ExternalStockID}}{{end}}</strong>
<span class="secondary">ERP ID:{{.ExternalStockID}}</span>
</td>
<td data-label="店铺">{{if .ShopName}}{{.ShopName}}{{else}}未提供{{end}}</td>
<td data-label="状态">
<span class="secondary">订单:{{if .OrderStatus}}{{.OrderStatus}}{{else}}未提供{{end}}</span>
<span class="secondary">采购:{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}</span>
</td>
<td data-label="商品">{{.ItemCount}} 项</td>
<td data-label="更新时间"><time datetime="{{machineTime .UpdatedAt}}">{{displayTime .UpdatedAt}}</time></td>
<td data-label="操作"><a class="detail-link" href="/freight/{{pathPart .ID}}">查看详情</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<h2>尚未导入货运单</h2>
<p>按完整单号创建第一条 ERP 同步任务。</p>
<a class="button primary" href="/freight/import">导入货运单</a>
</div>
{{end}}
</section>
</main>
</body>
</html>
{{end}}
@@ -17,6 +17,7 @@
<nav class="main-nav" aria-label="主导航">
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
<a href="/freight" {{if .Page.FreightCurrent}}aria-current="page"{{end}}>ERP 货运</a>
</nav>
{{if .Page.CSRFToken}}
<form class="logout-form" method="post" action="/logout">
@@ -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
@@ -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)
@@ -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)
}
@@ -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[:])
}
@@ -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,
}},
}},
}
}