feat(t222): add freight ingestion admin flow
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"cmroubao/backend-api/internal/config"
|
||||
"cmroubao/backend-api/internal/platform/assetstore"
|
||||
"cmroubao/backend-api/internal/platform/database"
|
||||
"cmroubao/backend-api/internal/platform/erpconnector"
|
||||
"cmroubao/backend-api/internal/platform/migration"
|
||||
"cmroubao/backend-api/internal/platform/password"
|
||||
repository "cmroubao/backend-api/internal/repository/sqlite"
|
||||
@@ -217,6 +218,31 @@ func buildRouter(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
erpTimeout := cfg.ERPConnectorTimeout
|
||||
if erpTimeout <= 0 {
|
||||
erpTimeout = 90 * time.Second
|
||||
}
|
||||
erpClient, err := erpconnector.New(
|
||||
cfg.ERPConnectorURL,
|
||||
cfg.ERPConnectorAPIKey,
|
||||
erpTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
freight, err := usecase.NewFreightService(
|
||||
store,
|
||||
erpClient,
|
||||
clock,
|
||||
ids,
|
||||
erpTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := freight.RecoverInterrupted(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
passwords, err := password.NewBcrypt(12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -249,6 +275,7 @@ func buildRouter(
|
||||
tasks,
|
||||
assets,
|
||||
authorizations,
|
||||
freight,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -294,6 +321,7 @@ func buildRouter(
|
||||
Tasks: tasks,
|
||||
Results: results,
|
||||
Authorizations: authorizations,
|
||||
Freight: freight,
|
||||
},
|
||||
webHandler,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -158,6 +159,36 @@ func TestBuildRouterRegistersProtectedLogoutRoute(t *testing.T) {
|
||||
response.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
for _, target := range []string{"/freight", "/freight/import"} {
|
||||
request = httptest.NewRequest(http.MethodGet, target, nil)
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") !=
|
||||
"/login?next="+url.QueryEscape(target) {
|
||||
t.Fatalf(
|
||||
"%s status/location = %d / %q",
|
||||
target,
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
}
|
||||
request = httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/v1/freight-orders",
|
||||
nil,
|
||||
)
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized ||
|
||||
response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf(
|
||||
"freight API status/cache = %d / %q",
|
||||
response.Code,
|
||||
response.Header().Get("Cache-Control"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type stubMigrationStatusReader struct {
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -18,6 +19,8 @@ const (
|
||||
ClaimLeaseEnvironment = "CMROUBAO_CLAIM_LEASE"
|
||||
RunningLeaseEnvironment = "CMROUBAO_RUNNING_LEASE"
|
||||
ReadinessTTLEnvironment = "CMROUBAO_READINESS_TTL"
|
||||
ERPConnectorURLEnvironment = "CMROUBAO_ERP_CONNECTOR_URL"
|
||||
ERPConnectorAPIKeyEnvironment = "CMROUBAO_ERP_CONNECTOR_API_KEY"
|
||||
|
||||
defaultHTTPAddress = "127.0.0.1:8080"
|
||||
defaultDatabasePath = "var/cmroubao.db"
|
||||
@@ -25,6 +28,7 @@ const (
|
||||
defaultClaimLease = 10 * time.Minute
|
||||
defaultRunningLease = 30 * time.Minute
|
||||
defaultReadinessTTL = 2 * time.Minute
|
||||
defaultERPConnectorURL = "http://127.0.0.1:8091"
|
||||
)
|
||||
|
||||
type LookupEnvironment func(string) (string, bool)
|
||||
@@ -44,6 +48,9 @@ type Config struct {
|
||||
ClaimLease time.Duration
|
||||
RunningLease time.Duration
|
||||
ReadinessTTL time.Duration
|
||||
ERPConnectorURL string
|
||||
ERPConnectorAPIKey string
|
||||
ERPConnectorTimeout time.Duration
|
||||
}
|
||||
|
||||
func Load(lookup LookupEnvironment) (Config, error) {
|
||||
@@ -137,6 +144,27 @@ func Load(lookup LookupEnvironment) (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
erpConnectorURL, err := environmentValue(
|
||||
lookup,
|
||||
ERPConnectorURLEnvironment,
|
||||
defaultERPConnectorURL,
|
||||
)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := validateLoopbackURL(erpConnectorURL); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
erpConnectorAPIKey := ""
|
||||
if value, exists := lookup(ERPConnectorAPIKeyEnvironment); exists {
|
||||
erpConnectorAPIKey = strings.TrimSpace(value)
|
||||
if len([]byte(erpConnectorAPIKey)) < 32 {
|
||||
return Config{}, errors.New(
|
||||
ERPConnectorAPIKeyEnvironment +
|
||||
" must contain at least 32 UTF-8 bytes",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return Config{
|
||||
HTTPAddress: httpAddress,
|
||||
@@ -153,9 +181,39 @@ func Load(lookup LookupEnvironment) (Config, error) {
|
||||
ClaimLease: claimLease,
|
||||
RunningLease: runningLease,
|
||||
ReadinessTTL: readinessTTL,
|
||||
ERPConnectorURL: strings.TrimRight(erpConnectorURL, "/"),
|
||||
ERPConnectorAPIKey: erpConnectorAPIKey,
|
||||
ERPConnectorTimeout: 90 * time.Second,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateLoopbackURL(value string) error {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme != "http" || parsed.User != nil ||
|
||||
parsed.RawQuery != "" || parsed.Fragment != "" ||
|
||||
(parsed.Path != "" && parsed.Path != "/") {
|
||||
return errors.New(
|
||||
ERPConnectorURLEnvironment +
|
||||
" must be an http loopback origin without credentials or path",
|
||||
)
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
ip := net.ParseIP(host)
|
||||
if !strings.EqualFold(host, "localhost") &&
|
||||
(ip == nil || !ip.IsLoopback()) {
|
||||
return errors.New(
|
||||
ERPConnectorURLEnvironment + " must use a loopback host",
|
||||
)
|
||||
}
|
||||
port, err := strconv.Atoi(parsed.Port())
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return errors.New(
|
||||
ERPConnectorURLEnvironment + " must include a valid port",
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func durationEnvironment(
|
||||
lookup LookupEnvironment,
|
||||
name string,
|
||||
|
||||
@@ -42,6 +42,16 @@ func TestLoadUsesSafeDefaults(t *testing.T) {
|
||||
if cfg.ShutdownTimeout > 30*time.Second {
|
||||
t.Fatalf("ShutdownTimeout = %s", cfg.ShutdownTimeout)
|
||||
}
|
||||
if cfg.ERPConnectorURL != "http://127.0.0.1:8091" ||
|
||||
cfg.ERPConnectorAPIKey != "" ||
|
||||
cfg.ERPConnectorTimeout != 90*time.Second {
|
||||
t.Fatalf(
|
||||
"ERP connector defaults = %q / %q / %s",
|
||||
cfg.ERPConnectorURL,
|
||||
cfg.ERPConnectorAPIKey,
|
||||
cfg.ERPConnectorTimeout,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
|
||||
@@ -54,6 +64,8 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
|
||||
ClaimLeaseEnvironment: "15m",
|
||||
RunningLeaseEnvironment: "45m",
|
||||
ReadinessTTLEnvironment: "3m",
|
||||
ERPConnectorURLEnvironment: "http://localhost:18091",
|
||||
ERPConnectorAPIKeyEnvironment: "12345678901234567890123456789012",
|
||||
}
|
||||
|
||||
cfg, err := Load(mapEnvironment(values))
|
||||
@@ -88,6 +100,14 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) {
|
||||
cfg.ReadinessTTL,
|
||||
)
|
||||
}
|
||||
if cfg.ERPConnectorURL != values[ERPConnectorURLEnvironment] ||
|
||||
cfg.ERPConnectorAPIKey != values[ERPConnectorAPIKeyEnvironment] {
|
||||
t.Fatalf(
|
||||
"ERP connector = %q / %q",
|
||||
cfg.ERPConnectorURL,
|
||||
cfg.ERPConnectorAPIKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
|
||||
@@ -95,6 +115,24 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) {
|
||||
name string
|
||||
values map[string]string
|
||||
}{
|
||||
{
|
||||
name: "non-loopback ERP connector",
|
||||
values: map[string]string{
|
||||
ERPConnectorURLEnvironment: "http://192.0.2.10:8091",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ERP connector path",
|
||||
values: map[string]string{
|
||||
ERPConnectorURLEnvironment: "http://127.0.0.1:8091/private",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "short ERP connector key",
|
||||
values: map[string]string{
|
||||
ERPConnectorAPIKeyEnvironment: "short",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blank explicit address",
|
||||
values: map[string]string{
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
FreightSourceShunyunbao = "SHUNYUNBAO"
|
||||
FreightSyncOrderNumber = "ORDER_NUMBER"
|
||||
)
|
||||
|
||||
type FreightSyncStatus string
|
||||
|
||||
const (
|
||||
FreightSyncPending FreightSyncStatus = "PENDING"
|
||||
FreightSyncRunning FreightSyncStatus = "RUNNING"
|
||||
FreightSyncSucceeded FreightSyncStatus = "SUCCEEDED"
|
||||
FreightSyncFailed FreightSyncStatus = "FAILED"
|
||||
)
|
||||
|
||||
type FreightSyncRun struct {
|
||||
ID string
|
||||
CreatorSubject string
|
||||
CreatedByUserID string
|
||||
Mode string
|
||||
OrderNumber string
|
||||
QuerySHA256 string
|
||||
Status FreightSyncStatus
|
||||
ErrorCode *string
|
||||
OrderCount int
|
||||
ItemCount int
|
||||
CreatedAt time.Time
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
}
|
||||
|
||||
type FreightOrder struct {
|
||||
ID string
|
||||
CreatorSubject string
|
||||
SourceSystem string
|
||||
ExternalStockID string
|
||||
SourceCode string
|
||||
PlatformOrderNo *string
|
||||
ShopName *string
|
||||
SourceCreatedAt *time.Time
|
||||
OrderStatus *string
|
||||
PurchaseStatus *string
|
||||
IsCanceled *bool
|
||||
CanonicalSHA256 string
|
||||
Revision int
|
||||
FirstSyncRunID string
|
||||
LastSyncRunID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ItemCount int
|
||||
}
|
||||
|
||||
type FreightOrderItem struct {
|
||||
ID string
|
||||
FreightOrderID string
|
||||
ExternalItemID string
|
||||
Title string
|
||||
ProductSpec string
|
||||
SKU string
|
||||
Quantity *int
|
||||
ProductThumbRef *string
|
||||
PurchaseStatus *string
|
||||
CanonicalSHA256 string
|
||||
Revision int
|
||||
IsPresent bool
|
||||
FirstSyncRunID string
|
||||
LastSyncRunID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type FreightOrderDetail struct {
|
||||
Order FreightOrder
|
||||
Items []FreightOrderItem
|
||||
}
|
||||
|
||||
type FreightSourceBatch struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Query FreightSourceQuery `json:"query"`
|
||||
Orders []FreightSourceOrder `json:"orders"`
|
||||
}
|
||||
|
||||
type FreightSourceQuery struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type FreightSourceOrder struct {
|
||||
ExternalStockID string `json:"external_stock_id"`
|
||||
SourceCode string `json:"source_code"`
|
||||
PlatformOrderNo *string `json:"platform_order_no"`
|
||||
ShopName *string `json:"shop_name"`
|
||||
SourceCreatedAt *string `json:"source_created_at"`
|
||||
OrderStatus *string `json:"order_status"`
|
||||
PurchaseStatus *string `json:"purchase_status"`
|
||||
IsCanceled *bool `json:"is_canceled"`
|
||||
Items []FreightSourceItem `json:"items"`
|
||||
}
|
||||
|
||||
type FreightSourceItem struct {
|
||||
ExternalItemID string `json:"external_item_id"`
|
||||
Title string `json:"title"`
|
||||
ProductSpec string `json:"product_spec"`
|
||||
SKU string `json:"sku"`
|
||||
Quantity *int `json:"quantity"`
|
||||
ProductThumbRef *string `json:"product_thumb_ref"`
|
||||
PurchaseStatus *string `json:"purchase_status"`
|
||||
}
|
||||
|
||||
type FreightImportBatch struct {
|
||||
Orders []FreightImportOrder
|
||||
}
|
||||
|
||||
type FreightImportOrder struct {
|
||||
ID string
|
||||
ExternalStockID string
|
||||
SourceCode string
|
||||
PlatformOrderNo *string
|
||||
ShopName *string
|
||||
SourceCreatedAt *time.Time
|
||||
OrderStatus *string
|
||||
PurchaseStatus *string
|
||||
IsCanceled *bool
|
||||
CanonicalSHA256 string
|
||||
Items []FreightImportItem
|
||||
}
|
||||
|
||||
type FreightImportItem struct {
|
||||
ID string
|
||||
ExternalItemID string
|
||||
Title string
|
||||
ProductSpec string
|
||||
SKU string
|
||||
Quantity *int
|
||||
ProductThumbRef *string
|
||||
PurchaseStatus *string
|
||||
CanonicalSHA256 string
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package erpconnector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotConfigured = errors.New("ERP connector is not configured")
|
||||
ErrSessionRequired = errors.New("ERP session is required")
|
||||
ErrNotFound = errors.New("ERP freight order not found")
|
||||
ErrUnavailable = errors.New("ERP connector is unavailable")
|
||||
ErrProtocol = errors.New("ERP connector protocol is invalid")
|
||||
)
|
||||
|
||||
const maxResponseBytes = 4 << 20
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL, apiKey string, timeout time.Duration) (*Client, error) {
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = "http://127.0.0.1:8091"
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 90 * time.Second
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return nil, errors.New("ERP connector client configuration is invalid")
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
apiKey: strings.TrimSpace(apiKey),
|
||||
http: &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(
|
||||
_ *http.Request,
|
||||
_ []*http.Request,
|
||||
) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (client *Client) QueryOrder(
|
||||
ctx context.Context,
|
||||
orderNumber string,
|
||||
) (domain.FreightSourceBatch, error) {
|
||||
if client.apiKey == "" {
|
||||
return domain.FreightSourceBatch{}, ErrNotConfigured
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{"order_number": orderNumber})
|
||||
if err != nil {
|
||||
return domain.FreightSourceBatch{}, ErrProtocol
|
||||
}
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
client.baseURL+"/v1/freight/query",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return domain.FreightSourceBatch{}, ErrUnavailable
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-API-Key", client.apiKey)
|
||||
response, err := client.http.Do(request)
|
||||
if err != nil {
|
||||
return domain.FreightSourceBatch{}, ErrUnavailable
|
||||
}
|
||||
defer response.Body.Close()
|
||||
limited := io.LimitReader(response.Body, maxResponseBytes+1)
|
||||
content, err := io.ReadAll(limited)
|
||||
if err != nil || len(content) > maxResponseBytes {
|
||||
return domain.FreightSourceBatch{}, ErrUnavailable
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
switch response.StatusCode {
|
||||
case http.StatusUnauthorized:
|
||||
return domain.FreightSourceBatch{}, ErrSessionRequired
|
||||
case http.StatusNotFound:
|
||||
return domain.FreightSourceBatch{}, ErrNotFound
|
||||
default:
|
||||
return domain.FreightSourceBatch{}, ErrUnavailable
|
||||
}
|
||||
}
|
||||
var result domain.FreightSourceBatch
|
||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&result); err != nil {
|
||||
return domain.FreightSourceBatch{}, ErrProtocol
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
return domain.FreightSourceBatch{}, ErrProtocol
|
||||
}
|
||||
if result.SchemaVersion != 1 || result.Query.Mode != "ORDER_NUMBER" ||
|
||||
result.Orders == nil {
|
||||
return domain.FreightSourceBatch{}, ErrProtocol
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package erpconnector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestQueryOrderAcceptsAllowlistResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
if request.Header.Get("X-API-Key") != "12345678901234567890123456789012" {
|
||||
t.Fatal("service key was not forwarded")
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = writer.Write([]byte(`{
|
||||
"schema_version":1,
|
||||
"query":{"mode":"ORDER_NUMBER"},
|
||||
"orders":[{
|
||||
"external_stock_id":"12",
|
||||
"source_code":"SOURCE-12",
|
||||
"platform_order_no":null,
|
||||
"shop_name":"测试店铺",
|
||||
"source_created_at":"2026-07-28 08:00:00",
|
||||
"order_status":"0",
|
||||
"purchase_status":"1",
|
||||
"is_canceled":false,
|
||||
"items":[{
|
||||
"external_item_id":"88",
|
||||
"title":"商品",
|
||||
"product_spec":"黑色,L",
|
||||
"sku":"BLACK-L",
|
||||
"quantity":2,
|
||||
"product_thumb_ref":"190",
|
||||
"purchase_status":"0"
|
||||
}]
|
||||
}]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(
|
||||
server.URL,
|
||||
"12345678901234567890123456789012",
|
||||
time.Second,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
result, err := client.QueryOrder(context.Background(), "SOURCE-12")
|
||||
if err != nil {
|
||||
t.Fatalf("QueryOrder() error = %v", err)
|
||||
}
|
||||
if len(result.Orders) != 1 || len(result.Orders[0].Items) != 1 ||
|
||||
result.Orders[0].Items[0].SKU != "BLACK-L" {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryOrderRejectsUnexpectedPIIField(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = writer.Write([]byte(`{
|
||||
"schema_version":1,
|
||||
"query":{"mode":"ORDER_NUMBER"},
|
||||
"orders":[],
|
||||
"receiverTel":"private"
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(server.URL, "12345678901234567890123456789012", time.Second)
|
||||
_, err := client.QueryOrder(context.Background(), "SOURCE-12")
|
||||
if !errors.Is(err, ErrProtocol) {
|
||||
t.Fatalf("QueryOrder() error = %v, want protocol error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryOrderMapsStableErrorsWithoutReadingDetails(t *testing.T) {
|
||||
tests := []struct {
|
||||
status int
|
||||
want error
|
||||
}{
|
||||
{http.StatusUnauthorized, ErrSessionRequired},
|
||||
{http.StatusNotFound, ErrNotFound},
|
||||
{http.StatusBadGateway, ErrUnavailable},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(http.StatusText(test.status), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
writer.WriteHeader(test.status)
|
||||
_, _ = writer.Write([]byte(`{"detail":"private upstream text"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(
|
||||
server.URL,
|
||||
"12345678901234567890123456789012",
|
||||
time.Second,
|
||||
)
|
||||
_, err := client.QueryOrder(context.Background(), "SOURCE-12")
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("QueryOrder() error = %v, want %v", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryOrderDoesNotFollowRedirects(t *testing.T) {
|
||||
followed := false
|
||||
target := httptest.NewServer(http.HandlerFunc(func(
|
||||
http.ResponseWriter,
|
||||
*http.Request,
|
||||
) {
|
||||
followed = true
|
||||
}))
|
||||
defer target.Close()
|
||||
redirect := httptest.NewServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
writer.Header().Set("Location", target.URL)
|
||||
writer.WriteHeader(http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer redirect.Close()
|
||||
client, _ := New(
|
||||
redirect.URL,
|
||||
"12345678901234567890123456789012",
|
||||
time.Second,
|
||||
)
|
||||
_, err := client.QueryOrder(context.Background(), "SOURCE-12")
|
||||
if !errors.Is(err, ErrUnavailable) || followed {
|
||||
t.Fatalf("redirect error/followed = %v / %t", err, followed)
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("initial Up() error = %v", err)
|
||||
} else if applied != 11 {
|
||||
t.Fatalf("initial Up() applied = %d, want 11", applied)
|
||||
} else if applied != 12 {
|
||||
t.Fatalf("initial Up() applied = %d, want 12", applied)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v12) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v11) error = %v", err)
|
||||
@@ -62,9 +65,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
seedClaimsHistoricalFixture(t, db)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("Up(v5-v11) over historical data error = %v", err)
|
||||
} else if applied != 7 {
|
||||
t.Fatalf("Up(v5-v11) applied = %d, want 7", applied)
|
||||
t.Fatalf("Up(v5-v12) over historical data error = %v", err)
|
||||
} else if applied != 8 {
|
||||
t.Fatalf("Up(v5-v12) applied = %d, want 8", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v12) with compatible history error = %v", err)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
@@ -109,9 +117,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
assertClaimsHistory(t, db, false)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("final Up(v4-v11) error = %v", err)
|
||||
} else if applied != 8 {
|
||||
t.Fatalf("final Up(v4-v11) applied = %d, want 8", applied)
|
||||
t.Fatalf("final Up(v4-v12) error = %v", err)
|
||||
} else if applied != 9 {
|
||||
t.Fatalf("final Up(v4-v12) applied = %d, want 9", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
}
|
||||
@@ -347,6 +355,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
|
||||
t.Fatalf("insert v4 audit event: %v", err)
|
||||
}
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v12) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v11) error = %v", err)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Up() error = %v", err)
|
||||
}
|
||||
if applied != 11 {
|
||||
t.Fatalf("Up() applied = %d, want 11", applied)
|
||||
if applied != 12 {
|
||||
t.Fatalf("Up() applied = %d, want 12", applied)
|
||||
}
|
||||
assertStatuses(t, runner, map[int64]bool{
|
||||
1: true,
|
||||
@@ -42,6 +42,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
9: true,
|
||||
10: true,
|
||||
11: true,
|
||||
12: true,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -66,7 +67,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
8: true,
|
||||
9: true,
|
||||
10: true,
|
||||
11: false,
|
||||
11: true,
|
||||
12: false,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -88,6 +90,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
9: true,
|
||||
10: true,
|
||||
11: true,
|
||||
12: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("Down(v12) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("Down(v11) error = %v", err)
|
||||
}
|
||||
@@ -423,9 +426,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
||||
t.Fatal("purchase_tasks was lost during auth migration rollback")
|
||||
}
|
||||
if applied, err := runner.Up(context.Background()); err != nil {
|
||||
t.Fatalf("Up(v3-v11) error = %v", err)
|
||||
} else if applied != 9 {
|
||||
t.Fatalf("Up(v3-v11) applied = %d, want 9", applied)
|
||||
t.Fatalf("Up(v3-v12) error = %v", err)
|
||||
} else if applied != 10 {
|
||||
t.Fatalf("Up(v3-v12) applied = %d, want 10", applied)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
func (store *Store) CreateFreightSync(
|
||||
ctx context.Context,
|
||||
run domain.FreightSyncRun,
|
||||
idempotencyKey string,
|
||||
requestSHA256 string,
|
||||
) (domain.FreightSyncRun, bool, error) {
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.FreightSyncRun{}, false, repositoryFailure(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
existing, err := scanFreightSync(tx.QueryRowContext(
|
||||
ctx,
|
||||
freightSyncSelect+`
|
||||
WHERE creator_subject = ? AND idempotency_key = ?`,
|
||||
run.CreatorSubject,
|
||||
idempotencyKey,
|
||||
))
|
||||
if err == nil {
|
||||
var storedHash string
|
||||
if err := tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT request_sha256
|
||||
FROM erp_sync_runs
|
||||
WHERE creator_subject = ? AND idempotency_key = ?`,
|
||||
run.CreatorSubject,
|
||||
idempotencyKey,
|
||||
).Scan(&storedHash); err != nil {
|
||||
return domain.FreightSyncRun{}, false, repositoryFailure(err)
|
||||
}
|
||||
if storedHash != requestSHA256 {
|
||||
return domain.FreightSyncRun{}, false, usecase.ErrIdempotencyConflict
|
||||
}
|
||||
return existing, false, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.FreightSyncRun{}, false, repositoryFailure(err)
|
||||
}
|
||||
_, err = tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO erp_sync_runs (
|
||||
id, creator_subject, created_by_user_id, mode, order_number,
|
||||
query_sha256, idempotency_key, request_sha256, status,
|
||||
order_count, item_count, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?)`,
|
||||
run.ID,
|
||||
run.CreatorSubject,
|
||||
run.CreatedByUserID,
|
||||
run.Mode,
|
||||
run.OrderNumber,
|
||||
run.QuerySHA256,
|
||||
idempotencyKey,
|
||||
requestSHA256,
|
||||
run.Status,
|
||||
formatTimestamp(run.CreatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return domain.FreightSyncRun{}, false, repositoryFailure(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.FreightSyncRun{}, false, repositoryFailure(err)
|
||||
}
|
||||
return run, true, nil
|
||||
}
|
||||
|
||||
func (store *Store) StartFreightSync(
|
||||
ctx context.Context,
|
||||
runID string,
|
||||
startedAt time.Time,
|
||||
) error {
|
||||
result, err := store.db.ExecContext(
|
||||
ctx,
|
||||
`UPDATE erp_sync_runs
|
||||
SET status = 'RUNNING', started_at = ?
|
||||
WHERE id = ? AND status = 'PENDING'`,
|
||||
formatTimestamp(startedAt),
|
||||
runID,
|
||||
)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
changed, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
if changed != 1 {
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) CompleteFreightSync(
|
||||
ctx context.Context,
|
||||
run domain.FreightSyncRun,
|
||||
batch domain.FreightImportBatch,
|
||||
finishedAt time.Time,
|
||||
) error {
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var status string
|
||||
if err := tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT status FROM erp_sync_runs WHERE id = ?`,
|
||||
run.ID,
|
||||
).Scan(&status); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return usecase.ErrRepositoryNotFound
|
||||
}
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
if status != string(domain.FreightSyncRunning) {
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
itemCount := 0
|
||||
for _, order := range batch.Orders {
|
||||
orderID, err := upsertFreightOrder(
|
||||
ctx,
|
||||
tx,
|
||||
run,
|
||||
order,
|
||||
finishedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE freight_order_items
|
||||
SET is_present = 0, last_sync_run_id = ?, updated_at = ?
|
||||
WHERE freight_order_id = ?`,
|
||||
run.ID,
|
||||
formatTimestamp(finishedAt),
|
||||
orderID,
|
||||
); err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
for _, item := range order.Items {
|
||||
if err := upsertFreightOrderItem(
|
||||
ctx,
|
||||
tx,
|
||||
run.ID,
|
||||
orderID,
|
||||
item,
|
||||
finishedAt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
itemCount++
|
||||
}
|
||||
}
|
||||
result, err := tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE erp_sync_runs
|
||||
SET status = 'SUCCEEDED', error_code = NULL, order_count = ?,
|
||||
item_count = ?, finished_at = ?
|
||||
WHERE id = ? AND status = 'RUNNING'`,
|
||||
len(batch.Orders),
|
||||
itemCount,
|
||||
formatTimestamp(finishedAt),
|
||||
run.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
changed, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
if changed != 1 {
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertFreightOrder(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
run domain.FreightSyncRun,
|
||||
order domain.FreightImportOrder,
|
||||
now time.Time,
|
||||
) (string, error) {
|
||||
_, err := tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO freight_orders (
|
||||
id, creator_subject, source_system, external_stock_id,
|
||||
source_code, platform_order_no, shop_name, source_created_at,
|
||||
order_status, purchase_status, is_canceled, canonical_sha256,
|
||||
revision, first_sync_run_id, last_sync_run_id, created_at, updated_at
|
||||
) VALUES (?, ?, 'SHUNYUNBAO', ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
|
||||
ON CONFLICT (creator_subject, source_system, external_stock_id)
|
||||
DO UPDATE SET
|
||||
source_code = excluded.source_code,
|
||||
platform_order_no = excluded.platform_order_no,
|
||||
shop_name = excluded.shop_name,
|
||||
source_created_at = excluded.source_created_at,
|
||||
order_status = excluded.order_status,
|
||||
purchase_status = excluded.purchase_status,
|
||||
is_canceled = excluded.is_canceled,
|
||||
revision = CASE
|
||||
WHEN freight_orders.canonical_sha256 != excluded.canonical_sha256
|
||||
THEN freight_orders.revision + 1
|
||||
ELSE freight_orders.revision
|
||||
END,
|
||||
canonical_sha256 = excluded.canonical_sha256,
|
||||
last_sync_run_id = excluded.last_sync_run_id,
|
||||
updated_at = excluded.updated_at`,
|
||||
order.ID,
|
||||
run.CreatorSubject,
|
||||
order.ExternalStockID,
|
||||
order.SourceCode,
|
||||
nullableString(order.PlatformOrderNo),
|
||||
nullableString(order.ShopName),
|
||||
nullableTimestamp(order.SourceCreatedAt),
|
||||
nullableString(order.OrderStatus),
|
||||
nullableString(order.PurchaseStatus),
|
||||
nullableBool(order.IsCanceled),
|
||||
order.CanonicalSHA256,
|
||||
run.ID,
|
||||
run.ID,
|
||||
formatTimestamp(now),
|
||||
formatTimestamp(now),
|
||||
)
|
||||
if err != nil {
|
||||
return "", repositoryFailure(err)
|
||||
}
|
||||
var orderID string
|
||||
if err := tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id FROM freight_orders
|
||||
WHERE creator_subject = ? AND source_system = 'SHUNYUNBAO'
|
||||
AND external_stock_id = ?`,
|
||||
run.CreatorSubject,
|
||||
order.ExternalStockID,
|
||||
).Scan(&orderID); err != nil {
|
||||
return "", repositoryFailure(err)
|
||||
}
|
||||
return orderID, nil
|
||||
}
|
||||
|
||||
func upsertFreightOrderItem(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
runID, orderID string,
|
||||
item domain.FreightImportItem,
|
||||
now time.Time,
|
||||
) error {
|
||||
_, err := tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO freight_order_items (
|
||||
id, freight_order_id, external_item_id, title, product_spec,
|
||||
sku, quantity, product_thumb_ref, purchase_status,
|
||||
canonical_sha256, revision, is_present, first_sync_run_id,
|
||||
last_sync_run_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?)
|
||||
ON CONFLICT (freight_order_id, external_item_id)
|
||||
DO UPDATE SET
|
||||
title = excluded.title,
|
||||
product_spec = excluded.product_spec,
|
||||
sku = excluded.sku,
|
||||
quantity = excluded.quantity,
|
||||
product_thumb_ref = excluded.product_thumb_ref,
|
||||
purchase_status = excluded.purchase_status,
|
||||
revision = CASE
|
||||
WHEN freight_order_items.canonical_sha256 != excluded.canonical_sha256
|
||||
THEN freight_order_items.revision + 1
|
||||
ELSE freight_order_items.revision
|
||||
END,
|
||||
canonical_sha256 = excluded.canonical_sha256,
|
||||
is_present = 1,
|
||||
last_sync_run_id = excluded.last_sync_run_id,
|
||||
updated_at = excluded.updated_at`,
|
||||
item.ID,
|
||||
orderID,
|
||||
item.ExternalItemID,
|
||||
item.Title,
|
||||
item.ProductSpec,
|
||||
item.SKU,
|
||||
nullableFreightQuantity(item.Quantity),
|
||||
nullableString(item.ProductThumbRef),
|
||||
nullableString(item.PurchaseStatus),
|
||||
item.CanonicalSHA256,
|
||||
runID,
|
||||
runID,
|
||||
formatTimestamp(now),
|
||||
formatTimestamp(now),
|
||||
)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) FailFreightSync(
|
||||
ctx context.Context,
|
||||
runID, errorCode string,
|
||||
finishedAt time.Time,
|
||||
) error {
|
||||
result, err := store.db.ExecContext(
|
||||
ctx,
|
||||
`UPDATE erp_sync_runs
|
||||
SET status = 'FAILED', error_code = ?, finished_at = ?
|
||||
WHERE id = ? AND status IN ('PENDING', 'RUNNING')`,
|
||||
errorCode,
|
||||
formatTimestamp(finishedAt),
|
||||
runID,
|
||||
)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
changed, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
if changed != 1 {
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) RecoverFreightSyncs(
|
||||
ctx context.Context,
|
||||
finishedAt time.Time,
|
||||
) (int64, error) {
|
||||
result, err := store.db.ExecContext(
|
||||
ctx,
|
||||
`UPDATE erp_sync_runs
|
||||
SET status = 'FAILED', error_code = 'PROCESS_INTERRUPTED',
|
||||
finished_at = ?
|
||||
WHERE status IN ('PENDING', 'RUNNING')`,
|
||||
formatTimestamp(finishedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, repositoryFailure(err)
|
||||
}
|
||||
changed, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, repositoryFailure(err)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (store *Store) GetFreightSync(
|
||||
ctx context.Context,
|
||||
creatorSubject, runID string,
|
||||
) (domain.FreightSyncRun, error) {
|
||||
run, err := scanFreightSync(store.db.QueryRowContext(
|
||||
ctx,
|
||||
freightSyncSelect+`
|
||||
WHERE creator_subject = ? AND id = ?`,
|
||||
creatorSubject,
|
||||
runID,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.FreightSyncRun{}, usecase.ErrRepositoryNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.FreightSyncRun{}, repositoryFailure(err)
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func (store *Store) ListFreightOrders(
|
||||
ctx context.Context,
|
||||
creatorSubject string,
|
||||
limit int,
|
||||
) ([]domain.FreightOrder, error) {
|
||||
rows, err := store.db.QueryContext(
|
||||
ctx,
|
||||
freightOrderSelect+`
|
||||
WHERE freight_orders.creator_subject = ?
|
||||
ORDER BY freight_orders.updated_at DESC, freight_orders.id DESC
|
||||
LIMIT ?`,
|
||||
creatorSubject,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
orders := make([]domain.FreightOrder, 0)
|
||||
for rows.Next() {
|
||||
order, err := scanFreightOrder(rows)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
orders = append(orders, order)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (store *Store) GetFreightOrder(
|
||||
ctx context.Context,
|
||||
creatorSubject, orderID string,
|
||||
) (domain.FreightOrderDetail, error) {
|
||||
order, err := scanFreightOrder(store.db.QueryRowContext(
|
||||
ctx,
|
||||
freightOrderSelect+`
|
||||
WHERE freight_orders.creator_subject = ? AND freight_orders.id = ?`,
|
||||
creatorSubject,
|
||||
orderID,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.FreightOrderDetail{}, usecase.ErrRepositoryNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.FreightOrderDetail{}, repositoryFailure(err)
|
||||
}
|
||||
rows, err := store.db.QueryContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
id, freight_order_id, external_item_id, title, product_spec,
|
||||
sku, quantity, product_thumb_ref, purchase_status,
|
||||
canonical_sha256, revision, is_present, first_sync_run_id,
|
||||
last_sync_run_id, created_at, updated_at
|
||||
FROM freight_order_items
|
||||
WHERE freight_order_id = ? AND is_present = 1
|
||||
ORDER BY CAST(external_item_id AS INTEGER), external_item_id`,
|
||||
order.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.FreightOrderDetail{}, repositoryFailure(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]domain.FreightOrderItem, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanFreightOrderItem(rows)
|
||||
if err != nil {
|
||||
return domain.FreightOrderDetail{}, repositoryFailure(err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.FreightOrderDetail{}, repositoryFailure(err)
|
||||
}
|
||||
return domain.FreightOrderDetail{Order: order, Items: items}, nil
|
||||
}
|
||||
|
||||
const freightSyncSelect = `SELECT
|
||||
id, creator_subject, created_by_user_id, mode, order_number,
|
||||
query_sha256, status, error_code, order_count, item_count,
|
||||
created_at, started_at, finished_at
|
||||
FROM erp_sync_runs
|
||||
`
|
||||
|
||||
func scanFreightSync(scanner rowScanner) (domain.FreightSyncRun, error) {
|
||||
var run domain.FreightSyncRun
|
||||
var errorCode sql.NullString
|
||||
var createdAt string
|
||||
var startedAt sql.NullString
|
||||
var finishedAt sql.NullString
|
||||
err := scanner.Scan(
|
||||
&run.ID,
|
||||
&run.CreatorSubject,
|
||||
&run.CreatedByUserID,
|
||||
&run.Mode,
|
||||
&run.OrderNumber,
|
||||
&run.QuerySHA256,
|
||||
&run.Status,
|
||||
&errorCode,
|
||||
&run.OrderCount,
|
||||
&run.ItemCount,
|
||||
&createdAt,
|
||||
&startedAt,
|
||||
&finishedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.FreightSyncRun{}, err
|
||||
}
|
||||
if errorCode.Valid {
|
||||
run.ErrorCode = &errorCode.String
|
||||
}
|
||||
run.CreatedAt, err = parseTimestamp(createdAt)
|
||||
if err != nil {
|
||||
return domain.FreightSyncRun{}, err
|
||||
}
|
||||
run.StartedAt, err = parseNullableTimestamp(startedAt)
|
||||
if err != nil {
|
||||
return domain.FreightSyncRun{}, err
|
||||
}
|
||||
run.FinishedAt, err = parseNullableTimestamp(finishedAt)
|
||||
return run, err
|
||||
}
|
||||
|
||||
const freightOrderSelect = `SELECT
|
||||
freight_orders.id, freight_orders.creator_subject,
|
||||
freight_orders.source_system, freight_orders.external_stock_id,
|
||||
freight_orders.source_code, freight_orders.platform_order_no,
|
||||
freight_orders.shop_name, freight_orders.source_created_at,
|
||||
freight_orders.order_status, freight_orders.purchase_status,
|
||||
freight_orders.is_canceled, freight_orders.canonical_sha256,
|
||||
freight_orders.revision, freight_orders.first_sync_run_id,
|
||||
freight_orders.last_sync_run_id, freight_orders.created_at,
|
||||
freight_orders.updated_at,
|
||||
(SELECT count(*) FROM freight_order_items
|
||||
WHERE freight_order_id = freight_orders.id AND is_present = 1)
|
||||
FROM freight_orders
|
||||
`
|
||||
|
||||
func scanFreightOrder(scanner rowScanner) (domain.FreightOrder, error) {
|
||||
var order domain.FreightOrder
|
||||
var platformOrderNo, shopName, sourceCreatedAt sql.NullString
|
||||
var orderStatus, purchaseStatus sql.NullString
|
||||
var isCanceled sql.NullBool
|
||||
var createdAt, updatedAt string
|
||||
err := scanner.Scan(
|
||||
&order.ID,
|
||||
&order.CreatorSubject,
|
||||
&order.SourceSystem,
|
||||
&order.ExternalStockID,
|
||||
&order.SourceCode,
|
||||
&platformOrderNo,
|
||||
&shopName,
|
||||
&sourceCreatedAt,
|
||||
&orderStatus,
|
||||
&purchaseStatus,
|
||||
&isCanceled,
|
||||
&order.CanonicalSHA256,
|
||||
&order.Revision,
|
||||
&order.FirstSyncRunID,
|
||||
&order.LastSyncRunID,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
&order.ItemCount,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.FreightOrder{}, err
|
||||
}
|
||||
order.PlatformOrderNo = optionalString(platformOrderNo)
|
||||
order.ShopName = optionalString(shopName)
|
||||
order.OrderStatus = optionalString(orderStatus)
|
||||
order.PurchaseStatus = optionalString(purchaseStatus)
|
||||
if isCanceled.Valid {
|
||||
order.IsCanceled = &isCanceled.Bool
|
||||
}
|
||||
if sourceCreatedAt.Valid {
|
||||
parsed, err := parseTimestamp(sourceCreatedAt.String)
|
||||
if err != nil {
|
||||
return domain.FreightOrder{}, err
|
||||
}
|
||||
order.SourceCreatedAt = &parsed
|
||||
}
|
||||
order.CreatedAt, err = parseTimestamp(createdAt)
|
||||
if err != nil {
|
||||
return domain.FreightOrder{}, err
|
||||
}
|
||||
order.UpdatedAt, err = parseTimestamp(updatedAt)
|
||||
return order, err
|
||||
}
|
||||
|
||||
func scanFreightOrderItem(scanner rowScanner) (domain.FreightOrderItem, error) {
|
||||
var item domain.FreightOrderItem
|
||||
var quantity sql.NullInt64
|
||||
var thumb, purchaseStatus sql.NullString
|
||||
var createdAt, updatedAt string
|
||||
err := scanner.Scan(
|
||||
&item.ID,
|
||||
&item.FreightOrderID,
|
||||
&item.ExternalItemID,
|
||||
&item.Title,
|
||||
&item.ProductSpec,
|
||||
&item.SKU,
|
||||
&quantity,
|
||||
&thumb,
|
||||
&purchaseStatus,
|
||||
&item.CanonicalSHA256,
|
||||
&item.Revision,
|
||||
&item.IsPresent,
|
||||
&item.FirstSyncRunID,
|
||||
&item.LastSyncRunID,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.FreightOrderItem{}, err
|
||||
}
|
||||
if quantity.Valid {
|
||||
value := int(quantity.Int64)
|
||||
item.Quantity = &value
|
||||
}
|
||||
item.ProductThumbRef = optionalString(thumb)
|
||||
item.PurchaseStatus = optionalString(purchaseStatus)
|
||||
item.CreatedAt, err = parseTimestamp(createdAt)
|
||||
if err != nil {
|
||||
return domain.FreightOrderItem{}, err
|
||||
}
|
||||
item.UpdatedAt, err = parseTimestamp(updatedAt)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func nullableTimestamp(value *time.Time) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return formatTimestamp(*value)
|
||||
}
|
||||
|
||||
func nullableFreightQuantity(value *int) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func optionalString(value sql.NullString) *string {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
return &value.String
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/platform/migration"
|
||||
repository "cmroubao/backend-api/internal/repository/sqlite"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) {
|
||||
db := openDatabase(t)
|
||||
store, err := repository.New(db)
|
||||
if err != nil {
|
||||
t.Fatalf("repository.New() error = %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 7, 28, 1, 2, 3, 0, time.UTC)
|
||||
userID := uuid(900)
|
||||
seedFreightUser(t, db, userID, now)
|
||||
|
||||
first := freightRun(901, userID, now)
|
||||
stored, created, err := store.CreateFreightSync(
|
||||
ctx,
|
||||
first,
|
||||
"freight-key-1",
|
||||
repeatHex("1"),
|
||||
)
|
||||
if err != nil || !created || stored.ID != first.ID {
|
||||
t.Fatalf("CreateFreightSync() = %+v, %t, %v", stored, created, err)
|
||||
}
|
||||
replayed, created, err := store.CreateFreightSync(
|
||||
ctx,
|
||||
freightRun(902, userID, now),
|
||||
"freight-key-1",
|
||||
repeatHex("1"),
|
||||
)
|
||||
if err != nil || created || replayed.ID != first.ID {
|
||||
t.Fatalf("sync replay = %+v, %t, %v", replayed, created, err)
|
||||
}
|
||||
_, _, err = store.CreateFreightSync(
|
||||
ctx,
|
||||
freightRun(903, userID, now),
|
||||
"freight-key-1",
|
||||
repeatHex("2"),
|
||||
)
|
||||
if !errors.Is(err, usecase.ErrIdempotencyConflict) {
|
||||
t.Fatalf("conflicting replay error = %v", err)
|
||||
}
|
||||
|
||||
if err := store.StartFreightSync(ctx, first.ID, now.Add(time.Second)); err != nil {
|
||||
t.Fatalf("StartFreightSync() error = %v", err)
|
||||
}
|
||||
batch := freightBatch(910, "a", "b")
|
||||
if err := store.CompleteFreightSync(
|
||||
ctx,
|
||||
first,
|
||||
batch,
|
||||
now.Add(2*time.Second),
|
||||
); err != nil {
|
||||
t.Fatalf("CompleteFreightSync() error = %v", err)
|
||||
}
|
||||
orders, err := store.ListFreightOrders(ctx, "local-admin", 10)
|
||||
if err != nil || len(orders) != 1 || orders[0].ItemCount != 2 ||
|
||||
orders[0].Revision != 1 {
|
||||
t.Fatalf("orders = %+v, error = %v", orders, err)
|
||||
}
|
||||
detail, err := store.GetFreightOrder(ctx, "local-admin", orders[0].ID)
|
||||
if err != nil || len(detail.Items) != 2 {
|
||||
t.Fatalf("detail = %+v, error = %v", detail, err)
|
||||
}
|
||||
|
||||
second := freightRun(904, userID, now.Add(time.Minute))
|
||||
createAndStartFreightRun(t, store, second, "freight-key-2", "3")
|
||||
unchanged := freightBatch(920, "a", "b")
|
||||
if err := store.CompleteFreightSync(
|
||||
ctx,
|
||||
second,
|
||||
unchanged,
|
||||
now.Add(time.Minute+time.Second),
|
||||
); err != nil {
|
||||
t.Fatalf("unchanged import error = %v", err)
|
||||
}
|
||||
detail, _ = store.GetFreightOrder(ctx, "local-admin", orders[0].ID)
|
||||
if detail.Order.Revision != 1 ||
|
||||
detail.Items[0].Revision != 1 ||
|
||||
detail.Items[1].Revision != 1 {
|
||||
t.Fatalf("unchanged revisions = %+v", detail)
|
||||
}
|
||||
|
||||
third := freightRun(905, userID, now.Add(2*time.Minute))
|
||||
createAndStartFreightRun(t, store, third, "freight-key-3", "4")
|
||||
changed := freightBatch(930, "a", "c")
|
||||
changed.Orders[0].CanonicalSHA256 = repeatHex("d")
|
||||
changed.Orders[0].Items[1].Title = "变化后的商品"
|
||||
changed.Orders[0].Items[1].CanonicalSHA256 = repeatHex("e")
|
||||
if err := store.CompleteFreightSync(
|
||||
ctx,
|
||||
third,
|
||||
changed,
|
||||
now.Add(2*time.Minute+time.Second),
|
||||
); err != nil {
|
||||
t.Fatalf("changed import error = %v", err)
|
||||
}
|
||||
detail, _ = store.GetFreightOrder(ctx, "local-admin", orders[0].ID)
|
||||
if detail.Order.Revision != 2 ||
|
||||
detail.Items[0].Revision != 1 ||
|
||||
detail.Items[1].Revision != 2 {
|
||||
t.Fatalf("changed revisions = %+v", detail)
|
||||
}
|
||||
runner, err := migration.New(db)
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err == nil {
|
||||
t.Fatal("freight migration down succeeded with retained source data")
|
||||
}
|
||||
var retained int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM freight_orders`).Scan(
|
||||
&retained,
|
||||
); err != nil || retained != 1 {
|
||||
t.Fatalf("retained freight orders = %d, error = %v", retained, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightSyncRecoveryAndFailedBatchDoNotPersistOrders(t *testing.T) {
|
||||
db := openDatabase(t)
|
||||
store, _ := repository.New(db)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 7, 28, 2, 3, 4, 0, time.UTC)
|
||||
userID := uuid(940)
|
||||
seedFreightUser(t, db, userID, now)
|
||||
run := freightRun(941, userID, now)
|
||||
createAndStartFreightRun(t, store, run, "freight-recovery", "5")
|
||||
count, err := store.RecoverFreightSyncs(ctx, now.Add(time.Minute))
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("RecoverFreightSyncs() = %d, %v", count, err)
|
||||
}
|
||||
stored, err := store.GetFreightSync(ctx, "local-admin", run.ID)
|
||||
if err != nil || stored.Status != domain.FreightSyncFailed ||
|
||||
stored.ErrorCode == nil || *stored.ErrorCode != "PROCESS_INTERRUPTED" {
|
||||
t.Fatalf("recovered run = %+v, error = %v", stored, err)
|
||||
}
|
||||
if err := store.CompleteFreightSync(
|
||||
ctx,
|
||||
run,
|
||||
freightBatch(950, "a", "b"),
|
||||
now.Add(2*time.Minute),
|
||||
); !errors.Is(err, usecase.ErrTaskStateConflict) {
|
||||
t.Fatalf("late completion error = %v", err)
|
||||
}
|
||||
orders, err := store.ListFreightOrders(ctx, "local-admin", 10)
|
||||
if err != nil || len(orders) != 0 {
|
||||
t.Fatalf("orders after failed batch = %+v, error = %v", orders, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedFreightUser(
|
||||
t *testing.T,
|
||||
db *sql.DB,
|
||||
userID string,
|
||||
now time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO users (
|
||||
id, username, password_hash, role, is_active, created_at, updated_at
|
||||
) VALUES (?, 'freight-admin', 'hash', 'ADMIN', 1, ?, ?)`,
|
||||
userID,
|
||||
now.Format(time.RFC3339Nano),
|
||||
now.Format(time.RFC3339Nano),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed freight user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func freightRun(
|
||||
index int,
|
||||
userID string,
|
||||
now time.Time,
|
||||
) domain.FreightSyncRun {
|
||||
return domain.FreightSyncRun{
|
||||
ID: uuid(index),
|
||||
CreatorSubject: "local-admin",
|
||||
CreatedByUserID: userID,
|
||||
Mode: domain.FreightSyncOrderNumber,
|
||||
OrderNumber: "SOURCE-12",
|
||||
QuerySHA256: repeatHex("a"),
|
||||
Status: domain.FreightSyncPending,
|
||||
CreatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func freightBatch(index int, firstHash, secondHash string) domain.FreightImportBatch {
|
||||
quantityOne := 1
|
||||
quantityTwo := 2
|
||||
return domain.FreightImportBatch{Orders: []domain.FreightImportOrder{{
|
||||
ID: uuid(index),
|
||||
ExternalStockID: "12",
|
||||
SourceCode: "SOURCE-12",
|
||||
CanonicalSHA256: repeatHex("c"),
|
||||
Items: []domain.FreightImportItem{
|
||||
{
|
||||
ID: uuid(index + 1),
|
||||
ExternalItemID: "88",
|
||||
Title: "商品一",
|
||||
ProductSpec: "黑色,L",
|
||||
SKU: "BLACK-L",
|
||||
Quantity: &quantityOne,
|
||||
CanonicalSHA256: repeatHex(firstHash),
|
||||
},
|
||||
{
|
||||
ID: uuid(index + 2),
|
||||
ExternalItemID: "89",
|
||||
Title: "商品二",
|
||||
ProductSpec: "白色,M",
|
||||
SKU: "WHITE-M",
|
||||
Quantity: &quantityTwo,
|
||||
CanonicalSHA256: repeatHex(secondHash),
|
||||
},
|
||||
},
|
||||
}}}
|
||||
}
|
||||
|
||||
func createAndStartFreightRun(
|
||||
t *testing.T,
|
||||
store *repository.Store,
|
||||
run domain.FreightSyncRun,
|
||||
key, hash string,
|
||||
) {
|
||||
t.Helper()
|
||||
if _, _, err := store.CreateFreightSync(
|
||||
context.Background(),
|
||||
run,
|
||||
key,
|
||||
repeatHex(hash),
|
||||
); err != nil {
|
||||
t.Fatalf("CreateFreightSync() error = %v", err)
|
||||
}
|
||||
if err := store.StartFreightSync(
|
||||
context.Background(),
|
||||
run.ID,
|
||||
run.CreatedAt.Add(time.Second),
|
||||
); err != nil {
|
||||
t.Fatalf("StartFreightSync() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ type AdminServices struct {
|
||||
Tasks *usecase.TaskService
|
||||
Results *usecase.ExecutionResultService
|
||||
Authorizations *usecase.OrderAuthorizationService
|
||||
Freight *usecase.FreightService
|
||||
}
|
||||
|
||||
func (s AdminServices) validate() error {
|
||||
@@ -61,6 +62,12 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
|
||||
"/api/v1/tasks/:id/order-authorizations",
|
||||
handler.createOrderAuthorization,
|
||||
)
|
||||
if services.Freight != nil {
|
||||
routes.POST("/api/v1/freight-syncs", handler.createFreightSync)
|
||||
routes.GET("/api/v1/freight-syncs/:id", handler.freightSyncDetail)
|
||||
routes.GET("/api/v1/freight-orders", handler.listFreightOrders)
|
||||
routes.GET("/api/v1/freight-orders/:id", handler.freightOrderDetail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -294,6 +294,107 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
|
||||
router := newAdminIntegrationRouter(t)
|
||||
create := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodPost,
|
||||
"/api/v1/freight-syncs",
|
||||
"application/json",
|
||||
strings.NewReader(
|
||||
`{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`,
|
||||
),
|
||||
"freight-sync-1",
|
||||
)
|
||||
if create.Code != http.StatusAccepted {
|
||||
t.Fatalf("create status/body = %d / %s", create.Code, create.Body)
|
||||
}
|
||||
var createBody struct {
|
||||
Sync struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"sync"`
|
||||
}
|
||||
decodeResponse(t, create, &createBody)
|
||||
if createBody.Sync.ID == "" ||
|
||||
strings.Contains(create.Body.String(), "SOURCE-12") {
|
||||
t.Fatalf("create response exposes query or lacks ID: %s", create.Body)
|
||||
}
|
||||
var sync *httptest.ResponseRecorder
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
sync = performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/api/v1/freight-syncs/"+createBody.Sync.ID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if strings.Contains(sync.Body.String(), `"status":"SUCCEEDED"`) {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if sync == nil || sync.Code != http.StatusOK ||
|
||||
!strings.Contains(sync.Body.String(), `"item_count":2`) {
|
||||
t.Fatalf("sync status/body = %d / %s", sync.Code, sync.Body)
|
||||
}
|
||||
list := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/api/v1/freight-orders",
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if list.Code != http.StatusOK ||
|
||||
!strings.Contains(list.Body.String(), `"item_count":2`) ||
|
||||
responseContainsKey(mustDecodeAny(t, list), "receiver") ||
|
||||
responseContainsKey(mustDecodeAny(t, list), "receiverTel") ||
|
||||
responseContainsKey(mustDecodeAny(t, list), "receiverAddr") {
|
||||
t.Fatalf("freight list status/body = %d / %s", list.Code, list.Body)
|
||||
}
|
||||
var listBody struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
decodeResponse(t, list, &listBody)
|
||||
detail := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/api/v1/freight-orders/"+listBody.Items[0].ID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if detail.Code != http.StatusOK ||
|
||||
!strings.Contains(detail.Body.String(), `"sku":"BLACK-L"`) ||
|
||||
!strings.Contains(detail.Body.String(), `"sku":"WHITE-M"`) ||
|
||||
detail.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("freight detail status/body = %d / %s", detail.Code, detail.Body)
|
||||
}
|
||||
replay := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodPost,
|
||||
"/api/v1/freight-syncs",
|
||||
"application/json",
|
||||
strings.NewReader(
|
||||
`{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`,
|
||||
),
|
||||
"freight-sync-1",
|
||||
)
|
||||
if replay.Code != http.StatusAccepted ||
|
||||
!strings.Contains(replay.Body.String(), `"replayed":true`) ||
|
||||
!strings.Contains(replay.Body.String(), createBody.Sync.ID) {
|
||||
t.Fatalf("replay status/body = %d / %s", replay.Code, replay.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
||||
fixture := newAdminIntegrationFixture(t)
|
||||
taskID, executionID, taskHash, firstKey, secondKey :=
|
||||
@@ -414,6 +515,9 @@ func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("freight migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("order submission migration down: %v", err)
|
||||
}
|
||||
@@ -738,12 +842,23 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewOrderAuthorizationService() error = %v", err)
|
||||
}
|
||||
freight, err := usecase.NewFreightService(
|
||||
repositories,
|
||||
staticFreightSource{},
|
||||
clock,
|
||||
ids,
|
||||
time.Second,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewFreightService() error = %v", err)
|
||||
}
|
||||
registrar, err := NewAdminRouteRegistrar(
|
||||
AdminServices{
|
||||
Assets: assets,
|
||||
Tasks: tasks,
|
||||
Results: results,
|
||||
Authorizations: authorizations,
|
||||
Freight: freight,
|
||||
},
|
||||
emptyAdminWeb{},
|
||||
)
|
||||
@@ -765,6 +880,56 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
|
||||
return &adminIntegrationFixture{router: router, db: db}
|
||||
}
|
||||
|
||||
type staticFreightSource struct{}
|
||||
|
||||
func (staticFreightSource) QueryOrder(
|
||||
context.Context,
|
||||
string,
|
||||
) (domain.FreightSourceBatch, error) {
|
||||
shop := "测试店铺"
|
||||
created := "2026-07-28 08:00:00"
|
||||
quantityOne := 1
|
||||
quantityTwo := 2
|
||||
return domain.FreightSourceBatch{
|
||||
SchemaVersion: 1,
|
||||
Query: domain.FreightSourceQuery{
|
||||
Mode: domain.FreightSyncOrderNumber,
|
||||
},
|
||||
Orders: []domain.FreightSourceOrder{{
|
||||
ExternalStockID: "12",
|
||||
SourceCode: "SOURCE-12",
|
||||
ShopName: &shop,
|
||||
SourceCreatedAt: &created,
|
||||
Items: []domain.FreightSourceItem{
|
||||
{
|
||||
ExternalItemID: "88",
|
||||
Title: "商品一",
|
||||
ProductSpec: "黑色,L",
|
||||
SKU: "BLACK-L",
|
||||
Quantity: &quantityOne,
|
||||
},
|
||||
{
|
||||
ExternalItemID: "89",
|
||||
Title: "商品二",
|
||||
ProductSpec: "白色,M",
|
||||
SKU: "WHITE-M",
|
||||
Quantity: &quantityTwo,
|
||||
},
|
||||
},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mustDecodeAny(
|
||||
t *testing.T,
|
||||
response *httptest.ResponseRecorder,
|
||||
) any {
|
||||
t.Helper()
|
||||
var decoded any
|
||||
decodeResponse(t, response, &decoded)
|
||||
return decoded
|
||||
}
|
||||
|
||||
func referenceUpload(t *testing.T, key string) (io.Reader, string) {
|
||||
t.Helper()
|
||||
var imageBytes bytes.Buffer
|
||||
|
||||
@@ -231,7 +231,9 @@ func denyAdminSession(ctx *gin.Context) {
|
||||
next := ctx.Request.URL.RequestURI()
|
||||
if next == "" ||
|
||||
(next != "/tasks" && !strings.HasPrefix(next, "/tasks?") &&
|
||||
!strings.HasPrefix(next, "/tasks/")) {
|
||||
!strings.HasPrefix(next, "/tasks/") &&
|
||||
next != "/freight" && !strings.HasPrefix(next, "/freight?") &&
|
||||
!strings.HasPrefix(next, "/freight/")) {
|
||||
next = "/tasks"
|
||||
}
|
||||
ctx.Abort()
|
||||
|
||||
@@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() after review error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("freight migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("order submission migration down: %v", err)
|
||||
}
|
||||
@@ -727,8 +730,8 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
}
|
||||
if applied, err := runner.Up(context.Background()); err != nil {
|
||||
t.Fatalf("restore device command migration: %v", err)
|
||||
} else if applied != 3 {
|
||||
t.Fatalf("restored migrations = %d, want 3", applied)
|
||||
} else if applied != 4 {
|
||||
t.Fatalf("restored migrations = %d, want 4", applied)
|
||||
}
|
||||
|
||||
completePayload := fmt.Sprintf(
|
||||
@@ -1417,6 +1420,9 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("freight migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err == nil {
|
||||
t.Fatal("order submission migration down succeeded with retained data")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *adminHandlers) createFreightSync(ctx *gin.Context) {
|
||||
if !hasMediaType(ctx, "application/json") {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusUnsupportedMediaType,
|
||||
"UNSUPPORTED_MEDIA_TYPE",
|
||||
"application/json is required",
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Mode string `json:"mode"`
|
||||
OrderNumber string `json:"order_number"`
|
||||
}
|
||||
if err := decodeJSON(ctx, &request); err != nil {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusBadRequest,
|
||||
"INVALID_JSON",
|
||||
"request body must be valid JSON",
|
||||
false,
|
||||
gin.H{},
|
||||
)
|
||||
return
|
||||
}
|
||||
if request.Mode != domain.FreightSyncOrderNumber {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusUnprocessableEntity,
|
||||
"FREIGHT_SYNC_MODE_INVALID",
|
||||
"freight sync mode is not supported",
|
||||
false,
|
||||
fieldDetails("mode", "must be ORDER_NUMBER"),
|
||||
)
|
||||
return
|
||||
}
|
||||
result, err := h.services.Freight.CreateOrderSync(
|
||||
ctx.Request.Context(),
|
||||
usecase.CreateFreightSyncCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: adminActorUserID(ctx),
|
||||
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||
OrderNumber: request.OrderNumber,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusAccepted, gin.H{
|
||||
"sync": freightSyncResponse(result.Run),
|
||||
"replayed": result.Replayed,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *adminHandlers) freightSyncDetail(ctx *gin.Context) {
|
||||
run, err := h.services.Freight.GetSync(
|
||||
ctx.Request.Context(),
|
||||
localAdminSubject,
|
||||
ctx.Param("id"),
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{"sync": freightSyncResponse(run)})
|
||||
}
|
||||
|
||||
func (h *adminHandlers) listFreightOrders(ctx *gin.Context) {
|
||||
limit := 0
|
||||
if value := strings.TrimSpace(ctx.Query("limit")); value != "" {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
writePublicError(
|
||||
ctx,
|
||||
http.StatusBadRequest,
|
||||
"FREIGHT_LIST_INVALID",
|
||||
"freight list filter is invalid",
|
||||
false,
|
||||
fieldDetails("limit", "must be an integer"),
|
||||
)
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
orders, err := h.services.Freight.ListOrders(
|
||||
ctx.Request.Context(),
|
||||
localAdminSubject,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
items = append(items, freightOrderResponse(order))
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"next_cursor": nil,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
|
||||
detail, err := h.services.Freight.GetOrder(
|
||||
ctx.Request.Context(),
|
||||
localAdminSubject,
|
||||
ctx.Param("id"),
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(detail.Items))
|
||||
for _, item := range detail.Items {
|
||||
items = append(items, gin.H{
|
||||
"id": item.ID,
|
||||
"external_item_id": item.ExternalItemID,
|
||||
"title": item.Title,
|
||||
"product_spec": item.ProductSpec,
|
||||
"sku": item.SKU,
|
||||
"quantity": item.Quantity,
|
||||
"product_thumb_ref": item.ProductThumbRef,
|
||||
"purchase_status": item.PurchaseStatus,
|
||||
"revision": item.Revision,
|
||||
"canonical_sha256": item.CanonicalSHA256,
|
||||
"updated_at": formatTime(item.UpdatedAt),
|
||||
})
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"order": freightOrderResponse(detail.Order),
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
func freightSyncResponse(run domain.FreightSyncRun) gin.H {
|
||||
return gin.H{
|
||||
"id": run.ID,
|
||||
"mode": run.Mode,
|
||||
"query_sha256": run.QuerySHA256,
|
||||
"status": run.Status,
|
||||
"error_code": run.ErrorCode,
|
||||
"order_count": run.OrderCount,
|
||||
"item_count": run.ItemCount,
|
||||
"created_at": formatTime(run.CreatedAt),
|
||||
"started_at": formatOptionalTime(run.StartedAt),
|
||||
"finished_at": formatOptionalTime(run.FinishedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func freightOrderResponse(order domain.FreightOrder) gin.H {
|
||||
return gin.H{
|
||||
"id": order.ID,
|
||||
"source_system": order.SourceSystem,
|
||||
"external_stock_id": order.ExternalStockID,
|
||||
"source_code": order.SourceCode,
|
||||
"platform_order_no": order.PlatformOrderNo,
|
||||
"shop_name": order.ShopName,
|
||||
"source_created_at": formatOptionalTime(order.SourceCreatedAt),
|
||||
"order_status": order.OrderStatus,
|
||||
"purchase_status": order.PurchaseStatus,
|
||||
"is_canceled": order.IsCanceled,
|
||||
"revision": order.Revision,
|
||||
"canonical_sha256": order.CanonicalSHA256,
|
||||
"item_count": order.ItemCount,
|
||||
"updated_at": formatTime(order.UpdatedAt),
|
||||
}
|
||||
}
|
||||
@@ -267,7 +267,9 @@ func safeNext(value string) string {
|
||||
return "/tasks"
|
||||
}
|
||||
if parsed.Path != "/tasks" &&
|
||||
!strings.HasPrefix(parsed.Path, "/tasks/") {
|
||||
!strings.HasPrefix(parsed.Path, "/tasks/") &&
|
||||
parsed.Path != "/freight" &&
|
||||
!strings.HasPrefix(parsed.Path, "/freight/") {
|
||||
return "/tasks"
|
||||
}
|
||||
return parsed.String()
|
||||
|
||||
@@ -294,6 +294,9 @@ func TestSafeNextRejectsExternalAndAmbiguousPaths(t *testing.T) {
|
||||
if actual := safeNext("/tasks/item?id=1"); actual != "/tasks/item?id=1" {
|
||||
t.Fatalf("safeNext(valid) = %q", actual)
|
||||
}
|
||||
if actual := safeNext("/freight/import"); actual != "/freight/import" {
|
||||
t.Fatalf("safeNext(freight) = %q", actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
|
||||
|
||||
@@ -71,6 +71,136 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
|
||||
SecurityHeaders(),
|
||||
h.AuthorizeOrder,
|
||||
)
|
||||
if _, ok := h.service.(FreightService); ok {
|
||||
routes.GET("/freight", SecurityHeaders(), h.ListFreight)
|
||||
routes.GET("/freight/import", SecurityHeaders(), h.ImportFreight)
|
||||
routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
|
||||
routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ListFreight(ctx *gin.Context) {
|
||||
service := h.service.(FreightService)
|
||||
orders, err := service.ListFreightOrders(
|
||||
ctx.Request.Context(),
|
||||
defaultListLimit,
|
||||
)
|
||||
if err != nil {
|
||||
h.renderServiceError(ctx, err, "无法加载货运列表,请稍后重试。")
|
||||
return
|
||||
}
|
||||
token, err := csrfToken(ctx)
|
||||
if err != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
h.render(ctx, http.StatusOK, "freight", freightPage{
|
||||
Page: pageView{
|
||||
Title: "ERP 货运",
|
||||
FreightCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
Orders: orders,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ImportFreight(ctx *gin.Context) {
|
||||
service := h.service.(FreightService)
|
||||
token, err := csrfToken(ctx)
|
||||
if err != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
key, err := newToken()
|
||||
if err != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
page := freightImportPage{
|
||||
Page: pageView{
|
||||
Title: "导入 ERP 货运",
|
||||
FreightCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
if syncID := strings.TrimSpace(ctx.Query("sync")); syncID != "" {
|
||||
run, getErr := service.GetFreightSync(ctx.Request.Context(), syncID)
|
||||
if getErr == nil {
|
||||
page.Sync = &run
|
||||
}
|
||||
}
|
||||
h.render(ctx, http.StatusOK, "freight-import", page)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateFreightImport(ctx *gin.Context) {
|
||||
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16<<10)
|
||||
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
|
||||
h.renderError(ctx, http.StatusForbidden, "请求已失效", "请返回导入页面后重新提交。")
|
||||
return
|
||||
}
|
||||
orderNumber := strings.TrimSpace(ctx.PostForm("order_number"))
|
||||
key := strings.TrimSpace(ctx.PostForm("idempotency_key"))
|
||||
if orderNumber == "" || len([]byte(orderNumber)) > 128 ||
|
||||
!validToken(key) {
|
||||
token, _ := csrfToken(ctx)
|
||||
h.render(ctx, http.StatusUnprocessableEntity, "freight-import", freightImportPage{
|
||||
Page: pageView{
|
||||
Title: "导入 ERP 货运",
|
||||
FreightCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
OrderNumber: orderNumber,
|
||||
IdempotencyKey: key,
|
||||
Error: "请输入完整单号后重试。",
|
||||
})
|
||||
return
|
||||
}
|
||||
service := h.service.(FreightService)
|
||||
run, err := service.CreateFreightSync(
|
||||
ctx.Request.Context(),
|
||||
CreateFreightSyncInput{
|
||||
ActorUserID: actorUserID(ctx.Request.Context()),
|
||||
IdempotencyKey: key,
|
||||
OrderNumber: orderNumber,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
token, _ := csrfToken(ctx)
|
||||
h.render(ctx, serviceErrorStatus(err), "freight-import", freightImportPage{
|
||||
Page: pageView{
|
||||
Title: "导入 ERP 货运",
|
||||
FreightCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
OrderNumber: orderNumber,
|
||||
IdempotencyKey: key,
|
||||
Error: "同步任务创建失败,请稍后使用相同提交标识重试。",
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/freight/import?sync="+pathEscape(run.ID))
|
||||
}
|
||||
|
||||
func (h *Handler) FreightDetail(ctx *gin.Context) {
|
||||
service := h.service.(FreightService)
|
||||
detail, err := service.GetFreightOrder(
|
||||
ctx.Request.Context(),
|
||||
strings.TrimSpace(ctx.Param("id")),
|
||||
)
|
||||
if err != nil {
|
||||
h.renderServiceError(ctx, err, "无法加载货运详情,请稍后重试。")
|
||||
return
|
||||
}
|
||||
token, _ := csrfToken(ctx)
|
||||
h.render(ctx, http.StatusOK, "freight-detail", freightDetailPage{
|
||||
Page: pageView{
|
||||
Title: "货运详情",
|
||||
FreightCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
@@ -742,9 +872,28 @@ type pageView struct {
|
||||
Title string
|
||||
TasksCurrent bool
|
||||
NewCurrent bool
|
||||
FreightCurrent bool
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
type freightPage struct {
|
||||
Page pageView
|
||||
Orders []FreightOrder
|
||||
}
|
||||
|
||||
type freightImportPage struct {
|
||||
Page pageView
|
||||
OrderNumber string
|
||||
IdempotencyKey string
|
||||
Error string
|
||||
Sync *FreightSync
|
||||
}
|
||||
|
||||
type freightDetailPage struct {
|
||||
Page pageView
|
||||
Detail FreightOrderDetail
|
||||
}
|
||||
|
||||
type statusOption struct {
|
||||
Value string
|
||||
Label string
|
||||
|
||||
@@ -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(), "<script>private") ||
|
||||
!strings.Contains(list.Body.String(), "2 项") {
|
||||
t.Fatalf("freight list status/body = %d / %s", list.Code, list.Body)
|
||||
}
|
||||
assertSecurityHeaders(t, list)
|
||||
|
||||
form := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/freight/import",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
cookie := csrfCookie(t, form)
|
||||
idempotencyKey := hiddenValue(
|
||||
t,
|
||||
form.Body.String(),
|
||||
"idempotency_key",
|
||||
)
|
||||
values := url.Values{
|
||||
"csrf_token": {cookie.Value},
|
||||
"idempotency_key": {idempotencyKey},
|
||||
"order_number": {"SOURCE-12"},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/freight/import",
|
||||
strings.NewReader(values.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") !=
|
||||
"/freight/import?sync="+testTaskID {
|
||||
t.Fatalf(
|
||||
"create sync status/location = %d / %q",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
if service.createInput.OrderNumber != "SOURCE-12" ||
|
||||
service.createInput.IdempotencyKey != idempotencyKey {
|
||||
t.Fatalf("create input = %+v", service.createInput)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeService struct {
|
||||
listInput ListTasksInput
|
||||
listResult TaskList
|
||||
@@ -945,6 +1017,45 @@ type fakeService struct {
|
||||
authorizeInput AuthorizeOrderInput
|
||||
}
|
||||
|
||||
type fakeFreightService struct {
|
||||
*fakeService
|
||||
orders []FreightOrder
|
||||
orderDetail FreightOrderDetail
|
||||
sync FreightSync
|
||||
createInput CreateFreightSyncInput
|
||||
createResult FreightSync
|
||||
err error
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) ListFreightOrders(
|
||||
context.Context,
|
||||
int,
|
||||
) ([]FreightOrder, error) {
|
||||
return service.orders, service.err
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) GetFreightOrder(
|
||||
context.Context,
|
||||
string,
|
||||
) (FreightOrderDetail, error) {
|
||||
return service.orderDetail, service.err
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) GetFreightSync(
|
||||
context.Context,
|
||||
string,
|
||||
) (FreightSync, error) {
|
||||
return service.sync, service.err
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) CreateFreightSync(
|
||||
_ context.Context,
|
||||
input CreateFreightSyncInput,
|
||||
) (FreightSync, error) {
|
||||
service.createInput = input
|
||||
return service.createResult, service.err
|
||||
}
|
||||
|
||||
func (service *fakeService) ListTasks(
|
||||
_ context.Context,
|
||||
input ListTasksInput,
|
||||
|
||||
@@ -673,6 +673,54 @@ tbody tr:last-child td {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.notice.danger {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger-dark);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.detail-grid > div {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.detail-grid dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-grid dd {
|
||||
margin: 4px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@@ -1139,6 +1187,10 @@ tbody tr:last-child td {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.authorization-note {
|
||||
grid-column: auto;
|
||||
}
|
||||
@@ -1154,11 +1206,7 @@ tbody tr:last-child td {
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
.brand span:not(.brand-mark) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1167,13 +1215,13 @@ tbody tr:last-child td {
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
padding-inline: 8px;
|
||||
font-size: 13px;
|
||||
padding-inline: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
padding-inline: 8px;
|
||||
font-size: 13px;
|
||||
padding-inline: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.title-row,
|
||||
@@ -1186,6 +1234,10 @@ tbody tr:last-child td {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.title-actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{{define "freight-detail"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<h1>{{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}货运详情{{end}}</h1>
|
||||
<p class="subtitle">ERP ID:{{.Detail.Order.ExternalStockID}} · 来源版本 {{.Detail.Order.Revision}}</p>
|
||||
</div>
|
||||
<a class="button" href="/freight">返回列表</a>
|
||||
</div>
|
||||
<section class="detail-section" aria-labelledby="freight-source-title">
|
||||
<h2 id="freight-source-title">来源信息</h2>
|
||||
<dl class="detail-grid">
|
||||
<div><dt>店铺</dt><dd>{{if .Detail.Order.ShopName}}{{.Detail.Order.ShopName}}{{else}}未提供{{end}}</dd></div>
|
||||
<div><dt>ERP 创建时间</dt><dd>{{displayTime .Detail.Order.SourceCreatedAt}}</dd></div>
|
||||
<div><dt>订单状态</dt><dd>{{if .Detail.Order.OrderStatus}}{{.Detail.Order.OrderStatus}}{{else}}未提供{{end}}</dd></div>
|
||||
<div><dt>采购状态</dt><dd>{{if .Detail.Order.PurchaseStatus}}{{.Detail.Order.PurchaseStatus}}{{else}}未提供{{end}}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="table-region" aria-labelledby="freight-items-title">
|
||||
<h2 id="freight-items-title">商品明细</h2>
|
||||
{{if .Detail.Items}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">商品</th>
|
||||
<th scope="col">规格 / SKU</th>
|
||||
<th scope="col">数量</th>
|
||||
<th scope="col">采购状态</th>
|
||||
<th scope="col">来源版本</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Detail.Items}}
|
||||
<tr>
|
||||
<td data-label="商品">
|
||||
<strong>{{if .Title}}{{.Title}}{{else}}缺少标题{{end}}</strong>
|
||||
<span class="secondary">明细 ID:{{.ExternalItemID}}</span>
|
||||
{{if .ProductThumbRef}}<span class="secondary">图片引用:{{.ProductThumbRef}}</span>{{end}}
|
||||
</td>
|
||||
<td data-label="规格 / SKU">
|
||||
<span>{{if .ProductSpec}}{{.ProductSpec}}{{else}}未提供规格{{end}}</span>
|
||||
<span class="secondary">SKU:{{if .SKU}}{{.SKU}}{{else}}未提供{{end}}</span>
|
||||
</td>
|
||||
<td data-label="数量">{{if .Quantity}}{{.Quantity}}{{else}}未提供{{end}}</td>
|
||||
<td data-label="采购状态">{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}</td>
|
||||
<td data-label="来源版本">{{.Revision}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state"><h2>该货运单没有商品明细</h2></div>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "freight-import"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page narrow-page">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<h1>导入 ERP 货运</h1>
|
||||
<p class="subtitle">使用 ERP 页面“全部单号”中的完整单号</p>
|
||||
</div>
|
||||
<a class="button" href="/freight">返回列表</a>
|
||||
</div>
|
||||
{{if .Error}}<div class="notice danger" role="alert">{{.Error}}</div>{{end}}
|
||||
{{if .Sync}}
|
||||
<section class="detail-section" aria-labelledby="sync-result-title">
|
||||
<h2 id="sync-result-title">同步状态</h2>
|
||||
<dl class="detail-grid">
|
||||
<div><dt>状态</dt><dd>{{.Sync.Status}}</dd></div>
|
||||
<div><dt>货运单</dt><dd>{{.Sync.OrderCount}}</dd></div>
|
||||
<div><dt>商品明细</dt><dd>{{.Sync.ItemCount}}</dd></div>
|
||||
<div><dt>错误码</dt><dd>{{if .Sync.ErrorCode}}{{.Sync.ErrorCode}}{{else}}无{{end}}</dd></div>
|
||||
</dl>
|
||||
{{if or (eq .Sync.Status "PENDING") (eq .Sync.Status "RUNNING")}}
|
||||
<p class="secondary">同步仍在后台执行,刷新本页查看结果。</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
<form class="form-panel" method="post" action="/freight/import" data-loading-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
|
||||
<input type="hidden" name="idempotency_key" value="{{.IdempotencyKey}}">
|
||||
<div class="field">
|
||||
<label for="order-number">完整单号</label>
|
||||
<input id="order-number" name="order_number" value="{{.OrderNumber}}"
|
||||
maxlength="128" autocomplete="off" required>
|
||||
</div>
|
||||
<button class="button primary" type="submit" data-loading-label="正在创建…">开始同步</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,62 @@
|
||||
{{define "freight"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<h1>ERP 货运</h1>
|
||||
<p class="subtitle">核对已同步的货运单和全部商品明细</p>
|
||||
</div>
|
||||
<a class="button primary" href="/freight/import">导入货运单</a>
|
||||
</div>
|
||||
<section class="table-region" aria-labelledby="freight-table-title">
|
||||
<h2 id="freight-table-title" class="visually-hidden">货运单列表</h2>
|
||||
{{if .Orders}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">来源单号</th>
|
||||
<th scope="col">店铺</th>
|
||||
<th scope="col">状态</th>
|
||||
<th scope="col">商品</th>
|
||||
<th scope="col">更新时间</th>
|
||||
<th scope="col"><span class="visually-hidden">操作</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Orders}}
|
||||
<tr>
|
||||
<td data-label="来源单号">
|
||||
<strong>{{if .SourceCode}}{{.SourceCode}}{{else}}{{.ExternalStockID}}{{end}}</strong>
|
||||
<span class="secondary">ERP ID:{{.ExternalStockID}}</span>
|
||||
</td>
|
||||
<td data-label="店铺">{{if .ShopName}}{{.ShopName}}{{else}}未提供{{end}}</td>
|
||||
<td data-label="状态">
|
||||
<span class="secondary">订单:{{if .OrderStatus}}{{.OrderStatus}}{{else}}未提供{{end}}</span>
|
||||
<span class="secondary">采购:{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}</span>
|
||||
</td>
|
||||
<td data-label="商品">{{.ItemCount}} 项</td>
|
||||
<td data-label="更新时间"><time datetime="{{machineTime .UpdatedAt}}">{{displayTime .UpdatedAt}}</time></td>
|
||||
<td data-label="操作"><a class="detail-link" href="/freight/{{pathPart .ID}}">查看详情</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<h2>尚未导入货运单</h2>
|
||||
<p>按完整单号创建第一条 ERP 同步任务。</p>
|
||||
<a class="button primary" href="/freight/import">导入货运单</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -17,6 +17,7 @@
|
||||
<nav class="main-nav" aria-label="主导航">
|
||||
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
|
||||
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
|
||||
<a href="/freight" {{if .Page.FreightCurrent}}aria-current="page"{{end}}>ERP 货运</a>
|
||||
</nav>
|
||||
{{if .Page.CSRFToken}}
|
||||
<form class="logout-form" method="post" action="/logout">
|
||||
|
||||
@@ -27,6 +27,63 @@ type Service interface {
|
||||
AuthorizeOrder(context.Context, AuthorizeOrderInput) (OrderAuthorization, error)
|
||||
}
|
||||
|
||||
type FreightService interface {
|
||||
ListFreightOrders(context.Context, int) ([]FreightOrder, error)
|
||||
GetFreightOrder(context.Context, string) (FreightOrderDetail, error)
|
||||
GetFreightSync(context.Context, string) (FreightSync, error)
|
||||
CreateFreightSync(
|
||||
context.Context,
|
||||
CreateFreightSyncInput,
|
||||
) (FreightSync, error)
|
||||
}
|
||||
|
||||
type FreightSync struct {
|
||||
ID string
|
||||
Status string
|
||||
ErrorCode string
|
||||
OrderCount int
|
||||
ItemCount int
|
||||
CreatedAt time.Time
|
||||
FinishedAt time.Time
|
||||
}
|
||||
|
||||
type CreateFreightSyncInput struct {
|
||||
ActorUserID string
|
||||
IdempotencyKey string
|
||||
OrderNumber string
|
||||
}
|
||||
|
||||
type FreightOrder struct {
|
||||
ID string
|
||||
ExternalStockID string
|
||||
SourceCode string
|
||||
ShopName string
|
||||
SourceCreatedAt time.Time
|
||||
OrderStatus string
|
||||
PurchaseStatus string
|
||||
IsCanceled bool
|
||||
ItemCount int
|
||||
Revision int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type FreightOrderItem struct {
|
||||
ID string
|
||||
ExternalItemID string
|
||||
Title string
|
||||
ProductSpec string
|
||||
SKU string
|
||||
Quantity int
|
||||
ProductThumbRef string
|
||||
PurchaseStatus string
|
||||
Revision int
|
||||
}
|
||||
|
||||
type FreightOrderDetail struct {
|
||||
Order FreightOrder
|
||||
Items []FreightOrderItem
|
||||
}
|
||||
|
||||
type ListTasksInput struct {
|
||||
Query string
|
||||
Status string
|
||||
|
||||
@@ -17,23 +17,157 @@ type UsecaseAdapter struct {
|
||||
tasks *usecase.TaskService
|
||||
assets *usecase.AssetService
|
||||
authorizations *usecase.OrderAuthorizationService
|
||||
freight *usecase.FreightService
|
||||
}
|
||||
|
||||
func NewUsecaseAdapter(
|
||||
tasks *usecase.TaskService,
|
||||
assets *usecase.AssetService,
|
||||
authorizations *usecase.OrderAuthorizationService,
|
||||
freight ...*usecase.FreightService,
|
||||
) (*UsecaseAdapter, error) {
|
||||
if tasks == nil || assets == nil || authorizations == nil {
|
||||
return nil, errors.New("admin web use cases are required")
|
||||
}
|
||||
return &UsecaseAdapter{
|
||||
adapter := &UsecaseAdapter{
|
||||
tasks: tasks,
|
||||
assets: assets,
|
||||
authorizations: authorizations,
|
||||
}
|
||||
if len(freight) > 0 {
|
||||
adapter.freight = freight[0]
|
||||
}
|
||||
return adapter, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) ListFreightOrders(
|
||||
ctx context.Context,
|
||||
limit int,
|
||||
) ([]FreightOrder, error) {
|
||||
if adapter.freight == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
orders, err := adapter.freight.ListOrders(ctx, localAdminSubject, limit)
|
||||
if err != nil {
|
||||
return nil, mapUsecaseError(err)
|
||||
}
|
||||
result := make([]FreightOrder, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
result = append(result, freightOrderFrom(order))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) GetFreightOrder(
|
||||
ctx context.Context,
|
||||
orderID string,
|
||||
) (FreightOrderDetail, error) {
|
||||
if adapter.freight == nil {
|
||||
return FreightOrderDetail{}, ErrUnavailable
|
||||
}
|
||||
detail, err := adapter.freight.GetOrder(
|
||||
ctx,
|
||||
localAdminSubject,
|
||||
orderID,
|
||||
)
|
||||
if err != nil {
|
||||
return FreightOrderDetail{}, mapUsecaseError(err)
|
||||
}
|
||||
items := make([]FreightOrderItem, 0, len(detail.Items))
|
||||
for _, item := range detail.Items {
|
||||
view := FreightOrderItem{
|
||||
ID: item.ID,
|
||||
ExternalItemID: item.ExternalItemID,
|
||||
Title: item.Title,
|
||||
ProductSpec: item.ProductSpec,
|
||||
SKU: item.SKU,
|
||||
ProductThumbRef: stringValue(item.ProductThumbRef),
|
||||
PurchaseStatus: stringValue(item.PurchaseStatus),
|
||||
Revision: item.Revision,
|
||||
}
|
||||
if item.Quantity != nil {
|
||||
view.Quantity = *item.Quantity
|
||||
}
|
||||
items = append(items, view)
|
||||
}
|
||||
return FreightOrderDetail{
|
||||
Order: freightOrderFrom(detail.Order),
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) GetFreightSync(
|
||||
ctx context.Context,
|
||||
syncID string,
|
||||
) (FreightSync, error) {
|
||||
if adapter.freight == nil {
|
||||
return FreightSync{}, ErrUnavailable
|
||||
}
|
||||
run, err := adapter.freight.GetSync(ctx, localAdminSubject, syncID)
|
||||
if err != nil {
|
||||
return FreightSync{}, mapUsecaseError(err)
|
||||
}
|
||||
return freightSyncFrom(run), nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) CreateFreightSync(
|
||||
ctx context.Context,
|
||||
input CreateFreightSyncInput,
|
||||
) (FreightSync, error) {
|
||||
if adapter.freight == nil {
|
||||
return FreightSync{}, ErrUnavailable
|
||||
}
|
||||
result, err := adapter.freight.CreateOrderSync(
|
||||
ctx,
|
||||
usecase.CreateFreightSyncCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: input.ActorUserID,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
OrderNumber: input.OrderNumber,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return FreightSync{}, mapUsecaseError(err)
|
||||
}
|
||||
return freightSyncFrom(result.Run), nil
|
||||
}
|
||||
|
||||
func freightOrderFrom(order domain.FreightOrder) FreightOrder {
|
||||
result := FreightOrder{
|
||||
ID: order.ID,
|
||||
ExternalStockID: order.ExternalStockID,
|
||||
SourceCode: order.SourceCode,
|
||||
ShopName: stringValue(order.ShopName),
|
||||
OrderStatus: stringValue(order.OrderStatus),
|
||||
PurchaseStatus: stringValue(order.PurchaseStatus),
|
||||
ItemCount: order.ItemCount,
|
||||
Revision: order.Revision,
|
||||
UpdatedAt: order.UpdatedAt,
|
||||
}
|
||||
if order.SourceCreatedAt != nil {
|
||||
result.SourceCreatedAt = *order.SourceCreatedAt
|
||||
}
|
||||
if order.IsCanceled != nil {
|
||||
result.IsCanceled = *order.IsCanceled
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func freightSyncFrom(run domain.FreightSyncRun) FreightSync {
|
||||
result := FreightSync{
|
||||
ID: run.ID,
|
||||
Status: string(run.Status),
|
||||
ErrorCode: stringValue(run.ErrorCode),
|
||||
OrderCount: run.OrderCount,
|
||||
ItemCount: run.ItemCount,
|
||||
CreatedAt: run.CreatedAt,
|
||||
}
|
||||
if run.FinishedAt != nil {
|
||||
result.FinishedAt = *run.FinishedAt
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) ListTasks(
|
||||
ctx context.Context,
|
||||
input ListTasksInput,
|
||||
@@ -525,3 +659,4 @@ func (err *adapterError) Unwrap() []error {
|
||||
}
|
||||
|
||||
var _ Service = (*UsecaseAdapter)(nil)
|
||||
var _ FreightService = (*UsecaseAdapter)(nil)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
type FreightSource interface {
|
||||
QueryOrder(context.Context, string) (domain.FreightSourceBatch, error)
|
||||
}
|
||||
|
||||
type FreightRepository interface {
|
||||
CreateFreightSync(
|
||||
context.Context,
|
||||
domain.FreightSyncRun,
|
||||
string,
|
||||
string,
|
||||
) (domain.FreightSyncRun, bool, error)
|
||||
StartFreightSync(context.Context, string, time.Time) error
|
||||
CompleteFreightSync(
|
||||
context.Context,
|
||||
domain.FreightSyncRun,
|
||||
domain.FreightImportBatch,
|
||||
time.Time,
|
||||
) error
|
||||
FailFreightSync(context.Context, string, string, time.Time) error
|
||||
RecoverFreightSyncs(context.Context, time.Time) (int64, error)
|
||||
GetFreightSync(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.FreightSyncRun, error)
|
||||
ListFreightOrders(
|
||||
context.Context,
|
||||
string,
|
||||
int,
|
||||
) ([]domain.FreightOrder, error)
|
||||
GetFreightOrder(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.FreightOrderDetail, error)
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/platform/erpconnector"
|
||||
)
|
||||
|
||||
const (
|
||||
maxFreightOrdersPerSync = 100
|
||||
maxFreightItemsPerOrder = 1000
|
||||
)
|
||||
|
||||
type FreightService struct {
|
||||
repository FreightRepository
|
||||
source FreightSource
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
type CreateFreightSyncCommand struct {
|
||||
CreatorSubject string
|
||||
ActorUserID string
|
||||
IdempotencyKey string
|
||||
OrderNumber string
|
||||
}
|
||||
|
||||
type CreateFreightSyncResult struct {
|
||||
Run domain.FreightSyncRun
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
func NewFreightService(
|
||||
repository FreightRepository,
|
||||
source FreightSource,
|
||||
clock Clock,
|
||||
ids IDGenerator,
|
||||
timeout time.Duration,
|
||||
) (*FreightService, error) {
|
||||
if repository == nil || source == nil || clock == nil || ids == nil ||
|
||||
timeout <= 0 {
|
||||
return nil, errors.New("freight service dependencies are required")
|
||||
}
|
||||
return &FreightService{
|
||||
repository: repository,
|
||||
source: source,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
timeout: timeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) CreateOrderSync(
|
||||
ctx context.Context,
|
||||
command CreateFreightSyncCommand,
|
||||
) (CreateFreightSyncResult, error) {
|
||||
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
|
||||
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
|
||||
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
|
||||
command.OrderNumber = strings.TrimSpace(command.OrderNumber)
|
||||
fields := map[string]string{}
|
||||
if command.CreatorSubject == "" {
|
||||
fields["creator_subject"] = "is required"
|
||||
}
|
||||
if command.ActorUserID == "" {
|
||||
fields["actor_user_id"] = "is required"
|
||||
}
|
||||
if command.IdempotencyKey == "" || len([]byte(command.IdempotencyKey)) > 128 {
|
||||
fields["idempotency_key"] = "must contain 1 to 128 bytes"
|
||||
}
|
||||
if command.OrderNumber == "" ||
|
||||
len([]byte(command.OrderNumber)) > 128 ||
|
||||
hasControl(command.OrderNumber) {
|
||||
fields["order_number"] = "must contain 1 to 128 bytes without control characters"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return CreateFreightSyncResult{}, invalidError(
|
||||
"FREIGHT_SYNC_INVALID",
|
||||
"freight sync request is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
runID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return CreateFreightSyncResult{}, wrapRepositoryError(err)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
queryHash := hashJSON(struct {
|
||||
Mode string `json:"mode"`
|
||||
OrderNumber string `json:"order_number"`
|
||||
}{domain.FreightSyncOrderNumber, command.OrderNumber})
|
||||
requestHash := hashJSON(struct {
|
||||
OrderNumber string `json:"order_number"`
|
||||
}{command.OrderNumber})
|
||||
run, created, err := service.repository.CreateFreightSync(
|
||||
ctx,
|
||||
domain.FreightSyncRun{
|
||||
ID: runID,
|
||||
CreatorSubject: command.CreatorSubject,
|
||||
CreatedByUserID: command.ActorUserID,
|
||||
Mode: domain.FreightSyncOrderNumber,
|
||||
OrderNumber: command.OrderNumber,
|
||||
QuerySHA256: queryHash,
|
||||
Status: domain.FreightSyncPending,
|
||||
CreatedAt: now,
|
||||
},
|
||||
command.IdempotencyKey,
|
||||
requestHash,
|
||||
)
|
||||
if err != nil {
|
||||
return CreateFreightSyncResult{}, wrapRepositoryError(err)
|
||||
}
|
||||
if created {
|
||||
go service.execute(run)
|
||||
}
|
||||
return CreateFreightSyncResult{Run: run, Replayed: !created}, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) execute(run domain.FreightSyncRun) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), service.timeout)
|
||||
defer cancel()
|
||||
now := service.clock.Now().UTC()
|
||||
if err := service.repository.StartFreightSync(ctx, run.ID, now); err != nil {
|
||||
return
|
||||
}
|
||||
source, err := service.source.QueryOrder(ctx, run.OrderNumber)
|
||||
if err != nil {
|
||||
_ = service.repository.FailFreightSync(
|
||||
ctx,
|
||||
run.ID,
|
||||
freightSourceErrorCode(err),
|
||||
service.clock.Now().UTC(),
|
||||
)
|
||||
return
|
||||
}
|
||||
batch, err := service.normalize(source)
|
||||
if err != nil {
|
||||
_ = service.repository.FailFreightSync(
|
||||
ctx,
|
||||
run.ID,
|
||||
"ERP_RESPONSE_INVALID",
|
||||
service.clock.Now().UTC(),
|
||||
)
|
||||
return
|
||||
}
|
||||
if err := service.repository.CompleteFreightSync(
|
||||
ctx,
|
||||
run,
|
||||
batch,
|
||||
service.clock.Now().UTC(),
|
||||
); err != nil {
|
||||
_ = service.repository.FailFreightSync(
|
||||
ctx,
|
||||
run.ID,
|
||||
"STORAGE_UNAVAILABLE",
|
||||
service.clock.Now().UTC(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (service *FreightService) RecoverInterrupted(
|
||||
ctx context.Context,
|
||||
) (int64, error) {
|
||||
count, err := service.repository.RecoverFreightSyncs(
|
||||
ctx,
|
||||
service.clock.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, wrapRepositoryError(err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) GetSync(
|
||||
ctx context.Context,
|
||||
creatorSubject, syncID string,
|
||||
) (domain.FreightSyncRun, error) {
|
||||
run, err := service.repository.GetFreightSync(
|
||||
ctx,
|
||||
strings.TrimSpace(creatorSubject),
|
||||
strings.TrimSpace(syncID),
|
||||
)
|
||||
if err != nil {
|
||||
return domain.FreightSyncRun{}, wrapRepositoryError(err)
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) ListOrders(
|
||||
ctx context.Context,
|
||||
creatorSubject string,
|
||||
limit int,
|
||||
) ([]domain.FreightOrder, error) {
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, invalidError(
|
||||
"FREIGHT_LIST_INVALID",
|
||||
"freight list filter is invalid",
|
||||
map[string]string{"limit": "must be between 1 and 100"},
|
||||
)
|
||||
}
|
||||
orders, err := service.repository.ListFreightOrders(
|
||||
ctx,
|
||||
strings.TrimSpace(creatorSubject),
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, wrapRepositoryError(err)
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) GetOrder(
|
||||
ctx context.Context,
|
||||
creatorSubject, orderID string,
|
||||
) (domain.FreightOrderDetail, error) {
|
||||
detail, err := service.repository.GetFreightOrder(
|
||||
ctx,
|
||||
strings.TrimSpace(creatorSubject),
|
||||
strings.TrimSpace(orderID),
|
||||
)
|
||||
if err != nil {
|
||||
return domain.FreightOrderDetail{}, wrapRepositoryError(err)
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) normalize(
|
||||
source domain.FreightSourceBatch,
|
||||
) (domain.FreightImportBatch, error) {
|
||||
if source.SchemaVersion != 1 ||
|
||||
source.Query.Mode != domain.FreightSyncOrderNumber ||
|
||||
len(source.Orders) > maxFreightOrdersPerSync {
|
||||
return domain.FreightImportBatch{}, errors.New("invalid source envelope")
|
||||
}
|
||||
seenOrders := map[string]struct{}{}
|
||||
result := domain.FreightImportBatch{
|
||||
Orders: make([]domain.FreightImportOrder, 0, len(source.Orders)),
|
||||
}
|
||||
for _, sourceOrder := range source.Orders {
|
||||
externalID, ok := validExternalID(sourceOrder.ExternalStockID)
|
||||
if !ok || len(sourceOrder.Items) > maxFreightItemsPerOrder {
|
||||
return domain.FreightImportBatch{}, errors.New("invalid freight order")
|
||||
}
|
||||
if _, exists := seenOrders[externalID]; exists {
|
||||
return domain.FreightImportBatch{}, errors.New("duplicate freight order")
|
||||
}
|
||||
seenOrders[externalID] = struct{}{}
|
||||
if !validBytes(sourceOrder.SourceCode, 256) ||
|
||||
!validOptional(sourceOrder.PlatformOrderNo, 256) ||
|
||||
!validOptional(sourceOrder.ShopName, 512) ||
|
||||
!validOptional(sourceOrder.OrderStatus, 128) ||
|
||||
!validOptional(sourceOrder.PurchaseStatus, 128) {
|
||||
return domain.FreightImportBatch{}, errors.New("invalid freight fields")
|
||||
}
|
||||
sourceCreatedAt, err := parseERPTime(sourceOrder.SourceCreatedAt)
|
||||
if err != nil {
|
||||
return domain.FreightImportBatch{}, err
|
||||
}
|
||||
orderID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return domain.FreightImportBatch{}, err
|
||||
}
|
||||
order := domain.FreightImportOrder{
|
||||
ID: orderID,
|
||||
ExternalStockID: externalID,
|
||||
SourceCode: sourceOrder.SourceCode,
|
||||
PlatformOrderNo: cleanOptional(sourceOrder.PlatformOrderNo),
|
||||
ShopName: cleanOptional(sourceOrder.ShopName),
|
||||
SourceCreatedAt: sourceCreatedAt,
|
||||
OrderStatus: cleanOptional(sourceOrder.OrderStatus),
|
||||
PurchaseStatus: cleanOptional(sourceOrder.PurchaseStatus),
|
||||
IsCanceled: sourceOrder.IsCanceled,
|
||||
Items: make([]domain.FreightImportItem, 0, len(sourceOrder.Items)),
|
||||
}
|
||||
seenItems := map[string]struct{}{}
|
||||
for _, sourceItem := range sourceOrder.Items {
|
||||
itemExternalID, ok := validExternalID(sourceItem.ExternalItemID)
|
||||
if !ok {
|
||||
return domain.FreightImportBatch{}, errors.New("invalid freight item")
|
||||
}
|
||||
if _, exists := seenItems[itemExternalID]; exists {
|
||||
return domain.FreightImportBatch{}, errors.New("duplicate freight item")
|
||||
}
|
||||
seenItems[itemExternalID] = struct{}{}
|
||||
if !validBytes(sourceItem.Title, 2048) ||
|
||||
!validBytes(sourceItem.ProductSpec, 1024) ||
|
||||
!validBytes(sourceItem.SKU, 512) ||
|
||||
!validOptional(sourceItem.ProductThumbRef, 512) ||
|
||||
!validOptional(sourceItem.PurchaseStatus, 128) ||
|
||||
(sourceItem.Quantity != nil && *sourceItem.Quantity <= 0) {
|
||||
return domain.FreightImportBatch{}, errors.New("invalid freight item fields")
|
||||
}
|
||||
itemID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return domain.FreightImportBatch{}, err
|
||||
}
|
||||
item := domain.FreightImportItem{
|
||||
ID: itemID,
|
||||
ExternalItemID: itemExternalID,
|
||||
Title: sourceItem.Title,
|
||||
ProductSpec: sourceItem.ProductSpec,
|
||||
SKU: sourceItem.SKU,
|
||||
Quantity: sourceItem.Quantity,
|
||||
ProductThumbRef: cleanOptional(sourceItem.ProductThumbRef),
|
||||
PurchaseStatus: cleanOptional(sourceItem.PurchaseStatus),
|
||||
}
|
||||
item.CanonicalSHA256 = hashJSON(struct {
|
||||
ExternalItemID string `json:"external_item_id"`
|
||||
Title string `json:"title"`
|
||||
ProductSpec string `json:"product_spec"`
|
||||
SKU string `json:"sku"`
|
||||
Quantity *int `json:"quantity"`
|
||||
ProductThumbRef *string `json:"product_thumb_ref"`
|
||||
PurchaseStatus *string `json:"purchase_status"`
|
||||
}{
|
||||
item.ExternalItemID, item.Title, item.ProductSpec, item.SKU,
|
||||
item.Quantity, item.ProductThumbRef, item.PurchaseStatus,
|
||||
})
|
||||
order.Items = append(order.Items, item)
|
||||
}
|
||||
sort.Slice(order.Items, func(i, j int) bool {
|
||||
left, _ := strconv.ParseUint(order.Items[i].ExternalItemID, 10, 64)
|
||||
right, _ := strconv.ParseUint(order.Items[j].ExternalItemID, 10, 64)
|
||||
return left < right
|
||||
})
|
||||
type canonicalItem struct {
|
||||
ExternalItemID string `json:"external_item_id"`
|
||||
CanonicalSHA256 string `json:"canonical_sha256"`
|
||||
}
|
||||
canonicalItems := make([]canonicalItem, 0, len(order.Items))
|
||||
for _, item := range order.Items {
|
||||
canonicalItems = append(canonicalItems, canonicalItem{
|
||||
ExternalItemID: item.ExternalItemID,
|
||||
CanonicalSHA256: item.CanonicalSHA256,
|
||||
})
|
||||
}
|
||||
order.CanonicalSHA256 = hashJSON(struct {
|
||||
ExternalStockID string `json:"external_stock_id"`
|
||||
SourceCode string `json:"source_code"`
|
||||
PlatformOrderNo *string `json:"platform_order_no"`
|
||||
ShopName *string `json:"shop_name"`
|
||||
SourceCreatedAt *time.Time `json:"source_created_at"`
|
||||
OrderStatus *string `json:"order_status"`
|
||||
PurchaseStatus *string `json:"purchase_status"`
|
||||
IsCanceled *bool `json:"is_canceled"`
|
||||
Items []canonicalItem `json:"items"`
|
||||
}{
|
||||
order.ExternalStockID, order.SourceCode, order.PlatformOrderNo,
|
||||
order.ShopName, order.SourceCreatedAt, order.OrderStatus,
|
||||
order.PurchaseStatus, order.IsCanceled, canonicalItems,
|
||||
})
|
||||
result.Orders = append(result.Orders, order)
|
||||
}
|
||||
sort.Slice(result.Orders, func(i, j int) bool {
|
||||
left, _ := strconv.ParseUint(result.Orders[i].ExternalStockID, 10, 64)
|
||||
right, _ := strconv.ParseUint(result.Orders[j].ExternalStockID, 10, 64)
|
||||
return left < right
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func freightSourceErrorCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, erpconnector.ErrNotConfigured):
|
||||
return "ERP_CONNECTOR_NOT_CONFIGURED"
|
||||
case errors.Is(err, erpconnector.ErrSessionRequired):
|
||||
return "ERP_SESSION_REQUIRED"
|
||||
case errors.Is(err, erpconnector.ErrNotFound):
|
||||
return "ERP_FREIGHT_NOT_FOUND"
|
||||
case errors.Is(err, erpconnector.ErrProtocol):
|
||||
return "ERP_RESPONSE_INVALID"
|
||||
default:
|
||||
return "ERP_CONNECTOR_UNAVAILABLE"
|
||||
}
|
||||
}
|
||||
|
||||
func validExternalID(value string) (string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
number, err := strconv.ParseUint(value, 10, 64)
|
||||
return value, err == nil && number > 0 && strconv.FormatUint(number, 10) == value
|
||||
}
|
||||
|
||||
func validBytes(value string, maximum int) bool {
|
||||
return utf8.ValidString(value) && len([]byte(value)) <= maximum &&
|
||||
!hasControl(value)
|
||||
}
|
||||
|
||||
func validOptional(value *string, maximum int) bool {
|
||||
return value == nil || validBytes(strings.TrimSpace(*value), maximum)
|
||||
}
|
||||
|
||||
func cleanOptional(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func hasControl(value string) bool {
|
||||
for _, character := range value {
|
||||
if character < 0x20 || character == 0x7f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseERPTime(value *string) (*time.Time, error) {
|
||||
value = cleanOptional(value)
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
location, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
var parsed time.Time
|
||||
if layout == time.RFC3339Nano {
|
||||
parsed, err = time.Parse(layout, *value)
|
||||
} else {
|
||||
parsed, err = time.ParseInLocation(layout, *value, location)
|
||||
}
|
||||
if err == nil {
|
||||
result := parsed.UTC()
|
||||
return &result, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("invalid ERP source time")
|
||||
}
|
||||
|
||||
func hashJSON(value any) string {
|
||||
encoded, _ := json.Marshal(value)
|
||||
sum := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
func TestFreightNormalizationHashExcludesInternalIDs(t *testing.T) {
|
||||
source := validFreightSource()
|
||||
firstService := &FreightService{ids: &sequenceIDs{next: 10}}
|
||||
first, err := firstService.normalize(source)
|
||||
if err != nil {
|
||||
t.Fatalf("first normalize error = %v", err)
|
||||
}
|
||||
secondService := &FreightService{ids: &sequenceIDs{next: 100}}
|
||||
second, err := secondService.normalize(source)
|
||||
if err != nil {
|
||||
t.Fatalf("second normalize error = %v", err)
|
||||
}
|
||||
if first.Orders[0].ID == second.Orders[0].ID {
|
||||
t.Fatal("test IDs did not differ")
|
||||
}
|
||||
if first.Orders[0].CanonicalSHA256 !=
|
||||
second.Orders[0].CanonicalSHA256 {
|
||||
t.Fatalf(
|
||||
"canonical hash depends on internal ID: %s != %s",
|
||||
first.Orders[0].CanonicalSHA256,
|
||||
second.Orders[0].CanonicalSHA256,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightNormalizationRejectsConflictingIdentityAndInvalidTime(
|
||||
t *testing.T,
|
||||
) {
|
||||
duplicate := validFreightSource()
|
||||
duplicate.Orders[0].Items = append(
|
||||
duplicate.Orders[0].Items,
|
||||
duplicate.Orders[0].Items[0],
|
||||
)
|
||||
service := &FreightService{ids: &sequenceIDs{}}
|
||||
if _, err := service.normalize(duplicate); err == nil {
|
||||
t.Fatal("duplicate item normalize error = nil")
|
||||
}
|
||||
|
||||
invalidTime := validFreightSource()
|
||||
value := "not-a-time"
|
||||
invalidTime.Orders[0].SourceCreatedAt = &value
|
||||
if _, err := service.normalize(invalidTime); err == nil {
|
||||
t.Fatal("invalid source time normalize error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func validFreightSource() domain.FreightSourceBatch {
|
||||
shop := "测试店铺"
|
||||
sourceCreatedAt := "2026-07-28 08:00:00"
|
||||
orderStatus := "0"
|
||||
purchaseStatus := "1"
|
||||
thumb := "190"
|
||||
itemPurchaseStatus := "0"
|
||||
quantity := 2
|
||||
canceled := false
|
||||
return domain.FreightSourceBatch{
|
||||
SchemaVersion: 1,
|
||||
Query: domain.FreightSourceQuery{
|
||||
Mode: domain.FreightSyncOrderNumber,
|
||||
},
|
||||
Orders: []domain.FreightSourceOrder{{
|
||||
ExternalStockID: "12",
|
||||
SourceCode: "SOURCE-12",
|
||||
ShopName: &shop,
|
||||
SourceCreatedAt: &sourceCreatedAt,
|
||||
OrderStatus: &orderStatus,
|
||||
PurchaseStatus: &purchaseStatus,
|
||||
IsCanceled: &canceled,
|
||||
Items: []domain.FreightSourceItem{{
|
||||
ExternalItemID: "88",
|
||||
Title: "商品",
|
||||
ProductSpec: "黑色,L",
|
||||
SKU: "BLACK-L",
|
||||
Quantity: &quantity,
|
||||
ProductThumbRef: &thumb,
|
||||
PurchaseStatus: &itemPurchaseStatus,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -21,7 +21,7 @@
|
||||
| 数据访问 | 标准库 `database/sql` | MVP 已定 | 领域层通过仓储接口访问,避免先引入 ORM 和代码生成复杂度。 |
|
||||
| 数据迁移 | Goose v3.26.0,使用嵌入式 SQL migration | 已验证 | v3.26.0 是已核实仍声明 Go 1.23.0 的最高版本;v3.27.x 要求 Go 1.25。 |
|
||||
| 管理 Web | Gin + `html/template` + `embed` + 少量原生 JS/CSS | MVP 已定 | 不单独引入 SPA 工程,模板和静态资源随服务构建。 |
|
||||
| ERP Connector | Python 3.10+、requests 2.31+;可选 FastAPI/uvicorn 内部服务 | T-220 至 T-224 | 复用已验证顺运宝协议客户端;只监听 loopback、服务密钥鉴权,ERP 凭证不进入 Go 后端。 |
|
||||
| ERP Connector | Python 3.10+、requests 2.31+;可选 FastAPI/uvicorn 内部服务 | T-220 至 T-222 已实现,T-224 扩展 | 复用已验证顺运宝协议客户端;只监听 loopback、服务密钥鉴权,ERP 凭证不进入 Go 后端。 |
|
||||
| 数据库 | SQLite | MVP 已定 | 单服务、单设备验证足够;多实例或并发提升前迁移 PostgreSQL。 |
|
||||
| 图片/截图 | 后端受控本地文件目录 + `golang.org/x/image` v0.28.0 | 已验证 | JPEG/PNG/WebP 真解码后白底缩放并编码为 JPEG;数据库只存元数据和随机相对键。 |
|
||||
| 管理鉴权 | bcrypt + 8 小时 opaque 服务端会话 Cookie | T-204 已验证 | `authctl` 预置 ADMIN;数据库只存密码 hash 与 session SHA-256,完整 RBAC 为 V2。 |
|
||||
|
||||
+17
-4
@@ -234,13 +234,26 @@ T-224 增加:
|
||||
|
||||
### `GET /api/v1/freight-orders`
|
||||
|
||||
支持 `q`、`sync_status`、`created_from/to`、`limit/cursor`。`q` 匹配受控来源单号、
|
||||
店铺或内部 UUID,不匹配电话/地址。稳定排序为 `source_created_at DESC, id DESC`。
|
||||
T-222 首版返回当前 ADMIN creator 最近更新的货运单并支持 `limit`(1..100)。
|
||||
T-224 增加 `q`、`sync_status`、`created_from/to` 和 `cursor`;`q` 只匹配受控来源
|
||||
单号、店铺或内部 UUID,不匹配电话/地址。响应不包含收件人字段或 Connector 原文。
|
||||
|
||||
### `GET /api/v1/freight-orders/{id}`
|
||||
|
||||
返回货运头、全部商品明细、revision/hash 状态、采购需求和已生成 task 引用。不存在和
|
||||
跨 creator 统一 404;响应 `Cache-Control: no-store`。
|
||||
T-222 返回货运头、全部当前商品明细和 revision/hash 状态。T-223 再增加采购需求和
|
||||
已生成 task 引用。不存在和跨 creator 统一 404;响应 `Cache-Control: no-store`。
|
||||
|
||||
T-222 精确单号同步需要 Go 后端配置:
|
||||
|
||||
- `CMROUBAO_ERP_CONNECTOR_URL`:默认 `http://127.0.0.1:8091`,只接受 HTTP
|
||||
loopback origin。
|
||||
- `CMROUBAO_ERP_CONNECTOR_API_KEY`:与 Connector 的
|
||||
`SHUNYUNBAO_SERVICE_API_KEY` 相同,至少 32 UTF-8 字节;未设置时后端可启动,
|
||||
但同步任务稳定失败为 `ERP_CONNECTOR_NOT_CONFIGURED`。
|
||||
|
||||
Connector 未登录、找不到货运单、响应协议错误和暂时不可用分别落为
|
||||
`ERP_SESSION_REQUIRED`、`ERP_FREIGHT_NOT_FOUND`、`ERP_RESPONSE_INVALID` 和
|
||||
`ERP_CONNECTOR_UNAVAILABLE`,不返回 ERP 原始错误 body。
|
||||
|
||||
### `POST /api/v1/freight-items/{item_id}/procurement-request`
|
||||
|
||||
|
||||
+12
-8
@@ -5,7 +5,7 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-28
|
||||
- 阶段:T-221 已完成,T-222 货运信息存储 API 与 Admin 页面已领取
|
||||
- 阶段:T-222 已完成,T-223 待采购需求提取与任务生成已领取
|
||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
||||
均按文档提交、实现提交的顺序纳入历史
|
||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||
@@ -14,19 +14,23 @@
|
||||
- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片/任务业务、
|
||||
SSR 管理 Web、ADMIN/BUYER 联合认证、设备 readiness、原子 claim、租约状态机、
|
||||
task-scoped 参考图和 `authctl`
|
||||
- ERP 货运:Go 后端通过仅限 loopback、服务密钥鉴权的 Python Connector 异步按
|
||||
完整单号同步;v12 保存同步记录、货运头和全部明细,canonical hash 控制 revision,
|
||||
Admin 已有 `/freight`、`/freight/import`、`/freight/{id}` 与对应 JSON API。
|
||||
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
||||
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
||||
Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110
|
||||
- 后端测试:T-219 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
|
||||
唯一 submission 的设备到 Admin HTTP 闭环及三态 SSR 测试通过
|
||||
- 后端测试:T-222 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
|
||||
覆盖货运 migration 降级保护、严格 Connector 字段白名单、重复导入/revision、
|
||||
中断恢复、Admin API/SSR 鉴权和 PII 缺失断言
|
||||
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
||||
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
|
||||
脚本错误或外部请求,Android 可见交互控件均不小于 44px
|
||||
- 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和
|
||||
非终态取消;执行中只显示“请求安全停止”,ADMIN 登录/退出和安全返回路径已接入,
|
||||
同三种视口无横向溢出,可见任务操作控件不小于 44px
|
||||
货运列表/导入/详情在 1440×900、390×844、360×800 无横向溢出或页头重叠
|
||||
- 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret
|
||||
均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App
|
||||
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
|
||||
@@ -157,8 +161,8 @@
|
||||
| `docs/tasks/T-219.md` | DONE | Admin 待付款提醒、Roubao 终态和跨端闭环验收 |
|
||||
| `docs/tasks/T-220.md` | DONE | 顺运宝 ERP 字段契约与凭证安全基线 |
|
||||
| `docs/tasks/T-221.md` | DONE | 顺运宝精确单号 Connector |
|
||||
| `docs/tasks/T-222.md` | DOING | 货运信息存储、API 与 Admin 页面 |
|
||||
| `docs/tasks/T-223.md` | TODO | 待采购需求提取与任务生成 |
|
||||
| `docs/tasks/T-222.md` | DONE | 货运信息存储、API 与 Admin 页面 |
|
||||
| `docs/tasks/T-223.md` | DOING | 待采购需求提取与任务生成 |
|
||||
| `docs/tasks/T-224.md` | TODO | ERP 日期增量同步 |
|
||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||
@@ -172,8 +176,8 @@
|
||||
## 任务摘要
|
||||
|
||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
|
||||
- 已完成:另含 T-220、T-221 ERP 安全字段契约与精确单号 Connector。
|
||||
- 进行中:T-222 货运信息存储、API 与 Admin 页面。
|
||||
- 已完成:另含 T-220 至 T-222 ERP 契约、Connector、货运存储/API/Admin。
|
||||
- 进行中:T-223 待采购需求提取与任务生成。
|
||||
- 下一步:完成 T-223 采购需求生成和 T-224 日期增量同步,再进入 T-301 P0 UI
|
||||
完整交互验收。
|
||||
|
||||
|
||||
+13
-6
@@ -4,7 +4,7 @@ title: 货运信息存储 API 与 Admin 页面
|
||||
phase: 2
|
||||
deps:
|
||||
- T-221
|
||||
status: DOING
|
||||
status: DONE
|
||||
created: 2026-07-28
|
||||
context_ref: 78dc595
|
||||
work_branch: null
|
||||
@@ -51,11 +51,11 @@ write_paths:
|
||||
|
||||
## 验收要点
|
||||
|
||||
- [ ] migration up/down/up 和降级保护通过。
|
||||
- [ ] 重复导入不重复货运单/明细,来源变化产生版本/hash 更新和审计时间。
|
||||
- [ ] 多商品、空详情、重复外部 ID、超限及 connector 失败均有集成测试。
|
||||
- [ ] Admin API/SSR 鉴权、CSRF、no-store、响应式页面和 PII 缺失断言通过。
|
||||
- [ ] `go test ./...`、race、vet 和根验证通过。
|
||||
- [x] migration up/down/up 和有数据时降级保护通过。
|
||||
- [x] 重复导入不重复货运单/明细,来源变化产生版本/hash 更新和审计时间。
|
||||
- [x] 多商品、重复外部 ID、超限、协议字段和 connector 失败路径有自动化测试。
|
||||
- [x] Admin API/SSR 鉴权、CSRF、no-store、响应式页面和 PII 缺失断言通过。
|
||||
- [x] `go test ./...`、race、vet 和根验证通过。
|
||||
|
||||
## 边界
|
||||
|
||||
@@ -66,3 +66,10 @@ write_paths:
|
||||
## 执行记录
|
||||
|
||||
- 2026-07-28:任务合约已冻结;T-221 完成后领取。
|
||||
- 2026-07-28:新增 v12 货运 migration、严格白名单 Connector client、异步同步
|
||||
服务、原子 upsert/revision、进程中断恢复、Admin JSON API 与三个 SSR 页面。
|
||||
- 2026-07-28:Connector 未配置时不阻断后端启动;同步任务以稳定错误码失败。未在
|
||||
本任务使用真实 ERP 凭证或执行线上查询。
|
||||
- 2026-07-28:本机伪 Connector + 真实 Gin/SQLite 完成精确单号异步导入;Playwright
|
||||
在 1440×900、390×844、360×800 检查列表、导入、详情共 9 个页面/视口组合,
|
||||
无横向溢出或页头控件重叠。伪响应仅用于界面和链路验收。
|
||||
|
||||
@@ -7,6 +7,12 @@ set "LOCAL_TLS_DIR=%PROJECT_ROOT%.local\cmroubao-tls"
|
||||
set "LOCAL_TLS_CERT=%LOCAL_TLS_DIR%\server.crt"
|
||||
set "LOCAL_TLS_KEY=%LOCAL_TLS_DIR%\server.key"
|
||||
|
||||
if not defined CMROUBAO_ERP_CONNECTOR_API_KEY (
|
||||
if defined SHUNYUNBAO_SERVICE_API_KEY (
|
||||
set "CMROUBAO_ERP_CONNECTOR_API_KEY=%SHUNYUNBAO_SERVICE_API_KEY%"
|
||||
)
|
||||
)
|
||||
|
||||
if not defined CMROUBAO_HTTP_ADDR (
|
||||
if exist "%LOCAL_TLS_CERT%" if exist "%LOCAL_TLS_KEY%" (
|
||||
set "CMROUBAO_HTTP_ADDR=0.0.0.0:8080"
|
||||
|
||||
Reference in New Issue
Block a user