feat(t222): add freight ingestion admin flow

This commit is contained in:
QiuSW
2026-07-28 23:26:34 +08:00
parent c524e734fb
commit 6d052b0462
37 changed files with 3397 additions and 101 deletions
+28
View File
@@ -14,6 +14,7 @@ import (
"cmroubao/backend-api/internal/config"
"cmroubao/backend-api/internal/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,
)
+31
View File
@@ -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 {
+58
View File
@@ -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{
+140
View File
@@ -0,0 +1,140 @@
package domain
import "time"
const (
FreightSourceShunyunbao = "SHUNYUNBAO"
FreightSyncOrderNumber = "ORDER_NUMBER"
)
type FreightSyncStatus string
const (
FreightSyncPending FreightSyncStatus = "PENDING"
FreightSyncRunning FreightSyncStatus = "RUNNING"
FreightSyncSucceeded FreightSyncStatus = "SUCCEEDED"
FreightSyncFailed FreightSyncStatus = "FAILED"
)
type FreightSyncRun struct {
ID string
CreatorSubject string
CreatedByUserID string
Mode string
OrderNumber string
QuerySHA256 string
Status FreightSyncStatus
ErrorCode *string
OrderCount int
ItemCount int
CreatedAt time.Time
StartedAt *time.Time
FinishedAt *time.Time
}
type FreightOrder struct {
ID string
CreatorSubject string
SourceSystem string
ExternalStockID string
SourceCode string
PlatformOrderNo *string
ShopName *string
SourceCreatedAt *time.Time
OrderStatus *string
PurchaseStatus *string
IsCanceled *bool
CanonicalSHA256 string
Revision int
FirstSyncRunID string
LastSyncRunID string
CreatedAt time.Time
UpdatedAt time.Time
ItemCount int
}
type FreightOrderItem struct {
ID string
FreightOrderID string
ExternalItemID string
Title string
ProductSpec string
SKU string
Quantity *int
ProductThumbRef *string
PurchaseStatus *string
CanonicalSHA256 string
Revision int
IsPresent bool
FirstSyncRunID string
LastSyncRunID string
CreatedAt time.Time
UpdatedAt time.Time
}
type FreightOrderDetail struct {
Order FreightOrder
Items []FreightOrderItem
}
type FreightSourceBatch struct {
SchemaVersion int `json:"schema_version"`
Query FreightSourceQuery `json:"query"`
Orders []FreightSourceOrder `json:"orders"`
}
type FreightSourceQuery struct {
Mode string `json:"mode"`
}
type FreightSourceOrder struct {
ExternalStockID string `json:"external_stock_id"`
SourceCode string `json:"source_code"`
PlatformOrderNo *string `json:"platform_order_no"`
ShopName *string `json:"shop_name"`
SourceCreatedAt *string `json:"source_created_at"`
OrderStatus *string `json:"order_status"`
PurchaseStatus *string `json:"purchase_status"`
IsCanceled *bool `json:"is_canceled"`
Items []FreightSourceItem `json:"items"`
}
type FreightSourceItem struct {
ExternalItemID string `json:"external_item_id"`
Title string `json:"title"`
ProductSpec string `json:"product_spec"`
SKU string `json:"sku"`
Quantity *int `json:"quantity"`
ProductThumbRef *string `json:"product_thumb_ref"`
PurchaseStatus *string `json:"purchase_status"`
}
type FreightImportBatch struct {
Orders []FreightImportOrder
}
type FreightImportOrder struct {
ID string
ExternalStockID string
SourceCode string
PlatformOrderNo *string
ShopName *string
SourceCreatedAt *time.Time
OrderStatus *string
PurchaseStatus *string
IsCanceled *bool
CanonicalSHA256 string
Items []FreightImportItem
}
type FreightImportItem struct {
ID string
ExternalItemID string
Title string
ProductSpec string
SKU string
Quantity *int
ProductThumbRef *string
PurchaseStatus *string
CanonicalSHA256 string
}
@@ -0,0 +1,114 @@
package erpconnector
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"cmroubao/backend-api/internal/domain"
)
var (
ErrNotConfigured = errors.New("ERP connector is not configured")
ErrSessionRequired = errors.New("ERP session is required")
ErrNotFound = errors.New("ERP freight order not found")
ErrUnavailable = errors.New("ERP connector is unavailable")
ErrProtocol = errors.New("ERP connector protocol is invalid")
)
const maxResponseBytes = 4 << 20
type Client struct {
baseURL string
apiKey string
http *http.Client
}
func New(baseURL, apiKey string, timeout time.Duration) (*Client, error) {
if strings.TrimSpace(baseURL) == "" {
baseURL = "http://127.0.0.1:8091"
}
if timeout <= 0 {
timeout = 90 * time.Second
}
if timeout <= 0 {
return nil, errors.New("ERP connector client configuration is invalid")
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: strings.TrimSpace(apiKey),
http: &http.Client{
Timeout: timeout,
CheckRedirect: func(
_ *http.Request,
_ []*http.Request,
) error {
return http.ErrUseLastResponse
},
},
}, nil
}
func (client *Client) QueryOrder(
ctx context.Context,
orderNumber string,
) (domain.FreightSourceBatch, error) {
if client.apiKey == "" {
return domain.FreightSourceBatch{}, ErrNotConfigured
}
body, err := json.Marshal(map[string]string{"order_number": orderNumber})
if err != nil {
return domain.FreightSourceBatch{}, ErrProtocol
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
client.baseURL+"/v1/freight/query",
bytes.NewReader(body),
)
if err != nil {
return domain.FreightSourceBatch{}, ErrUnavailable
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-API-Key", client.apiKey)
response, err := client.http.Do(request)
if err != nil {
return domain.FreightSourceBatch{}, ErrUnavailable
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, maxResponseBytes+1)
content, err := io.ReadAll(limited)
if err != nil || len(content) > maxResponseBytes {
return domain.FreightSourceBatch{}, ErrUnavailable
}
if response.StatusCode != http.StatusOK {
switch response.StatusCode {
case http.StatusUnauthorized:
return domain.FreightSourceBatch{}, ErrSessionRequired
case http.StatusNotFound:
return domain.FreightSourceBatch{}, ErrNotFound
default:
return domain.FreightSourceBatch{}, ErrUnavailable
}
}
var result domain.FreightSourceBatch
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&result); err != nil {
return domain.FreightSourceBatch{}, ErrProtocol
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return domain.FreightSourceBatch{}, ErrProtocol
}
if result.SchemaVersion != 1 || result.Query.Mode != "ORDER_NUMBER" ||
result.Orders == nil {
return domain.FreightSourceBatch{}, ErrProtocol
}
return result, nil
}
@@ -0,0 +1,143 @@
package erpconnector
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestQueryOrderAcceptsAllowlistResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
if request.Header.Get("X-API-Key") != "12345678901234567890123456789012" {
t.Fatal("service key was not forwarded")
}
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{
"schema_version":1,
"query":{"mode":"ORDER_NUMBER"},
"orders":[{
"external_stock_id":"12",
"source_code":"SOURCE-12",
"platform_order_no":null,
"shop_name":"测试店铺",
"source_created_at":"2026-07-28 08:00:00",
"order_status":"0",
"purchase_status":"1",
"is_canceled":false,
"items":[{
"external_item_id":"88",
"title":"商品",
"product_spec":"黑色,L",
"sku":"BLACK-L",
"quantity":2,
"product_thumb_ref":"190",
"purchase_status":"0"
}]
}]
}`))
}))
defer server.Close()
client, err := New(
server.URL,
"12345678901234567890123456789012",
time.Second,
)
if err != nil {
t.Fatalf("New() error = %v", err)
}
result, err := client.QueryOrder(context.Background(), "SOURCE-12")
if err != nil {
t.Fatalf("QueryOrder() error = %v", err)
}
if len(result.Orders) != 1 || len(result.Orders[0].Items) != 1 ||
result.Orders[0].Items[0].SKU != "BLACK-L" {
t.Fatalf("result = %+v", result)
}
}
func TestQueryOrderRejectsUnexpectedPIIField(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{
"schema_version":1,
"query":{"mode":"ORDER_NUMBER"},
"orders":[],
"receiverTel":"private"
}`))
}))
defer server.Close()
client, _ := New(server.URL, "12345678901234567890123456789012", time.Second)
_, err := client.QueryOrder(context.Background(), "SOURCE-12")
if !errors.Is(err, ErrProtocol) {
t.Fatalf("QueryOrder() error = %v, want protocol error", err)
}
}
func TestQueryOrderMapsStableErrorsWithoutReadingDetails(t *testing.T) {
tests := []struct {
status int
want error
}{
{http.StatusUnauthorized, ErrSessionRequired},
{http.StatusNotFound, ErrNotFound},
{http.StatusBadGateway, ErrUnavailable},
}
for _, test := range tests {
t.Run(http.StatusText(test.status), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.WriteHeader(test.status)
_, _ = writer.Write([]byte(`{"detail":"private upstream text"}`))
}))
defer server.Close()
client, _ := New(
server.URL,
"12345678901234567890123456789012",
time.Second,
)
_, err := client.QueryOrder(context.Background(), "SOURCE-12")
if !errors.Is(err, test.want) {
t.Fatalf("QueryOrder() error = %v, want %v", err, test.want)
}
})
}
}
func TestQueryOrderDoesNotFollowRedirects(t *testing.T) {
followed := false
target := httptest.NewServer(http.HandlerFunc(func(
http.ResponseWriter,
*http.Request,
) {
followed = true
}))
defer target.Close()
redirect := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.Header().Set("Location", target.URL)
writer.WriteHeader(http.StatusTemporaryRedirect)
}))
defer redirect.Close()
client, _ := New(
redirect.URL,
"12345678901234567890123456789012",
time.Second,
)
_, err := client.QueryOrder(context.Background(), "SOURCE-12")
if !errors.Is(err, ErrUnavailable) || followed {
t.Fatalf("redirect error/followed = %v / %t", err, followed)
}
}
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("initial Up() error = %v", err)
} else if applied != 11 {
t.Fatalf("initial Up() applied = %d, want 11", applied)
} else if applied != 12 {
t.Fatalf("initial Up() applied = %d, want 12", applied)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v12) error = %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v11) error = %v", err)
@@ -62,9 +65,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
seedClaimsHistoricalFixture(t, db)
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("Up(v5-v11) over historical data error = %v", err)
} else if applied != 7 {
t.Fatalf("Up(v5-v11) applied = %d, want 7", applied)
t.Fatalf("Up(v5-v12) over historical data error = %v", err)
} else if applied != 8 {
t.Fatalf("Up(v5-v12) applied = %d, want 8", applied)
}
assertClaimsHistory(t, db, true)
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v12) with compatible history error = %v", err)
}
assertClaimsHistory(t, db, true)
@@ -109,9 +117,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
assertClaimsHistory(t, db, false)
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("final Up(v4-v11) error = %v", err)
} else if applied != 8 {
t.Fatalf("final Up(v4-v11) applied = %d, want 8", applied)
t.Fatalf("final Up(v4-v12) error = %v", err)
} else if applied != 9 {
t.Fatalf("final Up(v4-v12) applied = %d, want 9", applied)
}
assertClaimsHistory(t, db, true)
}
@@ -347,6 +355,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
t.Fatalf("insert v4 audit event: %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v12) error = %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v11) error = %v", err)
}
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
if err != nil {
t.Fatalf("Up() error = %v", err)
}
if applied != 11 {
t.Fatalf("Up() applied = %d, want 11", applied)
if applied != 12 {
t.Fatalf("Up() applied = %d, want 12", applied)
}
assertStatuses(t, runner, map[int64]bool{
1: true,
@@ -42,6 +42,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
9: true,
10: true,
11: true,
12: true,
})
applied, err = runner.Up(context.Background())
@@ -66,7 +67,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
8: true,
9: true,
10: true,
11: false,
11: true,
12: false,
})
applied, err = runner.Up(context.Background())
@@ -88,6 +90,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
9: true,
10: true,
11: true,
12: true,
})
}
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v12) error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v11) error = %v", err)
}
@@ -423,9 +426,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
t.Fatal("purchase_tasks was lost during auth migration rollback")
}
if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("Up(v3-v11) error = %v", err)
} else if applied != 9 {
t.Fatalf("Up(v3-v11) applied = %d, want 9", applied)
t.Fatalf("Up(v3-v12) error = %v", err)
} else if applied != 10 {
t.Fatalf("Up(v3-v12) applied = %d, want 10", applied)
}
}
@@ -0,0 +1,630 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"time"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
)
func (store *Store) CreateFreightSync(
ctx context.Context,
run domain.FreightSyncRun,
idempotencyKey string,
requestSHA256 string,
) (domain.FreightSyncRun, bool, error) {
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return domain.FreightSyncRun{}, false, repositoryFailure(err)
}
defer tx.Rollback()
existing, err := scanFreightSync(tx.QueryRowContext(
ctx,
freightSyncSelect+`
WHERE creator_subject = ? AND idempotency_key = ?`,
run.CreatorSubject,
idempotencyKey,
))
if err == nil {
var storedHash string
if err := tx.QueryRowContext(
ctx,
`SELECT request_sha256
FROM erp_sync_runs
WHERE creator_subject = ? AND idempotency_key = ?`,
run.CreatorSubject,
idempotencyKey,
).Scan(&storedHash); err != nil {
return domain.FreightSyncRun{}, false, repositoryFailure(err)
}
if storedHash != requestSHA256 {
return domain.FreightSyncRun{}, false, usecase.ErrIdempotencyConflict
}
return existing, false, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return domain.FreightSyncRun{}, false, repositoryFailure(err)
}
_, err = tx.ExecContext(
ctx,
`INSERT INTO erp_sync_runs (
id, creator_subject, created_by_user_id, mode, order_number,
query_sha256, idempotency_key, request_sha256, status,
order_count, item_count, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?)`,
run.ID,
run.CreatorSubject,
run.CreatedByUserID,
run.Mode,
run.OrderNumber,
run.QuerySHA256,
idempotencyKey,
requestSHA256,
run.Status,
formatTimestamp(run.CreatedAt),
)
if err != nil {
return domain.FreightSyncRun{}, false, repositoryFailure(err)
}
if err := tx.Commit(); err != nil {
return domain.FreightSyncRun{}, false, repositoryFailure(err)
}
return run, true, nil
}
func (store *Store) StartFreightSync(
ctx context.Context,
runID string,
startedAt time.Time,
) error {
result, err := store.db.ExecContext(
ctx,
`UPDATE erp_sync_runs
SET status = 'RUNNING', started_at = ?
WHERE id = ? AND status = 'PENDING'`,
formatTimestamp(startedAt),
runID,
)
if err != nil {
return repositoryFailure(err)
}
changed, err := result.RowsAffected()
if err != nil {
return repositoryFailure(err)
}
if changed != 1 {
return usecase.ErrTaskStateConflict
}
return nil
}
func (store *Store) CompleteFreightSync(
ctx context.Context,
run domain.FreightSyncRun,
batch domain.FreightImportBatch,
finishedAt time.Time,
) error {
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return repositoryFailure(err)
}
defer tx.Rollback()
var status string
if err := tx.QueryRowContext(
ctx,
`SELECT status FROM erp_sync_runs WHERE id = ?`,
run.ID,
).Scan(&status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return usecase.ErrRepositoryNotFound
}
return repositoryFailure(err)
}
if status != string(domain.FreightSyncRunning) {
return usecase.ErrTaskStateConflict
}
itemCount := 0
for _, order := range batch.Orders {
orderID, err := upsertFreightOrder(
ctx,
tx,
run,
order,
finishedAt,
)
if err != nil {
return err
}
if _, err := tx.ExecContext(
ctx,
`UPDATE freight_order_items
SET is_present = 0, last_sync_run_id = ?, updated_at = ?
WHERE freight_order_id = ?`,
run.ID,
formatTimestamp(finishedAt),
orderID,
); err != nil {
return repositoryFailure(err)
}
for _, item := range order.Items {
if err := upsertFreightOrderItem(
ctx,
tx,
run.ID,
orderID,
item,
finishedAt,
); err != nil {
return err
}
itemCount++
}
}
result, err := tx.ExecContext(
ctx,
`UPDATE erp_sync_runs
SET status = 'SUCCEEDED', error_code = NULL, order_count = ?,
item_count = ?, finished_at = ?
WHERE id = ? AND status = 'RUNNING'`,
len(batch.Orders),
itemCount,
formatTimestamp(finishedAt),
run.ID,
)
if err != nil {
return repositoryFailure(err)
}
changed, err := result.RowsAffected()
if err != nil {
return repositoryFailure(err)
}
if changed != 1 {
return usecase.ErrTaskStateConflict
}
if err := tx.Commit(); err != nil {
return repositoryFailure(err)
}
return nil
}
func upsertFreightOrder(
ctx context.Context,
tx *sql.Tx,
run domain.FreightSyncRun,
order domain.FreightImportOrder,
now time.Time,
) (string, error) {
_, err := tx.ExecContext(
ctx,
`INSERT INTO freight_orders (
id, creator_subject, source_system, external_stock_id,
source_code, platform_order_no, shop_name, source_created_at,
order_status, purchase_status, is_canceled, canonical_sha256,
revision, first_sync_run_id, last_sync_run_id, created_at, updated_at
) VALUES (?, ?, 'SHUNYUNBAO', ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
ON CONFLICT (creator_subject, source_system, external_stock_id)
DO UPDATE SET
source_code = excluded.source_code,
platform_order_no = excluded.platform_order_no,
shop_name = excluded.shop_name,
source_created_at = excluded.source_created_at,
order_status = excluded.order_status,
purchase_status = excluded.purchase_status,
is_canceled = excluded.is_canceled,
revision = CASE
WHEN freight_orders.canonical_sha256 != excluded.canonical_sha256
THEN freight_orders.revision + 1
ELSE freight_orders.revision
END,
canonical_sha256 = excluded.canonical_sha256,
last_sync_run_id = excluded.last_sync_run_id,
updated_at = excluded.updated_at`,
order.ID,
run.CreatorSubject,
order.ExternalStockID,
order.SourceCode,
nullableString(order.PlatformOrderNo),
nullableString(order.ShopName),
nullableTimestamp(order.SourceCreatedAt),
nullableString(order.OrderStatus),
nullableString(order.PurchaseStatus),
nullableBool(order.IsCanceled),
order.CanonicalSHA256,
run.ID,
run.ID,
formatTimestamp(now),
formatTimestamp(now),
)
if err != nil {
return "", repositoryFailure(err)
}
var orderID string
if err := tx.QueryRowContext(
ctx,
`SELECT id FROM freight_orders
WHERE creator_subject = ? AND source_system = 'SHUNYUNBAO'
AND external_stock_id = ?`,
run.CreatorSubject,
order.ExternalStockID,
).Scan(&orderID); err != nil {
return "", repositoryFailure(err)
}
return orderID, nil
}
func upsertFreightOrderItem(
ctx context.Context,
tx *sql.Tx,
runID, orderID string,
item domain.FreightImportItem,
now time.Time,
) error {
_, err := tx.ExecContext(
ctx,
`INSERT INTO freight_order_items (
id, freight_order_id, external_item_id, title, product_spec,
sku, quantity, product_thumb_ref, purchase_status,
canonical_sha256, revision, is_present, first_sync_run_id,
last_sync_run_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?)
ON CONFLICT (freight_order_id, external_item_id)
DO UPDATE SET
title = excluded.title,
product_spec = excluded.product_spec,
sku = excluded.sku,
quantity = excluded.quantity,
product_thumb_ref = excluded.product_thumb_ref,
purchase_status = excluded.purchase_status,
revision = CASE
WHEN freight_order_items.canonical_sha256 != excluded.canonical_sha256
THEN freight_order_items.revision + 1
ELSE freight_order_items.revision
END,
canonical_sha256 = excluded.canonical_sha256,
is_present = 1,
last_sync_run_id = excluded.last_sync_run_id,
updated_at = excluded.updated_at`,
item.ID,
orderID,
item.ExternalItemID,
item.Title,
item.ProductSpec,
item.SKU,
nullableFreightQuantity(item.Quantity),
nullableString(item.ProductThumbRef),
nullableString(item.PurchaseStatus),
item.CanonicalSHA256,
runID,
runID,
formatTimestamp(now),
formatTimestamp(now),
)
if err != nil {
return repositoryFailure(err)
}
return nil
}
func (store *Store) FailFreightSync(
ctx context.Context,
runID, errorCode string,
finishedAt time.Time,
) error {
result, err := store.db.ExecContext(
ctx,
`UPDATE erp_sync_runs
SET status = 'FAILED', error_code = ?, finished_at = ?
WHERE id = ? AND status IN ('PENDING', 'RUNNING')`,
errorCode,
formatTimestamp(finishedAt),
runID,
)
if err != nil {
return repositoryFailure(err)
}
changed, err := result.RowsAffected()
if err != nil {
return repositoryFailure(err)
}
if changed != 1 {
return usecase.ErrTaskStateConflict
}
return nil
}
func (store *Store) RecoverFreightSyncs(
ctx context.Context,
finishedAt time.Time,
) (int64, error) {
result, err := store.db.ExecContext(
ctx,
`UPDATE erp_sync_runs
SET status = 'FAILED', error_code = 'PROCESS_INTERRUPTED',
finished_at = ?
WHERE status IN ('PENDING', 'RUNNING')`,
formatTimestamp(finishedAt),
)
if err != nil {
return 0, repositoryFailure(err)
}
changed, err := result.RowsAffected()
if err != nil {
return 0, repositoryFailure(err)
}
return changed, nil
}
func (store *Store) GetFreightSync(
ctx context.Context,
creatorSubject, runID string,
) (domain.FreightSyncRun, error) {
run, err := scanFreightSync(store.db.QueryRowContext(
ctx,
freightSyncSelect+`
WHERE creator_subject = ? AND id = ?`,
creatorSubject,
runID,
))
if errors.Is(err, sql.ErrNoRows) {
return domain.FreightSyncRun{}, usecase.ErrRepositoryNotFound
}
if err != nil {
return domain.FreightSyncRun{}, repositoryFailure(err)
}
return run, nil
}
func (store *Store) ListFreightOrders(
ctx context.Context,
creatorSubject string,
limit int,
) ([]domain.FreightOrder, error) {
rows, err := store.db.QueryContext(
ctx,
freightOrderSelect+`
WHERE freight_orders.creator_subject = ?
ORDER BY freight_orders.updated_at DESC, freight_orders.id DESC
LIMIT ?`,
creatorSubject,
limit,
)
if err != nil {
return nil, repositoryFailure(err)
}
defer rows.Close()
orders := make([]domain.FreightOrder, 0)
for rows.Next() {
order, err := scanFreightOrder(rows)
if err != nil {
return nil, repositoryFailure(err)
}
orders = append(orders, order)
}
if err := rows.Err(); err != nil {
return nil, repositoryFailure(err)
}
return orders, nil
}
func (store *Store) GetFreightOrder(
ctx context.Context,
creatorSubject, orderID string,
) (domain.FreightOrderDetail, error) {
order, err := scanFreightOrder(store.db.QueryRowContext(
ctx,
freightOrderSelect+`
WHERE freight_orders.creator_subject = ? AND freight_orders.id = ?`,
creatorSubject,
orderID,
))
if errors.Is(err, sql.ErrNoRows) {
return domain.FreightOrderDetail{}, usecase.ErrRepositoryNotFound
}
if err != nil {
return domain.FreightOrderDetail{}, repositoryFailure(err)
}
rows, err := store.db.QueryContext(
ctx,
`SELECT
id, freight_order_id, external_item_id, title, product_spec,
sku, quantity, product_thumb_ref, purchase_status,
canonical_sha256, revision, is_present, first_sync_run_id,
last_sync_run_id, created_at, updated_at
FROM freight_order_items
WHERE freight_order_id = ? AND is_present = 1
ORDER BY CAST(external_item_id AS INTEGER), external_item_id`,
order.ID,
)
if err != nil {
return domain.FreightOrderDetail{}, repositoryFailure(err)
}
defer rows.Close()
items := make([]domain.FreightOrderItem, 0)
for rows.Next() {
item, err := scanFreightOrderItem(rows)
if err != nil {
return domain.FreightOrderDetail{}, repositoryFailure(err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return domain.FreightOrderDetail{}, repositoryFailure(err)
}
return domain.FreightOrderDetail{Order: order, Items: items}, nil
}
const freightSyncSelect = `SELECT
id, creator_subject, created_by_user_id, mode, order_number,
query_sha256, status, error_code, order_count, item_count,
created_at, started_at, finished_at
FROM erp_sync_runs
`
func scanFreightSync(scanner rowScanner) (domain.FreightSyncRun, error) {
var run domain.FreightSyncRun
var errorCode sql.NullString
var createdAt string
var startedAt sql.NullString
var finishedAt sql.NullString
err := scanner.Scan(
&run.ID,
&run.CreatorSubject,
&run.CreatedByUserID,
&run.Mode,
&run.OrderNumber,
&run.QuerySHA256,
&run.Status,
&errorCode,
&run.OrderCount,
&run.ItemCount,
&createdAt,
&startedAt,
&finishedAt,
)
if err != nil {
return domain.FreightSyncRun{}, err
}
if errorCode.Valid {
run.ErrorCode = &errorCode.String
}
run.CreatedAt, err = parseTimestamp(createdAt)
if err != nil {
return domain.FreightSyncRun{}, err
}
run.StartedAt, err = parseNullableTimestamp(startedAt)
if err != nil {
return domain.FreightSyncRun{}, err
}
run.FinishedAt, err = parseNullableTimestamp(finishedAt)
return run, err
}
const freightOrderSelect = `SELECT
freight_orders.id, freight_orders.creator_subject,
freight_orders.source_system, freight_orders.external_stock_id,
freight_orders.source_code, freight_orders.platform_order_no,
freight_orders.shop_name, freight_orders.source_created_at,
freight_orders.order_status, freight_orders.purchase_status,
freight_orders.is_canceled, freight_orders.canonical_sha256,
freight_orders.revision, freight_orders.first_sync_run_id,
freight_orders.last_sync_run_id, freight_orders.created_at,
freight_orders.updated_at,
(SELECT count(*) FROM freight_order_items
WHERE freight_order_id = freight_orders.id AND is_present = 1)
FROM freight_orders
`
func scanFreightOrder(scanner rowScanner) (domain.FreightOrder, error) {
var order domain.FreightOrder
var platformOrderNo, shopName, sourceCreatedAt sql.NullString
var orderStatus, purchaseStatus sql.NullString
var isCanceled sql.NullBool
var createdAt, updatedAt string
err := scanner.Scan(
&order.ID,
&order.CreatorSubject,
&order.SourceSystem,
&order.ExternalStockID,
&order.SourceCode,
&platformOrderNo,
&shopName,
&sourceCreatedAt,
&orderStatus,
&purchaseStatus,
&isCanceled,
&order.CanonicalSHA256,
&order.Revision,
&order.FirstSyncRunID,
&order.LastSyncRunID,
&createdAt,
&updatedAt,
&order.ItemCount,
)
if err != nil {
return domain.FreightOrder{}, err
}
order.PlatformOrderNo = optionalString(platformOrderNo)
order.ShopName = optionalString(shopName)
order.OrderStatus = optionalString(orderStatus)
order.PurchaseStatus = optionalString(purchaseStatus)
if isCanceled.Valid {
order.IsCanceled = &isCanceled.Bool
}
if sourceCreatedAt.Valid {
parsed, err := parseTimestamp(sourceCreatedAt.String)
if err != nil {
return domain.FreightOrder{}, err
}
order.SourceCreatedAt = &parsed
}
order.CreatedAt, err = parseTimestamp(createdAt)
if err != nil {
return domain.FreightOrder{}, err
}
order.UpdatedAt, err = parseTimestamp(updatedAt)
return order, err
}
func scanFreightOrderItem(scanner rowScanner) (domain.FreightOrderItem, error) {
var item domain.FreightOrderItem
var quantity sql.NullInt64
var thumb, purchaseStatus sql.NullString
var createdAt, updatedAt string
err := scanner.Scan(
&item.ID,
&item.FreightOrderID,
&item.ExternalItemID,
&item.Title,
&item.ProductSpec,
&item.SKU,
&quantity,
&thumb,
&purchaseStatus,
&item.CanonicalSHA256,
&item.Revision,
&item.IsPresent,
&item.FirstSyncRunID,
&item.LastSyncRunID,
&createdAt,
&updatedAt,
)
if err != nil {
return domain.FreightOrderItem{}, err
}
if quantity.Valid {
value := int(quantity.Int64)
item.Quantity = &value
}
item.ProductThumbRef = optionalString(thumb)
item.PurchaseStatus = optionalString(purchaseStatus)
item.CreatedAt, err = parseTimestamp(createdAt)
if err != nil {
return domain.FreightOrderItem{}, err
}
item.UpdatedAt, err = parseTimestamp(updatedAt)
return item, err
}
func nullableTimestamp(value *time.Time) any {
if value == nil {
return nil
}
return formatTimestamp(*value)
}
func nullableFreightQuantity(value *int) any {
if value == nil {
return nil
}
return *value
}
func optionalString(value sql.NullString) *string {
if !value.Valid {
return nil
}
return &value.String
}
@@ -0,0 +1,253 @@
package sqlite_test
import (
"context"
"database/sql"
"errors"
"testing"
"time"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/platform/migration"
repository "cmroubao/backend-api/internal/repository/sqlite"
"cmroubao/backend-api/internal/usecase"
)
func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) {
db := openDatabase(t)
store, err := repository.New(db)
if err != nil {
t.Fatalf("repository.New() error = %v", err)
}
ctx := context.Background()
now := time.Date(2026, 7, 28, 1, 2, 3, 0, time.UTC)
userID := uuid(900)
seedFreightUser(t, db, userID, now)
first := freightRun(901, userID, now)
stored, created, err := store.CreateFreightSync(
ctx,
first,
"freight-key-1",
repeatHex("1"),
)
if err != nil || !created || stored.ID != first.ID {
t.Fatalf("CreateFreightSync() = %+v, %t, %v", stored, created, err)
}
replayed, created, err := store.CreateFreightSync(
ctx,
freightRun(902, userID, now),
"freight-key-1",
repeatHex("1"),
)
if err != nil || created || replayed.ID != first.ID {
t.Fatalf("sync replay = %+v, %t, %v", replayed, created, err)
}
_, _, err = store.CreateFreightSync(
ctx,
freightRun(903, userID, now),
"freight-key-1",
repeatHex("2"),
)
if !errors.Is(err, usecase.ErrIdempotencyConflict) {
t.Fatalf("conflicting replay error = %v", err)
}
if err := store.StartFreightSync(ctx, first.ID, now.Add(time.Second)); err != nil {
t.Fatalf("StartFreightSync() error = %v", err)
}
batch := freightBatch(910, "a", "b")
if err := store.CompleteFreightSync(
ctx,
first,
batch,
now.Add(2*time.Second),
); err != nil {
t.Fatalf("CompleteFreightSync() error = %v", err)
}
orders, err := store.ListFreightOrders(ctx, "local-admin", 10)
if err != nil || len(orders) != 1 || orders[0].ItemCount != 2 ||
orders[0].Revision != 1 {
t.Fatalf("orders = %+v, error = %v", orders, err)
}
detail, err := store.GetFreightOrder(ctx, "local-admin", orders[0].ID)
if err != nil || len(detail.Items) != 2 {
t.Fatalf("detail = %+v, error = %v", detail, err)
}
second := freightRun(904, userID, now.Add(time.Minute))
createAndStartFreightRun(t, store, second, "freight-key-2", "3")
unchanged := freightBatch(920, "a", "b")
if err := store.CompleteFreightSync(
ctx,
second,
unchanged,
now.Add(time.Minute+time.Second),
); err != nil {
t.Fatalf("unchanged import error = %v", err)
}
detail, _ = store.GetFreightOrder(ctx, "local-admin", orders[0].ID)
if detail.Order.Revision != 1 ||
detail.Items[0].Revision != 1 ||
detail.Items[1].Revision != 1 {
t.Fatalf("unchanged revisions = %+v", detail)
}
third := freightRun(905, userID, now.Add(2*time.Minute))
createAndStartFreightRun(t, store, third, "freight-key-3", "4")
changed := freightBatch(930, "a", "c")
changed.Orders[0].CanonicalSHA256 = repeatHex("d")
changed.Orders[0].Items[1].Title = "变化后的商品"
changed.Orders[0].Items[1].CanonicalSHA256 = repeatHex("e")
if err := store.CompleteFreightSync(
ctx,
third,
changed,
now.Add(2*time.Minute+time.Second),
); err != nil {
t.Fatalf("changed import error = %v", err)
}
detail, _ = store.GetFreightOrder(ctx, "local-admin", orders[0].ID)
if detail.Order.Revision != 2 ||
detail.Items[0].Revision != 1 ||
detail.Items[1].Revision != 2 {
t.Fatalf("changed revisions = %+v", detail)
}
runner, err := migration.New(db)
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(ctx); err == nil {
t.Fatal("freight migration down succeeded with retained source data")
}
var retained int
if err := db.QueryRow(`SELECT count(*) FROM freight_orders`).Scan(
&retained,
); err != nil || retained != 1 {
t.Fatalf("retained freight orders = %d, error = %v", retained, err)
}
}
func TestFreightSyncRecoveryAndFailedBatchDoNotPersistOrders(t *testing.T) {
db := openDatabase(t)
store, _ := repository.New(db)
ctx := context.Background()
now := time.Date(2026, 7, 28, 2, 3, 4, 0, time.UTC)
userID := uuid(940)
seedFreightUser(t, db, userID, now)
run := freightRun(941, userID, now)
createAndStartFreightRun(t, store, run, "freight-recovery", "5")
count, err := store.RecoverFreightSyncs(ctx, now.Add(time.Minute))
if err != nil || count != 1 {
t.Fatalf("RecoverFreightSyncs() = %d, %v", count, err)
}
stored, err := store.GetFreightSync(ctx, "local-admin", run.ID)
if err != nil || stored.Status != domain.FreightSyncFailed ||
stored.ErrorCode == nil || *stored.ErrorCode != "PROCESS_INTERRUPTED" {
t.Fatalf("recovered run = %+v, error = %v", stored, err)
}
if err := store.CompleteFreightSync(
ctx,
run,
freightBatch(950, "a", "b"),
now.Add(2*time.Minute),
); !errors.Is(err, usecase.ErrTaskStateConflict) {
t.Fatalf("late completion error = %v", err)
}
orders, err := store.ListFreightOrders(ctx, "local-admin", 10)
if err != nil || len(orders) != 0 {
t.Fatalf("orders after failed batch = %+v, error = %v", orders, err)
}
}
func seedFreightUser(
t *testing.T,
db *sql.DB,
userID string,
now time.Time,
) {
t.Helper()
_, err := db.Exec(
`INSERT INTO users (
id, username, password_hash, role, is_active, created_at, updated_at
) VALUES (?, 'freight-admin', 'hash', 'ADMIN', 1, ?, ?)`,
userID,
now.Format(time.RFC3339Nano),
now.Format(time.RFC3339Nano),
)
if err != nil {
t.Fatalf("seed freight user: %v", err)
}
}
func freightRun(
index int,
userID string,
now time.Time,
) domain.FreightSyncRun {
return domain.FreightSyncRun{
ID: uuid(index),
CreatorSubject: "local-admin",
CreatedByUserID: userID,
Mode: domain.FreightSyncOrderNumber,
OrderNumber: "SOURCE-12",
QuerySHA256: repeatHex("a"),
Status: domain.FreightSyncPending,
CreatedAt: now,
}
}
func freightBatch(index int, firstHash, secondHash string) domain.FreightImportBatch {
quantityOne := 1
quantityTwo := 2
return domain.FreightImportBatch{Orders: []domain.FreightImportOrder{{
ID: uuid(index),
ExternalStockID: "12",
SourceCode: "SOURCE-12",
CanonicalSHA256: repeatHex("c"),
Items: []domain.FreightImportItem{
{
ID: uuid(index + 1),
ExternalItemID: "88",
Title: "商品一",
ProductSpec: "黑色,L",
SKU: "BLACK-L",
Quantity: &quantityOne,
CanonicalSHA256: repeatHex(firstHash),
},
{
ID: uuid(index + 2),
ExternalItemID: "89",
Title: "商品二",
ProductSpec: "白色,M",
SKU: "WHITE-M",
Quantity: &quantityTwo,
CanonicalSHA256: repeatHex(secondHash),
},
},
}}}
}
func createAndStartFreightRun(
t *testing.T,
store *repository.Store,
run domain.FreightSyncRun,
key, hash string,
) {
t.Helper()
if _, _, err := store.CreateFreightSync(
context.Background(),
run,
key,
repeatHex(hash),
); err != nil {
t.Fatalf("CreateFreightSync() error = %v", err)
}
if err := store.StartFreightSync(
context.Background(),
run.ID,
run.CreatedAt.Add(time.Second),
); err != nil {
t.Fatalf("StartFreightSync() error = %v", err)
}
}
@@ -28,6 +28,7 @@ type AdminServices struct {
Tasks *usecase.TaskService
Results *usecase.ExecutionResultService
Authorizations *usecase.OrderAuthorizationService
Freight *usecase.FreightService
}
func (s AdminServices) validate() error {
@@ -61,6 +62,12 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
"/api/v1/tasks/:id/order-authorizations",
handler.createOrderAuthorization,
)
if services.Freight != nil {
routes.POST("/api/v1/freight-syncs", handler.createFreightSync)
routes.GET("/api/v1/freight-syncs/:id", handler.freightSyncDetail)
routes.GET("/api/v1/freight-orders", handler.listFreightOrders)
routes.GET("/api/v1/freight-orders/:id", handler.freightOrderDetail)
}
return nil
}
@@ -294,6 +294,107 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
}
}
func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
router := newAdminIntegrationRouter(t)
create := performAdminRequest(
t,
router,
http.MethodPost,
"/api/v1/freight-syncs",
"application/json",
strings.NewReader(
`{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`,
),
"freight-sync-1",
)
if create.Code != http.StatusAccepted {
t.Fatalf("create status/body = %d / %s", create.Code, create.Body)
}
var createBody struct {
Sync struct {
ID string `json:"id"`
} `json:"sync"`
}
decodeResponse(t, create, &createBody)
if createBody.Sync.ID == "" ||
strings.Contains(create.Body.String(), "SOURCE-12") {
t.Fatalf("create response exposes query or lacks ID: %s", create.Body)
}
var sync *httptest.ResponseRecorder
for attempt := 0; attempt < 50; attempt++ {
sync = performAdminRequest(
t,
router,
http.MethodGet,
"/api/v1/freight-syncs/"+createBody.Sync.ID,
"",
nil,
"",
)
if strings.Contains(sync.Body.String(), `"status":"SUCCEEDED"`) {
break
}
time.Sleep(10 * time.Millisecond)
}
if sync == nil || sync.Code != http.StatusOK ||
!strings.Contains(sync.Body.String(), `"item_count":2`) {
t.Fatalf("sync status/body = %d / %s", sync.Code, sync.Body)
}
list := performAdminRequest(
t,
router,
http.MethodGet,
"/api/v1/freight-orders",
"",
nil,
"",
)
if list.Code != http.StatusOK ||
!strings.Contains(list.Body.String(), `"item_count":2`) ||
responseContainsKey(mustDecodeAny(t, list), "receiver") ||
responseContainsKey(mustDecodeAny(t, list), "receiverTel") ||
responseContainsKey(mustDecodeAny(t, list), "receiverAddr") {
t.Fatalf("freight list status/body = %d / %s", list.Code, list.Body)
}
var listBody struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
}
decodeResponse(t, list, &listBody)
detail := performAdminRequest(
t,
router,
http.MethodGet,
"/api/v1/freight-orders/"+listBody.Items[0].ID,
"",
nil,
"",
)
if detail.Code != http.StatusOK ||
!strings.Contains(detail.Body.String(), `"sku":"BLACK-L"`) ||
!strings.Contains(detail.Body.String(), `"sku":"WHITE-M"`) ||
detail.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("freight detail status/body = %d / %s", detail.Code, detail.Body)
}
replay := performAdminRequest(
t,
router,
http.MethodPost,
"/api/v1/freight-syncs",
"application/json",
strings.NewReader(
`{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`,
),
"freight-sync-1",
)
if replay.Code != http.StatusAccepted ||
!strings.Contains(replay.Body.String(), `"replayed":true`) ||
!strings.Contains(replay.Body.String(), createBody.Sync.ID) {
t.Fatalf("replay status/body = %d / %s", replay.Code, replay.Body)
}
}
func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
fixture := newAdminIntegrationFixture(t)
taskID, executionID, taskHash, firstKey, secondKey :=
@@ -414,6 +515,9 @@ func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("freight migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("order submission migration down: %v", err)
}
@@ -738,12 +842,23 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
if err != nil {
t.Fatalf("usecase.NewOrderAuthorizationService() error = %v", err)
}
freight, err := usecase.NewFreightService(
repositories,
staticFreightSource{},
clock,
ids,
time.Second,
)
if err != nil {
t.Fatalf("usecase.NewFreightService() error = %v", err)
}
registrar, err := NewAdminRouteRegistrar(
AdminServices{
Assets: assets,
Tasks: tasks,
Results: results,
Authorizations: authorizations,
Freight: freight,
},
emptyAdminWeb{},
)
@@ -765,6 +880,56 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
return &adminIntegrationFixture{router: router, db: db}
}
type staticFreightSource struct{}
func (staticFreightSource) QueryOrder(
context.Context,
string,
) (domain.FreightSourceBatch, error) {
shop := "测试店铺"
created := "2026-07-28 08:00:00"
quantityOne := 1
quantityTwo := 2
return domain.FreightSourceBatch{
SchemaVersion: 1,
Query: domain.FreightSourceQuery{
Mode: domain.FreightSyncOrderNumber,
},
Orders: []domain.FreightSourceOrder{{
ExternalStockID: "12",
SourceCode: "SOURCE-12",
ShopName: &shop,
SourceCreatedAt: &created,
Items: []domain.FreightSourceItem{
{
ExternalItemID: "88",
Title: "商品一",
ProductSpec: "黑色,L",
SKU: "BLACK-L",
Quantity: &quantityOne,
},
{
ExternalItemID: "89",
Title: "商品二",
ProductSpec: "白色,M",
SKU: "WHITE-M",
Quantity: &quantityTwo,
},
},
}},
}, nil
}
func mustDecodeAny(
t *testing.T,
response *httptest.ResponseRecorder,
) any {
t.Helper()
var decoded any
decodeResponse(t, response, &decoded)
return decoded
}
func referenceUpload(t *testing.T, key string) (io.Reader, string) {
t.Helper()
var imageBytes bytes.Buffer
@@ -231,7 +231,9 @@ func denyAdminSession(ctx *gin.Context) {
next := ctx.Request.URL.RequestURI()
if next == "" ||
(next != "/tasks" && !strings.HasPrefix(next, "/tasks?") &&
!strings.HasPrefix(next, "/tasks/")) {
!strings.HasPrefix(next, "/tasks/") &&
next != "/freight" && !strings.HasPrefix(next, "/freight?") &&
!strings.HasPrefix(next, "/freight/")) {
next = "/tasks"
}
ctx.Abort()
@@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
if err != nil {
t.Fatalf("migration.New() after review error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("freight migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("order submission migration down: %v", err)
}
@@ -727,8 +730,8 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
}
if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("restore device command migration: %v", err)
} else if applied != 3 {
t.Fatalf("restored migrations = %d, want 3", applied)
} else if applied != 4 {
t.Fatalf("restored migrations = %d, want 4", applied)
}
completePayload := fmt.Sprintf(
@@ -1417,6 +1420,9 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("freight migration down: %v", err)
}
if err := runner.Down(context.Background()); err == nil {
t.Fatal("order submission migration down succeeded with retained data")
}
@@ -0,0 +1,188 @@
package httpapi
import (
"net/http"
"strconv"
"strings"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
"github.com/gin-gonic/gin"
)
func (h *adminHandlers) createFreightSync(ctx *gin.Context) {
if !hasMediaType(ctx, "application/json") {
writePublicError(
ctx,
http.StatusUnsupportedMediaType,
"UNSUPPORTED_MEDIA_TYPE",
"application/json is required",
false,
gin.H{},
)
return
}
var request struct {
Mode string `json:"mode"`
OrderNumber string `json:"order_number"`
}
if err := decodeJSON(ctx, &request); err != nil {
writePublicError(
ctx,
http.StatusBadRequest,
"INVALID_JSON",
"request body must be valid JSON",
false,
gin.H{},
)
return
}
if request.Mode != domain.FreightSyncOrderNumber {
writePublicError(
ctx,
http.StatusUnprocessableEntity,
"FREIGHT_SYNC_MODE_INVALID",
"freight sync mode is not supported",
false,
fieldDetails("mode", "must be ORDER_NUMBER"),
)
return
}
result, err := h.services.Freight.CreateOrderSync(
ctx.Request.Context(),
usecase.CreateFreightSyncCommand{
CreatorSubject: localAdminSubject,
ActorUserID: adminActorUserID(ctx),
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
OrderNumber: request.OrderNumber,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusAccepted, gin.H{
"sync": freightSyncResponse(result.Run),
"replayed": result.Replayed,
})
}
func (h *adminHandlers) freightSyncDetail(ctx *gin.Context) {
run, err := h.services.Freight.GetSync(
ctx.Request.Context(),
localAdminSubject,
ctx.Param("id"),
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{"sync": freightSyncResponse(run)})
}
func (h *adminHandlers) listFreightOrders(ctx *gin.Context) {
limit := 0
if value := strings.TrimSpace(ctx.Query("limit")); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil {
writePublicError(
ctx,
http.StatusBadRequest,
"FREIGHT_LIST_INVALID",
"freight list filter is invalid",
false,
fieldDetails("limit", "must be an integer"),
)
return
}
limit = parsed
}
orders, err := h.services.Freight.ListOrders(
ctx.Request.Context(),
localAdminSubject,
limit,
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
items := make([]gin.H, 0, len(orders))
for _, order := range orders {
items = append(items, freightOrderResponse(order))
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"items": items,
"next_cursor": nil,
})
}
func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
detail, err := h.services.Freight.GetOrder(
ctx.Request.Context(),
localAdminSubject,
ctx.Param("id"),
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
items := make([]gin.H, 0, len(detail.Items))
for _, item := range detail.Items {
items = append(items, gin.H{
"id": item.ID,
"external_item_id": item.ExternalItemID,
"title": item.Title,
"product_spec": item.ProductSpec,
"sku": item.SKU,
"quantity": item.Quantity,
"product_thumb_ref": item.ProductThumbRef,
"purchase_status": item.PurchaseStatus,
"revision": item.Revision,
"canonical_sha256": item.CanonicalSHA256,
"updated_at": formatTime(item.UpdatedAt),
})
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"order": freightOrderResponse(detail.Order),
"items": items,
})
}
func freightSyncResponse(run domain.FreightSyncRun) gin.H {
return gin.H{
"id": run.ID,
"mode": run.Mode,
"query_sha256": run.QuerySHA256,
"status": run.Status,
"error_code": run.ErrorCode,
"order_count": run.OrderCount,
"item_count": run.ItemCount,
"created_at": formatTime(run.CreatedAt),
"started_at": formatOptionalTime(run.StartedAt),
"finished_at": formatOptionalTime(run.FinishedAt),
}
}
func freightOrderResponse(order domain.FreightOrder) gin.H {
return gin.H{
"id": order.ID,
"source_system": order.SourceSystem,
"external_stock_id": order.ExternalStockID,
"source_code": order.SourceCode,
"platform_order_no": order.PlatformOrderNo,
"shop_name": order.ShopName,
"source_created_at": formatOptionalTime(order.SourceCreatedAt),
"order_status": order.OrderStatus,
"purchase_status": order.PurchaseStatus,
"is_canceled": order.IsCanceled,
"revision": order.Revision,
"canonical_sha256": order.CanonicalSHA256,
"item_count": order.ItemCount,
"updated_at": formatTime(order.UpdatedAt),
}
}
@@ -267,7 +267,9 @@ func safeNext(value string) string {
return "/tasks"
}
if parsed.Path != "/tasks" &&
!strings.HasPrefix(parsed.Path, "/tasks/") {
!strings.HasPrefix(parsed.Path, "/tasks/") &&
parsed.Path != "/freight" &&
!strings.HasPrefix(parsed.Path, "/freight/") {
return "/tasks"
}
return parsed.String()
@@ -294,6 +294,9 @@ func TestSafeNextRejectsExternalAndAmbiguousPaths(t *testing.T) {
if actual := safeNext("/tasks/item?id=1"); actual != "/tasks/item?id=1" {
t.Fatalf("safeNext(valid) = %q", actual)
}
if actual := safeNext("/freight/import"); actual != "/freight/import" {
t.Fatalf("safeNext(freight) = %q", actual)
}
}
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
@@ -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(), "&lt;script&gt;private") ||
!strings.Contains(list.Body.String(), "2 项") {
t.Fatalf("freight list status/body = %d / %s", list.Code, list.Body)
}
assertSecurityHeaders(t, list)
form := performRequest(
t,
router,
http.MethodGet,
"/freight/import",
nil,
"",
)
cookie := csrfCookie(t, form)
idempotencyKey := hiddenValue(
t,
form.Body.String(),
"idempotency_key",
)
values := url.Values{
"csrf_token": {cookie.Value},
"idempotency_key": {idempotencyKey},
"order_number": {"SOURCE-12"},
}
request := httptest.NewRequest(
http.MethodPost,
"/freight/import",
strings.NewReader(values.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
response.Header().Get("Location") !=
"/freight/import?sync="+testTaskID {
t.Fatalf(
"create sync status/location = %d / %q",
response.Code,
response.Header().Get("Location"),
)
}
if service.createInput.OrderNumber != "SOURCE-12" ||
service.createInput.IdempotencyKey != idempotencyKey {
t.Fatalf("create input = %+v", service.createInput)
}
}
type fakeService struct {
listInput ListTasksInput
listResult TaskList
@@ -945,6 +1017,45 @@ type fakeService struct {
authorizeInput AuthorizeOrderInput
}
type fakeFreightService struct {
*fakeService
orders []FreightOrder
orderDetail FreightOrderDetail
sync FreightSync
createInput CreateFreightSyncInput
createResult FreightSync
err error
}
func (service *fakeFreightService) ListFreightOrders(
context.Context,
int,
) ([]FreightOrder, error) {
return service.orders, service.err
}
func (service *fakeFreightService) GetFreightOrder(
context.Context,
string,
) (FreightOrderDetail, error) {
return service.orderDetail, service.err
}
func (service *fakeFreightService) GetFreightSync(
context.Context,
string,
) (FreightSync, error) {
return service.sync, service.err
}
func (service *fakeFreightService) CreateFreightSync(
_ context.Context,
input CreateFreightSyncInput,
) (FreightSync, error) {
service.createInput = input
return service.createResult, service.err
}
func (service *fakeService) ListTasks(
_ context.Context,
input ListTasksInput,
@@ -673,6 +673,54 @@ tbody tr:last-child td {
background: var(--danger-soft);
}
.notice.danger {
border-color: var(--danger);
color: var(--danger-dark);
background: var(--danger-soft);
}
.form-panel {
display: grid;
gap: 18px;
padding: 22px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
}
.detail-section {
margin-bottom: 22px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
margin: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--line);
}
.detail-grid > div {
min-width: 0;
padding: 12px;
background: var(--surface);
}
.detail-grid dt {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.detail-grid dd {
margin: 4px 0 0;
overflow-wrap: anywhere;
font-weight: 700;
}
.task-form {
display: grid;
gap: 18px;
@@ -1139,6 +1187,10 @@ tbody tr:last-child td {
grid-template-columns: 1fr;
}
.detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.authorization-note {
grid-column: auto;
}
@@ -1154,11 +1206,7 @@ tbody tr:last-child td {
padding-inline: 10px;
}
.brand {
font-size: 14px;
}
.brand-mark {
.brand span:not(.brand-mark) {
display: none;
}
@@ -1167,13 +1215,13 @@ tbody tr:last-child td {
}
.main-nav a {
padding-inline: 8px;
font-size: 13px;
padding-inline: 6px;
font-size: 12px;
}
.logout-button {
padding-inline: 8px;
font-size: 13px;
padding-inline: 6px;
font-size: 12px;
}
.title-row,
@@ -1186,6 +1234,10 @@ tbody tr:last-child td {
width: 100%;
}
.detail-grid {
grid-template-columns: 1fr;
}
.title-actions {
justify-content: flex-start;
flex-wrap: wrap;
@@ -0,0 +1,66 @@
{{define "freight-detail"}}
<!doctype html>
<html lang="zh-CN">
<head>
<title>{{.Page.Title}} - 采购任务管理</title>
{{template "document-head" .}}
</head>
<body>
{{template "site-header" .}}
<main id="main-content" class="page">
<div class="title-row">
<div>
<h1>{{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}货运详情{{end}}</h1>
<p class="subtitle">ERP ID:{{.Detail.Order.ExternalStockID}} · 来源版本 {{.Detail.Order.Revision}}</p>
</div>
<a class="button" href="/freight">返回列表</a>
</div>
<section class="detail-section" aria-labelledby="freight-source-title">
<h2 id="freight-source-title">来源信息</h2>
<dl class="detail-grid">
<div><dt>店铺</dt><dd>{{if .Detail.Order.ShopName}}{{.Detail.Order.ShopName}}{{else}}未提供{{end}}</dd></div>
<div><dt>ERP 创建时间</dt><dd>{{displayTime .Detail.Order.SourceCreatedAt}}</dd></div>
<div><dt>订单状态</dt><dd>{{if .Detail.Order.OrderStatus}}{{.Detail.Order.OrderStatus}}{{else}}未提供{{end}}</dd></div>
<div><dt>采购状态</dt><dd>{{if .Detail.Order.PurchaseStatus}}{{.Detail.Order.PurchaseStatus}}{{else}}未提供{{end}}</dd></div>
</dl>
</section>
<section class="table-region" aria-labelledby="freight-items-title">
<h2 id="freight-items-title">商品明细</h2>
{{if .Detail.Items}}
<table>
<thead>
<tr>
<th scope="col">商品</th>
<th scope="col">规格 / SKU</th>
<th scope="col">数量</th>
<th scope="col">采购状态</th>
<th scope="col">来源版本</th>
</tr>
</thead>
<tbody>
{{range .Detail.Items}}
<tr>
<td data-label="商品">
<strong>{{if .Title}}{{.Title}}{{else}}缺少标题{{end}}</strong>
<span class="secondary">明细 ID:{{.ExternalItemID}}</span>
{{if .ProductThumbRef}}<span class="secondary">图片引用:{{.ProductThumbRef}}</span>{{end}}
</td>
<td data-label="规格 / SKU">
<span>{{if .ProductSpec}}{{.ProductSpec}}{{else}}未提供规格{{end}}</span>
<span class="secondary">SKU:{{if .SKU}}{{.SKU}}{{else}}未提供{{end}}</span>
</td>
<td data-label="数量">{{if .Quantity}}{{.Quantity}}{{else}}未提供{{end}}</td>
<td data-label="采购状态">{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}</td>
<td data-label="来源版本">{{.Revision}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state"><h2>该货运单没有商品明细</h2></div>
{{end}}
</section>
</main>
</body>
</html>
{{end}}
@@ -0,0 +1,46 @@
{{define "freight-import"}}
<!doctype html>
<html lang="zh-CN">
<head>
<title>{{.Page.Title}} - 采购任务管理</title>
{{template "document-head" .}}
</head>
<body>
{{template "site-header" .}}
<main id="main-content" class="page narrow-page">
<div class="title-row">
<div>
<h1>导入 ERP 货运</h1>
<p class="subtitle">使用 ERP 页面“全部单号”中的完整单号</p>
</div>
<a class="button" href="/freight">返回列表</a>
</div>
{{if .Error}}<div class="notice danger" role="alert">{{.Error}}</div>{{end}}
{{if .Sync}}
<section class="detail-section" aria-labelledby="sync-result-title">
<h2 id="sync-result-title">同步状态</h2>
<dl class="detail-grid">
<div><dt>状态</dt><dd>{{.Sync.Status}}</dd></div>
<div><dt>货运单</dt><dd>{{.Sync.OrderCount}}</dd></div>
<div><dt>商品明细</dt><dd>{{.Sync.ItemCount}}</dd></div>
<div><dt>错误码</dt><dd>{{if .Sync.ErrorCode}}{{.Sync.ErrorCode}}{{else}}无{{end}}</dd></div>
</dl>
{{if or (eq .Sync.Status "PENDING") (eq .Sync.Status "RUNNING")}}
<p class="secondary">同步仍在后台执行,刷新本页查看结果。</p>
{{end}}
</section>
{{end}}
<form class="form-panel" method="post" action="/freight/import" data-loading-form>
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
<input type="hidden" name="idempotency_key" value="{{.IdempotencyKey}}">
<div class="field">
<label for="order-number">完整单号</label>
<input id="order-number" name="order_number" value="{{.OrderNumber}}"
maxlength="128" autocomplete="off" required>
</div>
<button class="button primary" type="submit" data-loading-label="正在创建…">开始同步</button>
</form>
</main>
</body>
</html>
{{end}}
@@ -0,0 +1,62 @@
{{define "freight"}}
<!doctype html>
<html lang="zh-CN">
<head>
<title>{{.Page.Title}} - 采购任务管理</title>
{{template "document-head" .}}
</head>
<body>
{{template "site-header" .}}
<main id="main-content" class="page">
<div class="title-row">
<div>
<h1>ERP 货运</h1>
<p class="subtitle">核对已同步的货运单和全部商品明细</p>
</div>
<a class="button primary" href="/freight/import">导入货运单</a>
</div>
<section class="table-region" aria-labelledby="freight-table-title">
<h2 id="freight-table-title" class="visually-hidden">货运单列表</h2>
{{if .Orders}}
<table>
<thead>
<tr>
<th scope="col">来源单号</th>
<th scope="col">店铺</th>
<th scope="col">状态</th>
<th scope="col">商品</th>
<th scope="col">更新时间</th>
<th scope="col"><span class="visually-hidden">操作</span></th>
</tr>
</thead>
<tbody>
{{range .Orders}}
<tr>
<td data-label="来源单号">
<strong>{{if .SourceCode}}{{.SourceCode}}{{else}}{{.ExternalStockID}}{{end}}</strong>
<span class="secondary">ERP ID:{{.ExternalStockID}}</span>
</td>
<td data-label="店铺">{{if .ShopName}}{{.ShopName}}{{else}}未提供{{end}}</td>
<td data-label="状态">
<span class="secondary">订单:{{if .OrderStatus}}{{.OrderStatus}}{{else}}未提供{{end}}</span>
<span class="secondary">采购:{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}</span>
</td>
<td data-label="商品">{{.ItemCount}} 项</td>
<td data-label="更新时间"><time datetime="{{machineTime .UpdatedAt}}">{{displayTime .UpdatedAt}}</time></td>
<td data-label="操作"><a class="detail-link" href="/freight/{{pathPart .ID}}">查看详情</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<h2>尚未导入货运单</h2>
<p>按完整单号创建第一条 ERP 同步任务。</p>
<a class="button primary" href="/freight/import">导入货运单</a>
</div>
{{end}}
</section>
</main>
</body>
</html>
{{end}}
@@ -17,6 +17,7 @@
<nav class="main-nav" aria-label="主导航">
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
<a href="/freight" {{if .Page.FreightCurrent}}aria-current="page"{{end}}>ERP 货运</a>
</nav>
{{if .Page.CSRFToken}}
<form class="logout-form" method="post" action="/logout">
@@ -27,6 +27,63 @@ type Service interface {
AuthorizeOrder(context.Context, AuthorizeOrderInput) (OrderAuthorization, error)
}
type FreightService interface {
ListFreightOrders(context.Context, int) ([]FreightOrder, error)
GetFreightOrder(context.Context, string) (FreightOrderDetail, error)
GetFreightSync(context.Context, string) (FreightSync, error)
CreateFreightSync(
context.Context,
CreateFreightSyncInput,
) (FreightSync, error)
}
type FreightSync struct {
ID string
Status string
ErrorCode string
OrderCount int
ItemCount int
CreatedAt time.Time
FinishedAt time.Time
}
type CreateFreightSyncInput struct {
ActorUserID string
IdempotencyKey string
OrderNumber string
}
type FreightOrder struct {
ID string
ExternalStockID string
SourceCode string
ShopName string
SourceCreatedAt time.Time
OrderStatus string
PurchaseStatus string
IsCanceled bool
ItemCount int
Revision int
UpdatedAt time.Time
}
type FreightOrderItem struct {
ID string
ExternalItemID string
Title string
ProductSpec string
SKU string
Quantity int
ProductThumbRef string
PurchaseStatus string
Revision int
}
type FreightOrderDetail struct {
Order FreightOrder
Items []FreightOrderItem
}
type ListTasksInput struct {
Query string
Status string
@@ -17,23 +17,157 @@ type UsecaseAdapter struct {
tasks *usecase.TaskService
assets *usecase.AssetService
authorizations *usecase.OrderAuthorizationService
freight *usecase.FreightService
}
func NewUsecaseAdapter(
tasks *usecase.TaskService,
assets *usecase.AssetService,
authorizations *usecase.OrderAuthorizationService,
freight ...*usecase.FreightService,
) (*UsecaseAdapter, error) {
if tasks == nil || assets == nil || authorizations == nil {
return nil, errors.New("admin web use cases are required")
}
return &UsecaseAdapter{
adapter := &UsecaseAdapter{
tasks: tasks,
assets: assets,
authorizations: authorizations,
}
if len(freight) > 0 {
adapter.freight = freight[0]
}
return adapter, nil
}
func (adapter *UsecaseAdapter) ListFreightOrders(
ctx context.Context,
limit int,
) ([]FreightOrder, error) {
if adapter.freight == nil {
return nil, ErrUnavailable
}
orders, err := adapter.freight.ListOrders(ctx, localAdminSubject, limit)
if err != nil {
return nil, mapUsecaseError(err)
}
result := make([]FreightOrder, 0, len(orders))
for _, order := range orders {
result = append(result, freightOrderFrom(order))
}
return result, nil
}
func (adapter *UsecaseAdapter) GetFreightOrder(
ctx context.Context,
orderID string,
) (FreightOrderDetail, error) {
if adapter.freight == nil {
return FreightOrderDetail{}, ErrUnavailable
}
detail, err := adapter.freight.GetOrder(
ctx,
localAdminSubject,
orderID,
)
if err != nil {
return FreightOrderDetail{}, mapUsecaseError(err)
}
items := make([]FreightOrderItem, 0, len(detail.Items))
for _, item := range detail.Items {
view := FreightOrderItem{
ID: item.ID,
ExternalItemID: item.ExternalItemID,
Title: item.Title,
ProductSpec: item.ProductSpec,
SKU: item.SKU,
ProductThumbRef: stringValue(item.ProductThumbRef),
PurchaseStatus: stringValue(item.PurchaseStatus),
Revision: item.Revision,
}
if item.Quantity != nil {
view.Quantity = *item.Quantity
}
items = append(items, view)
}
return FreightOrderDetail{
Order: freightOrderFrom(detail.Order),
Items: items,
}, nil
}
func (adapter *UsecaseAdapter) GetFreightSync(
ctx context.Context,
syncID string,
) (FreightSync, error) {
if adapter.freight == nil {
return FreightSync{}, ErrUnavailable
}
run, err := adapter.freight.GetSync(ctx, localAdminSubject, syncID)
if err != nil {
return FreightSync{}, mapUsecaseError(err)
}
return freightSyncFrom(run), nil
}
func (adapter *UsecaseAdapter) CreateFreightSync(
ctx context.Context,
input CreateFreightSyncInput,
) (FreightSync, error) {
if adapter.freight == nil {
return FreightSync{}, ErrUnavailable
}
result, err := adapter.freight.CreateOrderSync(
ctx,
usecase.CreateFreightSyncCommand{
CreatorSubject: localAdminSubject,
ActorUserID: input.ActorUserID,
IdempotencyKey: input.IdempotencyKey,
OrderNumber: input.OrderNumber,
},
)
if err != nil {
return FreightSync{}, mapUsecaseError(err)
}
return freightSyncFrom(result.Run), nil
}
func freightOrderFrom(order domain.FreightOrder) FreightOrder {
result := FreightOrder{
ID: order.ID,
ExternalStockID: order.ExternalStockID,
SourceCode: order.SourceCode,
ShopName: stringValue(order.ShopName),
OrderStatus: stringValue(order.OrderStatus),
PurchaseStatus: stringValue(order.PurchaseStatus),
ItemCount: order.ItemCount,
Revision: order.Revision,
UpdatedAt: order.UpdatedAt,
}
if order.SourceCreatedAt != nil {
result.SourceCreatedAt = *order.SourceCreatedAt
}
if order.IsCanceled != nil {
result.IsCanceled = *order.IsCanceled
}
return result
}
func freightSyncFrom(run domain.FreightSyncRun) FreightSync {
result := FreightSync{
ID: run.ID,
Status: string(run.Status),
ErrorCode: stringValue(run.ErrorCode),
OrderCount: run.OrderCount,
ItemCount: run.ItemCount,
CreatedAt: run.CreatedAt,
}
if run.FinishedAt != nil {
result.FinishedAt = *run.FinishedAt
}
return result
}
func (adapter *UsecaseAdapter) ListTasks(
ctx context.Context,
input ListTasksInput,
@@ -525,3 +659,4 @@ func (err *adapterError) Unwrap() []error {
}
var _ Service = (*UsecaseAdapter)(nil)
var _ FreightService = (*UsecaseAdapter)(nil)
@@ -0,0 +1,45 @@
package usecase
import (
"context"
"time"
"cmroubao/backend-api/internal/domain"
)
type FreightSource interface {
QueryOrder(context.Context, string) (domain.FreightSourceBatch, error)
}
type FreightRepository interface {
CreateFreightSync(
context.Context,
domain.FreightSyncRun,
string,
string,
) (domain.FreightSyncRun, bool, error)
StartFreightSync(context.Context, string, time.Time) error
CompleteFreightSync(
context.Context,
domain.FreightSyncRun,
domain.FreightImportBatch,
time.Time,
) error
FailFreightSync(context.Context, string, string, time.Time) error
RecoverFreightSyncs(context.Context, time.Time) (int64, error)
GetFreightSync(
context.Context,
string,
string,
) (domain.FreightSyncRun, error)
ListFreightOrders(
context.Context,
string,
int,
) ([]domain.FreightOrder, error)
GetFreightOrder(
context.Context,
string,
string,
) (domain.FreightOrderDetail, error)
}
@@ -0,0 +1,459 @@
package usecase
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/platform/erpconnector"
)
const (
maxFreightOrdersPerSync = 100
maxFreightItemsPerOrder = 1000
)
type FreightService struct {
repository FreightRepository
source FreightSource
clock Clock
ids IDGenerator
timeout time.Duration
}
type CreateFreightSyncCommand struct {
CreatorSubject string
ActorUserID string
IdempotencyKey string
OrderNumber string
}
type CreateFreightSyncResult struct {
Run domain.FreightSyncRun
Replayed bool
}
func NewFreightService(
repository FreightRepository,
source FreightSource,
clock Clock,
ids IDGenerator,
timeout time.Duration,
) (*FreightService, error) {
if repository == nil || source == nil || clock == nil || ids == nil ||
timeout <= 0 {
return nil, errors.New("freight service dependencies are required")
}
return &FreightService{
repository: repository,
source: source,
clock: clock,
ids: ids,
timeout: timeout,
}, nil
}
func (service *FreightService) CreateOrderSync(
ctx context.Context,
command CreateFreightSyncCommand,
) (CreateFreightSyncResult, error) {
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
command.OrderNumber = strings.TrimSpace(command.OrderNumber)
fields := map[string]string{}
if command.CreatorSubject == "" {
fields["creator_subject"] = "is required"
}
if command.ActorUserID == "" {
fields["actor_user_id"] = "is required"
}
if command.IdempotencyKey == "" || len([]byte(command.IdempotencyKey)) > 128 {
fields["idempotency_key"] = "must contain 1 to 128 bytes"
}
if command.OrderNumber == "" ||
len([]byte(command.OrderNumber)) > 128 ||
hasControl(command.OrderNumber) {
fields["order_number"] = "must contain 1 to 128 bytes without control characters"
}
if len(fields) > 0 {
return CreateFreightSyncResult{}, invalidError(
"FREIGHT_SYNC_INVALID",
"freight sync request is invalid",
fields,
)
}
runID, err := service.ids.NewID()
if err != nil {
return CreateFreightSyncResult{}, wrapRepositoryError(err)
}
now := service.clock.Now().UTC()
queryHash := hashJSON(struct {
Mode string `json:"mode"`
OrderNumber string `json:"order_number"`
}{domain.FreightSyncOrderNumber, command.OrderNumber})
requestHash := hashJSON(struct {
OrderNumber string `json:"order_number"`
}{command.OrderNumber})
run, created, err := service.repository.CreateFreightSync(
ctx,
domain.FreightSyncRun{
ID: runID,
CreatorSubject: command.CreatorSubject,
CreatedByUserID: command.ActorUserID,
Mode: domain.FreightSyncOrderNumber,
OrderNumber: command.OrderNumber,
QuerySHA256: queryHash,
Status: domain.FreightSyncPending,
CreatedAt: now,
},
command.IdempotencyKey,
requestHash,
)
if err != nil {
return CreateFreightSyncResult{}, wrapRepositoryError(err)
}
if created {
go service.execute(run)
}
return CreateFreightSyncResult{Run: run, Replayed: !created}, nil
}
func (service *FreightService) execute(run domain.FreightSyncRun) {
ctx, cancel := context.WithTimeout(context.Background(), service.timeout)
defer cancel()
now := service.clock.Now().UTC()
if err := service.repository.StartFreightSync(ctx, run.ID, now); err != nil {
return
}
source, err := service.source.QueryOrder(ctx, run.OrderNumber)
if err != nil {
_ = service.repository.FailFreightSync(
ctx,
run.ID,
freightSourceErrorCode(err),
service.clock.Now().UTC(),
)
return
}
batch, err := service.normalize(source)
if err != nil {
_ = service.repository.FailFreightSync(
ctx,
run.ID,
"ERP_RESPONSE_INVALID",
service.clock.Now().UTC(),
)
return
}
if err := service.repository.CompleteFreightSync(
ctx,
run,
batch,
service.clock.Now().UTC(),
); err != nil {
_ = service.repository.FailFreightSync(
ctx,
run.ID,
"STORAGE_UNAVAILABLE",
service.clock.Now().UTC(),
)
}
}
func (service *FreightService) RecoverInterrupted(
ctx context.Context,
) (int64, error) {
count, err := service.repository.RecoverFreightSyncs(
ctx,
service.clock.Now().UTC(),
)
if err != nil {
return 0, wrapRepositoryError(err)
}
return count, nil
}
func (service *FreightService) GetSync(
ctx context.Context,
creatorSubject, syncID string,
) (domain.FreightSyncRun, error) {
run, err := service.repository.GetFreightSync(
ctx,
strings.TrimSpace(creatorSubject),
strings.TrimSpace(syncID),
)
if err != nil {
return domain.FreightSyncRun{}, wrapRepositoryError(err)
}
return run, nil
}
func (service *FreightService) ListOrders(
ctx context.Context,
creatorSubject string,
limit int,
) ([]domain.FreightOrder, error) {
if limit == 0 {
limit = 50
}
if limit < 1 || limit > 100 {
return nil, invalidError(
"FREIGHT_LIST_INVALID",
"freight list filter is invalid",
map[string]string{"limit": "must be between 1 and 100"},
)
}
orders, err := service.repository.ListFreightOrders(
ctx,
strings.TrimSpace(creatorSubject),
limit,
)
if err != nil {
return nil, wrapRepositoryError(err)
}
return orders, nil
}
func (service *FreightService) GetOrder(
ctx context.Context,
creatorSubject, orderID string,
) (domain.FreightOrderDetail, error) {
detail, err := service.repository.GetFreightOrder(
ctx,
strings.TrimSpace(creatorSubject),
strings.TrimSpace(orderID),
)
if err != nil {
return domain.FreightOrderDetail{}, wrapRepositoryError(err)
}
return detail, nil
}
func (service *FreightService) normalize(
source domain.FreightSourceBatch,
) (domain.FreightImportBatch, error) {
if source.SchemaVersion != 1 ||
source.Query.Mode != domain.FreightSyncOrderNumber ||
len(source.Orders) > maxFreightOrdersPerSync {
return domain.FreightImportBatch{}, errors.New("invalid source envelope")
}
seenOrders := map[string]struct{}{}
result := domain.FreightImportBatch{
Orders: make([]domain.FreightImportOrder, 0, len(source.Orders)),
}
for _, sourceOrder := range source.Orders {
externalID, ok := validExternalID(sourceOrder.ExternalStockID)
if !ok || len(sourceOrder.Items) > maxFreightItemsPerOrder {
return domain.FreightImportBatch{}, errors.New("invalid freight order")
}
if _, exists := seenOrders[externalID]; exists {
return domain.FreightImportBatch{}, errors.New("duplicate freight order")
}
seenOrders[externalID] = struct{}{}
if !validBytes(sourceOrder.SourceCode, 256) ||
!validOptional(sourceOrder.PlatformOrderNo, 256) ||
!validOptional(sourceOrder.ShopName, 512) ||
!validOptional(sourceOrder.OrderStatus, 128) ||
!validOptional(sourceOrder.PurchaseStatus, 128) {
return domain.FreightImportBatch{}, errors.New("invalid freight fields")
}
sourceCreatedAt, err := parseERPTime(sourceOrder.SourceCreatedAt)
if err != nil {
return domain.FreightImportBatch{}, err
}
orderID, err := service.ids.NewID()
if err != nil {
return domain.FreightImportBatch{}, err
}
order := domain.FreightImportOrder{
ID: orderID,
ExternalStockID: externalID,
SourceCode: sourceOrder.SourceCode,
PlatformOrderNo: cleanOptional(sourceOrder.PlatformOrderNo),
ShopName: cleanOptional(sourceOrder.ShopName),
SourceCreatedAt: sourceCreatedAt,
OrderStatus: cleanOptional(sourceOrder.OrderStatus),
PurchaseStatus: cleanOptional(sourceOrder.PurchaseStatus),
IsCanceled: sourceOrder.IsCanceled,
Items: make([]domain.FreightImportItem, 0, len(sourceOrder.Items)),
}
seenItems := map[string]struct{}{}
for _, sourceItem := range sourceOrder.Items {
itemExternalID, ok := validExternalID(sourceItem.ExternalItemID)
if !ok {
return domain.FreightImportBatch{}, errors.New("invalid freight item")
}
if _, exists := seenItems[itemExternalID]; exists {
return domain.FreightImportBatch{}, errors.New("duplicate freight item")
}
seenItems[itemExternalID] = struct{}{}
if !validBytes(sourceItem.Title, 2048) ||
!validBytes(sourceItem.ProductSpec, 1024) ||
!validBytes(sourceItem.SKU, 512) ||
!validOptional(sourceItem.ProductThumbRef, 512) ||
!validOptional(sourceItem.PurchaseStatus, 128) ||
(sourceItem.Quantity != nil && *sourceItem.Quantity <= 0) {
return domain.FreightImportBatch{}, errors.New("invalid freight item fields")
}
itemID, err := service.ids.NewID()
if err != nil {
return domain.FreightImportBatch{}, err
}
item := domain.FreightImportItem{
ID: itemID,
ExternalItemID: itemExternalID,
Title: sourceItem.Title,
ProductSpec: sourceItem.ProductSpec,
SKU: sourceItem.SKU,
Quantity: sourceItem.Quantity,
ProductThumbRef: cleanOptional(sourceItem.ProductThumbRef),
PurchaseStatus: cleanOptional(sourceItem.PurchaseStatus),
}
item.CanonicalSHA256 = hashJSON(struct {
ExternalItemID string `json:"external_item_id"`
Title string `json:"title"`
ProductSpec string `json:"product_spec"`
SKU string `json:"sku"`
Quantity *int `json:"quantity"`
ProductThumbRef *string `json:"product_thumb_ref"`
PurchaseStatus *string `json:"purchase_status"`
}{
item.ExternalItemID, item.Title, item.ProductSpec, item.SKU,
item.Quantity, item.ProductThumbRef, item.PurchaseStatus,
})
order.Items = append(order.Items, item)
}
sort.Slice(order.Items, func(i, j int) bool {
left, _ := strconv.ParseUint(order.Items[i].ExternalItemID, 10, 64)
right, _ := strconv.ParseUint(order.Items[j].ExternalItemID, 10, 64)
return left < right
})
type canonicalItem struct {
ExternalItemID string `json:"external_item_id"`
CanonicalSHA256 string `json:"canonical_sha256"`
}
canonicalItems := make([]canonicalItem, 0, len(order.Items))
for _, item := range order.Items {
canonicalItems = append(canonicalItems, canonicalItem{
ExternalItemID: item.ExternalItemID,
CanonicalSHA256: item.CanonicalSHA256,
})
}
order.CanonicalSHA256 = hashJSON(struct {
ExternalStockID string `json:"external_stock_id"`
SourceCode string `json:"source_code"`
PlatformOrderNo *string `json:"platform_order_no"`
ShopName *string `json:"shop_name"`
SourceCreatedAt *time.Time `json:"source_created_at"`
OrderStatus *string `json:"order_status"`
PurchaseStatus *string `json:"purchase_status"`
IsCanceled *bool `json:"is_canceled"`
Items []canonicalItem `json:"items"`
}{
order.ExternalStockID, order.SourceCode, order.PlatformOrderNo,
order.ShopName, order.SourceCreatedAt, order.OrderStatus,
order.PurchaseStatus, order.IsCanceled, canonicalItems,
})
result.Orders = append(result.Orders, order)
}
sort.Slice(result.Orders, func(i, j int) bool {
left, _ := strconv.ParseUint(result.Orders[i].ExternalStockID, 10, 64)
right, _ := strconv.ParseUint(result.Orders[j].ExternalStockID, 10, 64)
return left < right
})
return result, nil
}
func freightSourceErrorCode(err error) string {
switch {
case errors.Is(err, erpconnector.ErrNotConfigured):
return "ERP_CONNECTOR_NOT_CONFIGURED"
case errors.Is(err, erpconnector.ErrSessionRequired):
return "ERP_SESSION_REQUIRED"
case errors.Is(err, erpconnector.ErrNotFound):
return "ERP_FREIGHT_NOT_FOUND"
case errors.Is(err, erpconnector.ErrProtocol):
return "ERP_RESPONSE_INVALID"
default:
return "ERP_CONNECTOR_UNAVAILABLE"
}
}
func validExternalID(value string) (string, bool) {
value = strings.TrimSpace(value)
number, err := strconv.ParseUint(value, 10, 64)
return value, err == nil && number > 0 && strconv.FormatUint(number, 10) == value
}
func validBytes(value string, maximum int) bool {
return utf8.ValidString(value) && len([]byte(value)) <= maximum &&
!hasControl(value)
}
func validOptional(value *string, maximum int) bool {
return value == nil || validBytes(strings.TrimSpace(*value), maximum)
}
func cleanOptional(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func hasControl(value string) bool {
for _, character := range value {
if character < 0x20 || character == 0x7f {
return true
}
}
return false
}
func parseERPTime(value *string) (*time.Time, error) {
value = cleanOptional(value)
if value == nil {
return nil, nil
}
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return nil, err
}
layouts := []string{
time.RFC3339Nano,
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
}
for _, layout := range layouts {
var parsed time.Time
if layout == time.RFC3339Nano {
parsed, err = time.Parse(layout, *value)
} else {
parsed, err = time.ParseInLocation(layout, *value, location)
}
if err == nil {
result := parsed.UTC()
return &result, nil
}
}
return nil, errors.New("invalid ERP source time")
}
func hashJSON(value any) string {
encoded, _ := json.Marshal(value)
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,88 @@
package usecase
import (
"testing"
"cmroubao/backend-api/internal/domain"
)
func TestFreightNormalizationHashExcludesInternalIDs(t *testing.T) {
source := validFreightSource()
firstService := &FreightService{ids: &sequenceIDs{next: 10}}
first, err := firstService.normalize(source)
if err != nil {
t.Fatalf("first normalize error = %v", err)
}
secondService := &FreightService{ids: &sequenceIDs{next: 100}}
second, err := secondService.normalize(source)
if err != nil {
t.Fatalf("second normalize error = %v", err)
}
if first.Orders[0].ID == second.Orders[0].ID {
t.Fatal("test IDs did not differ")
}
if first.Orders[0].CanonicalSHA256 !=
second.Orders[0].CanonicalSHA256 {
t.Fatalf(
"canonical hash depends on internal ID: %s != %s",
first.Orders[0].CanonicalSHA256,
second.Orders[0].CanonicalSHA256,
)
}
}
func TestFreightNormalizationRejectsConflictingIdentityAndInvalidTime(
t *testing.T,
) {
duplicate := validFreightSource()
duplicate.Orders[0].Items = append(
duplicate.Orders[0].Items,
duplicate.Orders[0].Items[0],
)
service := &FreightService{ids: &sequenceIDs{}}
if _, err := service.normalize(duplicate); err == nil {
t.Fatal("duplicate item normalize error = nil")
}
invalidTime := validFreightSource()
value := "not-a-time"
invalidTime.Orders[0].SourceCreatedAt = &value
if _, err := service.normalize(invalidTime); err == nil {
t.Fatal("invalid source time normalize error = nil")
}
}
func validFreightSource() domain.FreightSourceBatch {
shop := "测试店铺"
sourceCreatedAt := "2026-07-28 08:00:00"
orderStatus := "0"
purchaseStatus := "1"
thumb := "190"
itemPurchaseStatus := "0"
quantity := 2
canceled := false
return domain.FreightSourceBatch{
SchemaVersion: 1,
Query: domain.FreightSourceQuery{
Mode: domain.FreightSyncOrderNumber,
},
Orders: []domain.FreightSourceOrder{{
ExternalStockID: "12",
SourceCode: "SOURCE-12",
ShopName: &shop,
SourceCreatedAt: &sourceCreatedAt,
OrderStatus: &orderStatus,
PurchaseStatus: &purchaseStatus,
IsCanceled: &canceled,
Items: []domain.FreightSourceItem{{
ExternalItemID: "88",
Title: "商品",
ProductSpec: "黑色,L",
SKU: "BLACK-L",
Quantity: &quantity,
ProductThumbRef: &thumb,
PurchaseStatus: &itemPurchaseStatus,
}},
}},
}
}
@@ -0,0 +1,170 @@
-- +goose Up
CREATE TABLE erp_sync_runs (
id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36),
creator_subject TEXT NOT NULL,
created_by_user_id TEXT NOT NULL
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
mode TEXT NOT NULL CHECK (mode = 'ORDER_NUMBER'),
order_number TEXT NOT NULL
CHECK (
length(trim(order_number)) > 0
AND length(CAST(order_number AS BLOB)) <= 128
),
query_sha256 TEXT NOT NULL
CHECK (
length(query_sha256) = 64
AND query_sha256 NOT GLOB '*[^0-9a-f]*'
),
idempotency_key TEXT NOT NULL
CHECK (
length(trim(idempotency_key)) > 0
AND length(CAST(idempotency_key AS BLOB)) <= 128
),
request_sha256 TEXT NOT NULL
CHECK (
length(request_sha256) = 64
AND request_sha256 NOT GLOB '*[^0-9a-f]*'
),
status TEXT NOT NULL
CHECK (status IN ('PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED')),
error_code TEXT
CHECK (
error_code IS NULL
OR length(CAST(error_code AS BLOB)) <= 64
),
order_count INTEGER NOT NULL DEFAULT 0 CHECK (order_count >= 0),
item_count INTEGER NOT NULL DEFAULT 0 CHECK (item_count >= 0),
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
CHECK (
(status = 'PENDING'
AND started_at IS NULL
AND finished_at IS NULL
AND error_code IS NULL)
OR (status = 'RUNNING'
AND started_at IS NOT NULL
AND finished_at IS NULL
AND error_code IS NULL)
OR (status = 'SUCCEEDED'
AND started_at IS NOT NULL
AND finished_at IS NOT NULL
AND error_code IS NULL)
OR (status = 'FAILED'
AND finished_at IS NOT NULL
AND error_code IS NOT NULL)
),
UNIQUE (creator_subject, idempotency_key)
);
CREATE INDEX erp_sync_runs_creator_created_idx
ON erp_sync_runs (creator_subject, created_at DESC, id DESC);
CREATE TABLE freight_orders (
id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36),
creator_subject TEXT NOT NULL,
source_system TEXT NOT NULL CHECK (source_system = 'SHUNYUNBAO'),
external_stock_id TEXT NOT NULL
CHECK (length(trim(external_stock_id)) > 0),
source_code TEXT NOT NULL
CHECK (length(CAST(source_code AS BLOB)) <= 256),
platform_order_no TEXT
CHECK (
platform_order_no IS NULL
OR length(CAST(platform_order_no AS BLOB)) <= 256
),
shop_name TEXT
CHECK (
shop_name IS NULL
OR length(CAST(shop_name AS BLOB)) <= 512
),
source_created_at TEXT,
order_status TEXT
CHECK (
order_status IS NULL
OR length(CAST(order_status AS BLOB)) <= 128
),
purchase_status TEXT
CHECK (
purchase_status IS NULL
OR length(CAST(purchase_status AS BLOB)) <= 128
),
is_canceled INTEGER CHECK (is_canceled IS NULL OR is_canceled IN (0, 1)),
canonical_sha256 TEXT NOT NULL
CHECK (
length(canonical_sha256) = 64
AND canonical_sha256 NOT GLOB '*[^0-9a-f]*'
),
revision INTEGER NOT NULL CHECK (revision >= 1),
first_sync_run_id TEXT NOT NULL
REFERENCES erp_sync_runs(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
last_sync_run_id TEXT NOT NULL
REFERENCES erp_sync_runs(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (creator_subject, source_system, external_stock_id)
);
CREATE INDEX freight_orders_creator_updated_idx
ON freight_orders (creator_subject, updated_at DESC, id DESC);
CREATE TABLE freight_order_items (
id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36),
freight_order_id TEXT NOT NULL
REFERENCES freight_orders(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
external_item_id TEXT NOT NULL
CHECK (length(trim(external_item_id)) > 0),
title TEXT NOT NULL
CHECK (length(CAST(title AS BLOB)) <= 2048),
product_spec TEXT NOT NULL
CHECK (length(CAST(product_spec AS BLOB)) <= 1024),
sku TEXT NOT NULL
CHECK (length(CAST(sku AS BLOB)) <= 512),
quantity INTEGER CHECK (quantity IS NULL OR quantity > 0),
product_thumb_ref TEXT
CHECK (
product_thumb_ref IS NULL
OR length(CAST(product_thumb_ref AS BLOB)) <= 512
),
purchase_status TEXT
CHECK (
purchase_status IS NULL
OR length(CAST(purchase_status AS BLOB)) <= 128
),
canonical_sha256 TEXT NOT NULL
CHECK (
length(canonical_sha256) = 64
AND canonical_sha256 NOT GLOB '*[^0-9a-f]*'
),
revision INTEGER NOT NULL CHECK (revision >= 1),
is_present INTEGER NOT NULL CHECK (is_present IN (0, 1)),
first_sync_run_id TEXT NOT NULL
REFERENCES erp_sync_runs(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
last_sync_run_id TEXT NOT NULL
REFERENCES erp_sync_runs(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (freight_order_id, external_item_id)
);
CREATE INDEX freight_order_items_order_present_idx
ON freight_order_items (freight_order_id, is_present, id);
-- +goose Down
CREATE TEMP TABLE freight_v12_down_guard (
allowed INTEGER NOT NULL CHECK (allowed = 1)
);
INSERT INTO freight_v12_down_guard (allowed)
SELECT CASE
WHEN EXISTS (SELECT 1 FROM erp_sync_runs)
OR EXISTS (SELECT 1 FROM freight_orders)
OR EXISTS (SELECT 1 FROM freight_order_items)
THEN 0
ELSE 1
END;
DROP TABLE freight_v12_down_guard;
DROP TABLE freight_order_items;
DROP TABLE freight_orders;
DROP TABLE erp_sync_runs;
+1 -1
View File
@@ -21,7 +21,7 @@
| 数据访问 | 标准库 `database/sql` | MVP 已定 | 领域层通过仓储接口访问,避免先引入 ORM 和代码生成复杂度。 |
| 数据迁移 | 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
View File
@@ -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
View File
@@ -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
View File
@@ -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 个页面/视口组合,
无横向溢出或页头控件重叠。伪响应仅用于界面和链路验收。
+6
View File
@@ -7,6 +7,12 @@ set "LOCAL_TLS_DIR=%PROJECT_ROOT%.local\cmroubao-tls"
set "LOCAL_TLS_CERT=%LOCAL_TLS_DIR%\server.crt"
set "LOCAL_TLS_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"