feat(t240): cache freight item images
This commit is contained in:
@@ -30,6 +30,7 @@ type AdminServices struct {
|
||||
Results *usecase.ExecutionResultService
|
||||
Authorizations *usecase.OrderAuthorizationService
|
||||
Freight *usecase.FreightService
|
||||
FreightImages *usecase.FreightImageService
|
||||
Procurement *usecase.ProcurementService
|
||||
ERP *shunyunbao.SessionManager
|
||||
}
|
||||
@@ -75,6 +76,12 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
|
||||
routes.GET("/api/v1/freight-orders", handler.listFreightOrders)
|
||||
routes.GET("/api/v1/freight-orders/:id", handler.freightOrderDetail)
|
||||
}
|
||||
if services.FreightImages != nil {
|
||||
routes.GET(
|
||||
"/api/v1/freight-items/:id/image",
|
||||
handler.freightItemImage,
|
||||
)
|
||||
}
|
||||
if services.Procurement != nil {
|
||||
routes.POST(
|
||||
"/api/v1/freight-items/:id/procurement-request",
|
||||
@@ -92,6 +99,29 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *adminHandlers) freightItemImage(ctx *gin.Context) {
|
||||
result, err := h.services.FreightImages.OpenItemImage(
|
||||
ctx.Request.Context(),
|
||||
localAdminSubject,
|
||||
ctx.Param("id"),
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
defer result.Content.Close()
|
||||
ctx.Header("Cache-Control", "private, no-store")
|
||||
ctx.Header("Content-Type", result.Image.MediaType)
|
||||
ctx.Header(
|
||||
"Content-Length",
|
||||
strconv.FormatInt(result.Image.SizeBytes, 10),
|
||||
)
|
||||
ctx.Header("ETag", `"`+result.Image.SHA256+`"`)
|
||||
ctx.Header("X-Content-Type-Options", "nosniff")
|
||||
ctx.Status(http.StatusOK)
|
||||
_, _ = io.Copy(ctx.Writer, result.Content)
|
||||
}
|
||||
|
||||
func (h *adminHandlers) evidenceContent(ctx *gin.Context) {
|
||||
result, err := h.services.Results.OpenEvidence(
|
||||
ctx.Request.Context(),
|
||||
|
||||
@@ -479,6 +479,41 @@ func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
|
||||
detail.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("freight detail status/body = %d / %s", detail.Code, detail.Body)
|
||||
}
|
||||
var detailBody struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
ImageStatus string `json:"image_status"`
|
||||
ImageURL string `json:"image_url"`
|
||||
} `json:"items"`
|
||||
}
|
||||
decodeResponse(t, detail, &detailBody)
|
||||
if len(detailBody.Items) != 2 ||
|
||||
detailBody.Items[0].ImageStatus != "READY" ||
|
||||
detailBody.Items[0].ImageURL == "" {
|
||||
t.Fatalf("freight detail images = %+v", detailBody.Items)
|
||||
}
|
||||
imageResponse := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
detailBody.Items[0].ImageURL,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if imageResponse.Code != http.StatusOK ||
|
||||
imageResponse.Header().Get("Content-Type") != "image/jpeg" ||
|
||||
imageResponse.Header().Get("Cache-Control") != "private, no-store" ||
|
||||
imageResponse.Header().Get("X-Content-Type-Options") != "nosniff" ||
|
||||
imageResponse.Header().Get("ETag") == "" ||
|
||||
!bytes.HasPrefix(imageResponse.Body.Bytes(), []byte{0xff, 0xd8}) {
|
||||
t.Fatalf(
|
||||
"freight image status/headers/body = %d / %#v / %x",
|
||||
imageResponse.Code,
|
||||
imageResponse.Header(),
|
||||
imageResponse.Body.Bytes(),
|
||||
)
|
||||
}
|
||||
replay := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
@@ -875,6 +910,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("image migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("metadata migration down: %v", err)
|
||||
}
|
||||
@@ -1211,12 +1249,22 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewOrderAuthorizationService() error = %v", err)
|
||||
}
|
||||
freightImages, err := usecase.NewFreightImageService(
|
||||
repositories,
|
||||
staticFreightSource{},
|
||||
files,
|
||||
clock,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewFreightImageService() error = %v", err)
|
||||
}
|
||||
freight, err := usecase.NewFreightService(
|
||||
repositories,
|
||||
staticFreightSource{},
|
||||
clock,
|
||||
ids,
|
||||
time.Second,
|
||||
usecase.WithFreightImageCache(freightImages),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewFreightService() error = %v", err)
|
||||
@@ -1236,6 +1284,7 @@ func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
|
||||
Results: results,
|
||||
Authorizations: authorizations,
|
||||
Freight: freight,
|
||||
FreightImages: freightImages,
|
||||
Procurement: procurement,
|
||||
},
|
||||
emptyAdminWeb{},
|
||||
@@ -1270,6 +1319,8 @@ func (staticFreightSource) QueryOrder(
|
||||
quantityTwo := 2
|
||||
priceOne := int64(12950)
|
||||
priceTwo := int64(8800)
|
||||
thumbOne := "190"
|
||||
thumbTwo := "191"
|
||||
return domain.FreightSourceBatch{
|
||||
SchemaVersion: 1,
|
||||
Query: domain.FreightSourceQuery{
|
||||
@@ -1289,6 +1340,7 @@ func (staticFreightSource) QueryOrder(
|
||||
Quantity: &quantityOne,
|
||||
OriginalUnitPriceMinor: &priceOne,
|
||||
OriginalCurrency: domain.FreightCurrencyTWD,
|
||||
ProductThumbRef: &thumbOne,
|
||||
},
|
||||
{
|
||||
ExternalItemID: "89",
|
||||
@@ -1298,12 +1350,41 @@ func (staticFreightSource) QueryOrder(
|
||||
Quantity: &quantityTwo,
|
||||
OriginalUnitPriceMinor: &priceTwo,
|
||||
OriginalCurrency: domain.FreightCurrencyTWD,
|
||||
ProductThumbRef: &thumbTwo,
|
||||
},
|
||||
},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (staticFreightSource) FetchProductImage(
|
||||
context.Context,
|
||||
string,
|
||||
) (usecase.FreightSourceImage, error) {
|
||||
var imageBytes bytes.Buffer
|
||||
source := image.NewRGBA(image.Rect(0, 0, 8, 6))
|
||||
for y := 0; y < 6; y++ {
|
||||
for x := 0; x < 8; x++ {
|
||||
source.Set(
|
||||
x,
|
||||
y,
|
||||
color.RGBA{R: uint8(x * 20), G: 80, B: 160, A: 255},
|
||||
)
|
||||
}
|
||||
}
|
||||
if err := jpeg.Encode(
|
||||
&imageBytes,
|
||||
source,
|
||||
&jpeg.Options{Quality: 85},
|
||||
); err != nil {
|
||||
return usecase.FreightSourceImage{}, err
|
||||
}
|
||||
return usecase.FreightSourceImage{
|
||||
Content: io.NopCloser(bytes.NewReader(imageBytes.Bytes())),
|
||||
MediaType: "image/jpeg",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (staticFreightSource) QueryCreatedRange(
|
||||
_ context.Context,
|
||||
createdFrom, createdTo string,
|
||||
|
||||
@@ -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("image migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("metadata migration down: %v", err)
|
||||
}
|
||||
@@ -739,8 +742,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 != 7 {
|
||||
t.Fatalf("restored migrations = %d, want 7", applied)
|
||||
} else if applied != 8 {
|
||||
t.Fatalf("restored migrations = %d, want 8", applied)
|
||||
}
|
||||
|
||||
completePayload := fmt.Sprintf(
|
||||
@@ -1429,6 +1432,9 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("image migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("metadata migration down: %v", err)
|
||||
}
|
||||
|
||||
@@ -205,6 +205,10 @@ func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
|
||||
}
|
||||
items := make([]gin.H, 0, len(detail.Items))
|
||||
for _, item := range detail.Items {
|
||||
var imageURL any
|
||||
if item.ImageStatus == domain.FreightItemImageReady {
|
||||
imageURL = "/api/v1/freight-items/" + item.ID + "/image"
|
||||
}
|
||||
items = append(items, gin.H{
|
||||
"id": item.ID,
|
||||
"external_item_id": item.ExternalItemID,
|
||||
@@ -215,6 +219,9 @@ func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
|
||||
"product_thumb_ref": item.ProductThumbRef,
|
||||
"original_unit_price_minor": item.OriginalUnitPriceMinor,
|
||||
"original_currency": item.OriginalCurrency,
|
||||
"image_status": item.ImageStatus,
|
||||
"image_error_code": item.ImageErrorCode,
|
||||
"image_url": imageURL,
|
||||
"purchase_status": item.PurchaseStatus,
|
||||
"revision": item.Revision,
|
||||
"canonical_sha256": item.CanonicalSHA256,
|
||||
|
||||
Reference in New Issue
Block a user