feat(t222): add freight ingestion admin flow
This commit is contained in:
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user