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
+28
View File
@@ -14,6 +14,7 @@ import (
"cmroubao/backend-api/internal/config" "cmroubao/backend-api/internal/config"
"cmroubao/backend-api/internal/platform/assetstore" "cmroubao/backend-api/internal/platform/assetstore"
"cmroubao/backend-api/internal/platform/database" "cmroubao/backend-api/internal/platform/database"
"cmroubao/backend-api/internal/platform/erpconnector"
"cmroubao/backend-api/internal/platform/migration" "cmroubao/backend-api/internal/platform/migration"
"cmroubao/backend-api/internal/platform/password" "cmroubao/backend-api/internal/platform/password"
repository "cmroubao/backend-api/internal/repository/sqlite" repository "cmroubao/backend-api/internal/repository/sqlite"
@@ -217,6 +218,31 @@ func buildRouter(
if err != nil { if err != nil {
return nil, err 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) passwords, err := password.NewBcrypt(12)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -249,6 +275,7 @@ func buildRouter(
tasks, tasks,
assets, assets,
authorizations, authorizations,
freight,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -294,6 +321,7 @@ func buildRouter(
Tasks: tasks, Tasks: tasks,
Results: results, Results: results,
Authorizations: authorizations, Authorizations: authorizations,
Freight: freight,
}, },
webHandler, webHandler,
) )
+31
View File
@@ -6,6 +6,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
@@ -158,6 +159,36 @@ func TestBuildRouterRegistersProtectedLogoutRoute(t *testing.T) {
response.Header().Get("Location"), 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 { type stubMigrationStatusReader struct {
+100 -42
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"errors" "errors"
"net" "net"
"net/url"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -10,40 +11,46 @@ import (
) )
const ( const (
HTTPAddressEnvironment = "CMROUBAO_HTTP_ADDR" HTTPAddressEnvironment = "CMROUBAO_HTTP_ADDR"
DatabasePathEnvironment = "CMROUBAO_DATABASE_PATH" DatabasePathEnvironment = "CMROUBAO_DATABASE_PATH"
AssetDirectoryEnvironment = "CMROUBAO_ASSET_DIR" AssetDirectoryEnvironment = "CMROUBAO_ASSET_DIR"
TLSCertificateEnvironment = "CMROUBAO_TLS_CERT_FILE" TLSCertificateEnvironment = "CMROUBAO_TLS_CERT_FILE"
TLSPrivateKeyEnvironment = "CMROUBAO_TLS_KEY_FILE" TLSPrivateKeyEnvironment = "CMROUBAO_TLS_KEY_FILE"
ClaimLeaseEnvironment = "CMROUBAO_CLAIM_LEASE" ClaimLeaseEnvironment = "CMROUBAO_CLAIM_LEASE"
RunningLeaseEnvironment = "CMROUBAO_RUNNING_LEASE" RunningLeaseEnvironment = "CMROUBAO_RUNNING_LEASE"
ReadinessTTLEnvironment = "CMROUBAO_READINESS_TTL" ReadinessTTLEnvironment = "CMROUBAO_READINESS_TTL"
ERPConnectorURLEnvironment = "CMROUBAO_ERP_CONNECTOR_URL"
ERPConnectorAPIKeyEnvironment = "CMROUBAO_ERP_CONNECTOR_API_KEY"
defaultHTTPAddress = "127.0.0.1:8080" defaultHTTPAddress = "127.0.0.1:8080"
defaultDatabasePath = "var/cmroubao.db" defaultDatabasePath = "var/cmroubao.db"
defaultAssetDirectory = "var/assets" defaultAssetDirectory = "var/assets"
defaultClaimLease = 10 * time.Minute defaultClaimLease = 10 * time.Minute
defaultRunningLease = 30 * time.Minute defaultRunningLease = 30 * time.Minute
defaultReadinessTTL = 2 * time.Minute defaultReadinessTTL = 2 * time.Minute
defaultERPConnectorURL = "http://127.0.0.1:8091"
) )
type LookupEnvironment func(string) (string, bool) type LookupEnvironment func(string) (string, bool)
type Config struct { type Config struct {
HTTPAddress string HTTPAddress string
DatabasePath string DatabasePath string
AssetDirectory string AssetDirectory string
TLSCertificate string TLSCertificate string
TLSPrivateKey string TLSPrivateKey string
ReadHeaderTimeout time.Duration ReadHeaderTimeout time.Duration
ReadTimeout time.Duration ReadTimeout time.Duration
WriteTimeout time.Duration WriteTimeout time.Duration
IdleTimeout time.Duration IdleTimeout time.Duration
ShutdownTimeout time.Duration ShutdownTimeout time.Duration
MaxHeaderBytes int MaxHeaderBytes int
ClaimLease time.Duration ClaimLease time.Duration
RunningLease time.Duration RunningLease time.Duration
ReadinessTTL time.Duration ReadinessTTL time.Duration
ERPConnectorURL string
ERPConnectorAPIKey string
ERPConnectorTimeout time.Duration
} }
func Load(lookup LookupEnvironment) (Config, error) { func Load(lookup LookupEnvironment) (Config, error) {
@@ -137,25 +144,76 @@ func Load(lookup LookupEnvironment) (Config, error) {
if err != nil { if err != nil {
return Config{}, err 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{ return Config{
HTTPAddress: httpAddress, HTTPAddress: httpAddress,
DatabasePath: filepath.Clean(databasePath), DatabasePath: filepath.Clean(databasePath),
AssetDirectory: assetDirectory, AssetDirectory: assetDirectory,
TLSCertificate: cleanOptionalPath(tlsCertificate), TLSCertificate: cleanOptionalPath(tlsCertificate),
TLSPrivateKey: cleanOptionalPath(tlsPrivateKey), TLSPrivateKey: cleanOptionalPath(tlsPrivateKey),
ReadHeaderTimeout: 5 * time.Second, ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second,
ShutdownTimeout: 10 * time.Second, ShutdownTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20, MaxHeaderBytes: 1 << 20,
ClaimLease: claimLease, ClaimLease: claimLease,
RunningLease: runningLease, RunningLease: runningLease,
ReadinessTTL: readinessTTL, ReadinessTTL: readinessTTL,
ERPConnectorURL: strings.TrimRight(erpConnectorURL, "/"),
ERPConnectorAPIKey: erpConnectorAPIKey,
ERPConnectorTimeout: 90 * time.Second,
}, nil }, 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( func durationEnvironment(
lookup LookupEnvironment, lookup LookupEnvironment,
name string, name string,
+46 -8
View File
@@ -42,18 +42,30 @@ func TestLoadUsesSafeDefaults(t *testing.T) {
if cfg.ShutdownTimeout > 30*time.Second { if cfg.ShutdownTimeout > 30*time.Second {
t.Fatalf("ShutdownTimeout = %s", cfg.ShutdownTimeout) 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) { func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
values := map[string]string{ values := map[string]string{
HTTPAddressEnvironment: "192.0.2.10:9090", HTTPAddressEnvironment: "192.0.2.10:9090",
DatabasePathEnvironment: "tmp/test.db", DatabasePathEnvironment: "tmp/test.db",
AssetDirectoryEnvironment: "tmp/assets", AssetDirectoryEnvironment: "tmp/assets",
TLSCertificateEnvironment: "tmp/server.crt", TLSCertificateEnvironment: "tmp/server.crt",
TLSPrivateKeyEnvironment: "tmp/server.key", TLSPrivateKeyEnvironment: "tmp/server.key",
ClaimLeaseEnvironment: "15m", ClaimLeaseEnvironment: "15m",
RunningLeaseEnvironment: "45m", RunningLeaseEnvironment: "45m",
ReadinessTTLEnvironment: "3m", ReadinessTTLEnvironment: "3m",
ERPConnectorURLEnvironment: "http://localhost:18091",
ERPConnectorAPIKeyEnvironment: "12345678901234567890123456789012",
} }
cfg, err := Load(mapEnvironment(values)) cfg, err := Load(mapEnvironment(values))
@@ -88,6 +100,14 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
cfg.ReadinessTTL, 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) { func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
@@ -95,6 +115,24 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
name string name string
values map[string]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", name: "blank explicit address",
values: map[string]string{ 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 { if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("initial Up() error = %v", err) t.Fatalf("initial Up() error = %v", err)
} else if applied != 11 { } else if applied != 12 {
t.Fatalf("initial Up() applied = %d, want 11", applied) 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 { if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v11) error = %v", err) t.Fatalf("initial Down(v11) error = %v", err)
@@ -62,9 +65,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
seedClaimsHistoricalFixture(t, db) seedClaimsHistoricalFixture(t, db)
if applied, err := runner.Up(ctx); err != nil { if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("Up(v5-v11) over historical data error = %v", err) t.Fatalf("Up(v5-v12) over historical data error = %v", err)
} else if applied != 7 { } else if applied != 8 {
t.Fatalf("Up(v5-v11) applied = %d, want 7", applied) 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) assertClaimsHistory(t, db, true)
@@ -109,9 +117,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
assertClaimsHistory(t, db, false) assertClaimsHistory(t, db, false)
if applied, err := runner.Up(ctx); err != nil { if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("final Up(v4-v11) error = %v", err) t.Fatalf("final Up(v4-v12) error = %v", err)
} else if applied != 8 { } else if applied != 9 {
t.Fatalf("final Up(v4-v11) applied = %d, want 8", applied) t.Fatalf("final Up(v4-v12) applied = %d, want 9", applied)
} }
assertClaimsHistory(t, db, true) assertClaimsHistory(t, db, true)
} }
@@ -347,6 +355,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
t.Fatalf("insert v4 audit event: %v", err) 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 { if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v11) error = %v", err) t.Fatalf("Down(v11) error = %v", err)
} }
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Up() error = %v", err) t.Fatalf("Up() error = %v", err)
} }
if applied != 11 { if applied != 12 {
t.Fatalf("Up() applied = %d, want 11", applied) t.Fatalf("Up() applied = %d, want 12", applied)
} }
assertStatuses(t, runner, map[int64]bool{ assertStatuses(t, runner, map[int64]bool{
1: true, 1: true,
@@ -42,6 +42,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
9: true, 9: true,
10: true, 10: true,
11: true, 11: true,
12: true,
}) })
applied, err = runner.Up(context.Background()) applied, err = runner.Up(context.Background())
@@ -66,7 +67,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
8: true, 8: true,
9: true, 9: true,
10: true, 10: true,
11: false, 11: true,
12: false,
}) })
applied, err = runner.Up(context.Background()) applied, err = runner.Up(context.Background())
@@ -88,6 +90,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
9: true, 9: true,
10: true, 10: true,
11: true, 11: true,
12: true,
}) })
} }
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
if err != nil { if err != nil {
t.Fatalf("migration.New() error = %v", err) 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 { if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v11) error = %v", err) t.Fatalf("Down(v11) error = %v", err)
} }
@@ -423,9 +426,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
t.Fatal("purchase_tasks was lost during auth migration rollback") t.Fatal("purchase_tasks was lost during auth migration rollback")
} }
if applied, err := runner.Up(context.Background()); err != nil { if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("Up(v3-v11) error = %v", err) t.Fatalf("Up(v3-v12) error = %v", err)
} else if applied != 9 { } else if applied != 10 {
t.Fatalf("Up(v3-v11) applied = %d, want 9", applied) 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 Tasks *usecase.TaskService
Results *usecase.ExecutionResultService Results *usecase.ExecutionResultService
Authorizations *usecase.OrderAuthorizationService Authorizations *usecase.OrderAuthorizationService
Freight *usecase.FreightService
} }
func (s AdminServices) validate() error { func (s AdminServices) validate() error {
@@ -61,6 +62,12 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
"/api/v1/tasks/:id/order-authorizations", "/api/v1/tasks/:id/order-authorizations",
handler.createOrderAuthorization, 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 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) { func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
fixture := newAdminIntegrationFixture(t) fixture := newAdminIntegrationFixture(t)
taskID, executionID, taskHash, firstKey, secondKey := taskID, executionID, taskHash, firstKey, secondKey :=
@@ -414,6 +515,9 @@ func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("migration.New() error = %v", err) 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 { if err := runner.Down(context.Background()); err != nil {
t.Fatalf("order submission migration down: %v", err) t.Fatalf("order submission migration down: %v", err)
} }
@@ -738,12 +842,23 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
if err != nil { if err != nil {
t.Fatalf("usecase.NewOrderAuthorizationService() error = %v", err) 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( registrar, err := NewAdminRouteRegistrar(
AdminServices{ AdminServices{
Assets: assets, Assets: assets,
Tasks: tasks, Tasks: tasks,
Results: results, Results: results,
Authorizations: authorizations, Authorizations: authorizations,
Freight: freight,
}, },
emptyAdminWeb{}, emptyAdminWeb{},
) )
@@ -765,6 +880,56 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
return &adminIntegrationFixture{router: router, db: db} 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) { func referenceUpload(t *testing.T, key string) (io.Reader, string) {
t.Helper() t.Helper()
var imageBytes bytes.Buffer var imageBytes bytes.Buffer
@@ -231,7 +231,9 @@ func denyAdminSession(ctx *gin.Context) {
next := ctx.Request.URL.RequestURI() next := ctx.Request.URL.RequestURI()
if next == "" || if next == "" ||
(next != "/tasks" && !strings.HasPrefix(next, "/tasks?") && (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" next = "/tasks"
} }
ctx.Abort() ctx.Abort()
@@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("migration.New() after review error = %v", err) 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 { if err := runner.Down(context.Background()); err != nil {
t.Fatalf("order submission migration down: %v", err) 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 { if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("restore device command migration: %v", err) t.Fatalf("restore device command migration: %v", err)
} else if applied != 3 { } else if applied != 4 {
t.Fatalf("restored migrations = %d, want 3", applied) t.Fatalf("restored migrations = %d, want 4", applied)
} }
completePayload := fmt.Sprintf( completePayload := fmt.Sprintf(
@@ -1417,6 +1420,9 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
if err != nil { if err != nil {
t.Fatalf("migration.New() error = %v", err) 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 { if err := runner.Down(context.Background()); err == nil {
t.Fatal("order submission migration down succeeded with retained data") 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" return "/tasks"
} }
if parsed.Path != "/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 "/tasks"
} }
return parsed.String() return parsed.String()
@@ -294,6 +294,9 @@ func TestSafeNextRejectsExternalAndAmbiguousPaths(t *testing.T) {
if actual := safeNext("/tasks/item?id=1"); actual != "/tasks/item?id=1" { if actual := safeNext("/tasks/item?id=1"); actual != "/tasks/item?id=1" {
t.Fatalf("safeNext(valid) = %q", actual) 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) { func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
+153 -4
View File
@@ -71,6 +71,136 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
SecurityHeaders(), SecurityHeaders(),
h.AuthorizeOrder, 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 { func SecurityHeaders() gin.HandlerFunc {
@@ -739,10 +869,29 @@ func fallback(value string, fallbackValue string) string {
} }
type pageView struct { type pageView struct {
Title string Title string
TasksCurrent bool TasksCurrent bool
NewCurrent bool NewCurrent bool
CSRFToken string 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 { 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 { type fakeService struct {
listInput ListTasksInput listInput ListTasksInput
listResult TaskList listResult TaskList
@@ -945,6 +1017,45 @@ type fakeService struct {
authorizeInput AuthorizeOrderInput 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( func (service *fakeService) ListTasks(
_ context.Context, _ context.Context,
input ListTasksInput, input ListTasksInput,
@@ -673,6 +673,54 @@ tbody tr:last-child td {
background: var(--danger-soft); 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 { .task-form {
display: grid; display: grid;
gap: 18px; gap: 18px;
@@ -1139,6 +1187,10 @@ tbody tr:last-child td {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.authorization-note { .authorization-note {
grid-column: auto; grid-column: auto;
} }
@@ -1154,11 +1206,7 @@ tbody tr:last-child td {
padding-inline: 10px; padding-inline: 10px;
} }
.brand { .brand span:not(.brand-mark) {
font-size: 14px;
}
.brand-mark {
display: none; display: none;
} }
@@ -1167,13 +1215,13 @@ tbody tr:last-child td {
} }
.main-nav a { .main-nav a {
padding-inline: 8px; padding-inline: 6px;
font-size: 13px; font-size: 12px;
} }
.logout-button { .logout-button {
padding-inline: 8px; padding-inline: 6px;
font-size: 13px; font-size: 12px;
} }
.title-row, .title-row,
@@ -1186,6 +1234,10 @@ tbody tr:last-child td {
width: 100%; width: 100%;
} }
.detail-grid {
grid-template-columns: 1fr;
}
.title-actions { .title-actions {
justify-content: flex-start; justify-content: flex-start;
flex-wrap: wrap; 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="主导航"> <nav class="main-nav" aria-label="主导航">
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a> <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="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
<a href="/freight" {{if .Page.FreightCurrent}}aria-current="page"{{end}}>ERP 货运</a>
</nav> </nav>
{{if .Page.CSRFToken}} {{if .Page.CSRFToken}}
<form class="logout-form" method="post" action="/logout"> <form class="logout-form" method="post" action="/logout">
@@ -27,6 +27,63 @@ type Service interface {
AuthorizeOrder(context.Context, AuthorizeOrderInput) (OrderAuthorization, error) 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 { type ListTasksInput struct {
Query string Query string
Status string Status string
@@ -17,23 +17,157 @@ type UsecaseAdapter struct {
tasks *usecase.TaskService tasks *usecase.TaskService
assets *usecase.AssetService assets *usecase.AssetService
authorizations *usecase.OrderAuthorizationService authorizations *usecase.OrderAuthorizationService
freight *usecase.FreightService
} }
func NewUsecaseAdapter( func NewUsecaseAdapter(
tasks *usecase.TaskService, tasks *usecase.TaskService,
assets *usecase.AssetService, assets *usecase.AssetService,
authorizations *usecase.OrderAuthorizationService, authorizations *usecase.OrderAuthorizationService,
freight ...*usecase.FreightService,
) (*UsecaseAdapter, error) { ) (*UsecaseAdapter, error) {
if tasks == nil || assets == nil || authorizations == nil { if tasks == nil || assets == nil || authorizations == nil {
return nil, errors.New("admin web use cases are required") return nil, errors.New("admin web use cases are required")
} }
return &UsecaseAdapter{ adapter := &UsecaseAdapter{
tasks: tasks, tasks: tasks,
assets: assets, assets: assets,
authorizations: authorizations, 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 }, 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( func (adapter *UsecaseAdapter) ListTasks(
ctx context.Context, ctx context.Context,
input ListTasksInput, input ListTasksInput,
@@ -525,3 +659,4 @@ func (err *adapterError) Unwrap() []error {
} }
var _ Service = (*UsecaseAdapter)(nil) 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,
}},
}},
}
}
@@ -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;
+1 -1
View File
@@ -21,7 +21,7 @@
| 数据访问 | 标准库 `database/sql` | MVP 已定 | 领域层通过仓储接口访问,避免先引入 ORM 和代码生成复杂度。 | | 数据访问 | 标准库 `database/sql` | MVP 已定 | 领域层通过仓储接口访问,避免先引入 ORM 和代码生成复杂度。 |
| 数据迁移 | Goose v3.26.0,使用嵌入式 SQL migration | 已验证 | v3.26.0 是已核实仍声明 Go 1.23.0 的最高版本;v3.27.x 要求 Go 1.25。 | | 数据迁移 | 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 工程,模板和静态资源随服务构建。 | | 管理 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。 | | 数据库 | SQLite | MVP 已定 | 单服务、单设备验证足够;多实例或并发提升前迁移 PostgreSQL。 |
| 图片/截图 | 后端受控本地文件目录 + `golang.org/x/image` v0.28.0 | 已验证 | JPEG/PNG/WebP 真解码后白底缩放并编码为 JPEG;数据库只存元数据和随机相对键。 | | 图片/截图 | 后端受控本地文件目录 + `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。 | | 管理鉴权 | bcrypt + 8 小时 opaque 服务端会话 Cookie | T-204 已验证 | `authctl` 预置 ADMIN;数据库只存密码 hash 与 session SHA-256,完整 RBAC 为 V2。 |
+17 -4
View File
@@ -234,13 +234,26 @@ T-224 增加:
### `GET /api/v1/freight-orders` ### `GET /api/v1/freight-orders`
支持 `q`、`sync_status`、`created_from/to`、`limit/cursor`。`q` 匹配受控来源单号、 T-222 首版返回当前 ADMIN creator 最近更新的货运单并支持 `limit`(1..100)。
店铺或内部 UUID,不匹配电话/地址。稳定排序为 `source_created_at DESC, id DESC`。 T-224 增加 `q`、`sync_status`、`created_from/to` 和 `cursor`;`q` 只匹配受控来源
单号、店铺或内部 UUID,不匹配电话/地址。响应不包含收件人字段或 Connector 原文。
### `GET /api/v1/freight-orders/{id}` ### `GET /api/v1/freight-orders/{id}`
返回货运头、全部商品明细、revision/hash 状态、采购需求和已生成 task 引用。不存在和 T-222 返回货运头、全部当前商品明细和 revision/hash 状态。T-223 再增加采购需求和
跨 creator 统一 404;响应 `Cache-Control: no-store`。 已生成 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` ### `POST /api/v1/freight-items/{item_id}/procurement-request`
+12 -8
View File
@@ -5,7 +5,7 @@
## 当前快照 ## 当前快照
- 日期:2026-07-28 - 日期: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 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
均按文档提交、实现提交的顺序纳入历史 均按文档提交、实现提交的顺序纳入历史
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码
@@ -14,19 +14,23 @@
- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片/任务业务、 - 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片/任务业务、
SSR 管理 Web、ADMIN/BUYER 联合认证、设备 readiness、原子 claim、租约状态机、 SSR 管理 Web、ADMIN/BUYER 联合认证、设备 readiness、原子 claim、租约状态机、
task-scoped 参考图和 `authctl` 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、 - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过; - 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110 Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110
- 后端测试:T-219 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`; - 后端测试:T-222 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
唯一 submission 的设备到 Admin HTTP 闭环及三态 SSR 测试通过 覆盖货运 migration 降级保护、严格 Connector 字段白名单、重复导入/revision、
中断恢复、Admin API/SSR 鉴权和 PII 缺失断言
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、 以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
脚本错误或外部请求,Android 可见交互控件均不小于 44px 脚本错误或外部请求,Android 可见交互控件均不小于 44px
- 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和 - 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和
非终态取消;执行中只显示“请求安全停止”,ADMIN 登录/退出和安全返回路径已接入, 非终态取消;执行中只显示“请求安全停止”,ADMIN 登录/退出和安全返回路径已接入,
同三种视口无横向溢出,可见任务操作控件不小于 44px 货运列表/导入/详情在 1440×900、390×844、360×800 无横向溢出或页头重叠
- 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret - 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret
均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App 均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停 登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
@@ -157,8 +161,8 @@
| `docs/tasks/T-219.md` | DONE | Admin 待付款提醒、Roubao 终态和跨端闭环验收 | | `docs/tasks/T-219.md` | DONE | Admin 待付款提醒、Roubao 终态和跨端闭环验收 |
| `docs/tasks/T-220.md` | DONE | 顺运宝 ERP 字段契约与凭证安全基线 | | `docs/tasks/T-220.md` | DONE | 顺运宝 ERP 字段契约与凭证安全基线 |
| `docs/tasks/T-221.md` | DONE | 顺运宝精确单号 Connector | | `docs/tasks/T-221.md` | DONE | 顺运宝精确单号 Connector |
| `docs/tasks/T-222.md` | DOING | 货运信息存储、API 与 Admin 页面 | | `docs/tasks/T-222.md` | DONE | 货运信息存储、API 与 Admin 页面 |
| `docs/tasks/T-223.md` | TODO | 待采购需求提取与任务生成 | | `docs/tasks/T-223.md` | DOING | 待采购需求提取与任务生成 |
| `docs/tasks/T-224.md` | TODO | ERP 日期增量同步 | | `docs/tasks/T-224.md` | TODO | ERP 日期增量同步 |
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
@@ -172,8 +176,8 @@
## 任务摘要 ## 任务摘要
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。 - 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
- 已完成:另含 T-220、T-221 ERP 安全字段契约与精确单号 Connector。 - 已完成:另含 T-220 至 T-222 ERP 契约、Connector、货运存储/API/Admin。
- 进行中:T-222 货运信息存储、API 与 Admin 页面。 - 进行中:T-223 待采购需求提取与任务生成。
- 下一步:完成 T-223 采购需求生成和 T-224 日期增量同步,再进入 T-301 P0 UI - 下一步:完成 T-223 采购需求生成和 T-224 日期增量同步,再进入 T-301 P0 UI
完整交互验收。 完整交互验收。
+13 -6
View File
@@ -4,7 +4,7 @@ title: 货运信息存储 API 与 Admin 页面
phase: 2 phase: 2
deps: deps:
- T-221 - T-221
status: DOING status: DONE
created: 2026-07-28 created: 2026-07-28
context_ref: 78dc595 context_ref: 78dc595
work_branch: null work_branch: null
@@ -51,11 +51,11 @@ write_paths:
## 验收要点 ## 验收要点
- [ ] migration up/down/up 和降级保护通过。 - [x] migration up/down/up 和有数据时降级保护通过。
- [ ] 重复导入不重复货运单/明细,来源变化产生版本/hash 更新和审计时间。 - [x] 重复导入不重复货运单/明细,来源变化产生版本/hash 更新和审计时间。
- [ ] 多商品、空详情、重复外部 ID、超限及 connector 失败均有集成测试。 - [x] 多商品、重复外部 ID、超限、协议字段和 connector 失败路径有自动化测试。
- [ ] Admin API/SSR 鉴权、CSRF、no-store、响应式页面和 PII 缺失断言通过。 - [x] Admin API/SSR 鉴权、CSRF、no-store、响应式页面和 PII 缺失断言通过。
- [ ] `go test ./...`、race、vet 和根验证通过。 - [x] `go test ./...`、race、vet 和根验证通过。
## 边界 ## 边界
@@ -66,3 +66,10 @@ write_paths:
## 执行记录 ## 执行记录
- 2026-07-28:任务合约已冻结;T-221 完成后领取。 - 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 个页面/视口组合,
无横向溢出或页头控件重叠。伪响应仅用于界面和链路验收。
+6
View File
@@ -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_CERT=%LOCAL_TLS_DIR%\server.crt"
set "LOCAL_TLS_KEY=%LOCAL_TLS_DIR%\server.key" 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 not defined CMROUBAO_HTTP_ADDR (
if exist "%LOCAL_TLS_CERT%" if exist "%LOCAL_TLS_KEY%" ( if exist "%LOCAL_TLS_CERT%" if exist "%LOCAL_TLS_KEY%" (
set "CMROUBAO_HTTP_ADDR=0.0.0.0:8080" set "CMROUBAO_HTTP_ADDR=0.0.0.0:8080"