feat(t240): cache freight item images
This commit is contained in:
@@ -255,12 +255,22 @@ func buildRouter(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
freightImages, err := usecase.NewFreightImageService(
|
||||
store,
|
||||
erpSession,
|
||||
files,
|
||||
clock,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
freight, err := usecase.NewFreightService(
|
||||
store,
|
||||
erpSession,
|
||||
clock,
|
||||
ids,
|
||||
90*time.Second,
|
||||
usecase.WithFreightImageCache(freightImages),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -354,6 +364,7 @@ func buildRouter(
|
||||
Results: results,
|
||||
Authorizations: authorizations,
|
||||
Freight: freight,
|
||||
FreightImages: freightImages,
|
||||
Procurement: procurement,
|
||||
},
|
||||
webHandler,
|
||||
|
||||
@@ -18,6 +18,16 @@ const (
|
||||
FreightSyncFailed FreightSyncStatus = "FAILED"
|
||||
)
|
||||
|
||||
type FreightItemImageStatus string
|
||||
|
||||
const (
|
||||
FreightItemImageNone FreightItemImageStatus = "NONE"
|
||||
FreightItemImagePending FreightItemImageStatus = "PENDING"
|
||||
FreightItemImageReady FreightItemImageStatus = "READY"
|
||||
FreightItemImageMissing FreightItemImageStatus = "MISSING"
|
||||
FreightItemImageFailed FreightItemImageStatus = "FAILED"
|
||||
)
|
||||
|
||||
type FreightSyncRun struct {
|
||||
ID string
|
||||
CreatorSubject string
|
||||
@@ -69,6 +79,8 @@ type FreightOrderItem struct {
|
||||
ProductThumbRef *string
|
||||
OriginalUnitPriceMinor *int64
|
||||
OriginalCurrency string
|
||||
ImageStatus FreightItemImageStatus
|
||||
ImageErrorCode *string
|
||||
PurchaseStatus *string
|
||||
CanonicalSHA256 string
|
||||
Revision int
|
||||
@@ -79,6 +91,26 @@ type FreightOrderItem struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type FreightItemImageJob struct {
|
||||
CreatorSubject string
|
||||
FreightOrderItemID string
|
||||
ProductThumbRef string
|
||||
}
|
||||
|
||||
type FreightItemImage struct {
|
||||
CreatorSubject string
|
||||
FreightOrderItemID string
|
||||
ProductThumbRef string
|
||||
Status FreightItemImageStatus
|
||||
MediaType string
|
||||
SizeBytes int64
|
||||
SHA256 string
|
||||
StorageKey string
|
||||
ErrorCode *string
|
||||
AttemptCount int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type FreightOrderDetail struct {
|
||||
Order FreightOrder
|
||||
Items []FreightOrderItem
|
||||
|
||||
@@ -12,4 +12,6 @@ var (
|
||||
ErrFreightSourceProtocol = errors.New("freight source protocol is invalid")
|
||||
ErrFreightSourceOCRInvalid = errors.New("freight source OCR service is invalid")
|
||||
ErrFreightSourceLoginRejected = errors.New("freight source login was rejected")
|
||||
ErrFreightImageNotFound = errors.New("freight source image was not found")
|
||||
ErrFreightImageInvalid = errors.New("freight source image is invalid")
|
||||
)
|
||||
|
||||
@@ -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 != 15 {
|
||||
t.Fatalf("initial Up() applied = %d, want 15", applied)
|
||||
} else if applied != 16 {
|
||||
t.Fatalf("initial Up() applied = %d, want 16", applied)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v16) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v15) error = %v", err)
|
||||
@@ -74,9 +77,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
seedClaimsHistoricalFixture(t, db)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("Up(v5-v15) over historical data error = %v", err)
|
||||
} else if applied != 11 {
|
||||
t.Fatalf("Up(v5-v15) applied = %d, want 11", applied)
|
||||
t.Fatalf("Up(v5-v16) over historical data error = %v", err)
|
||||
} else if applied != 12 {
|
||||
t.Fatalf("Up(v5-v16) applied = %d, want 12", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v16) with compatible history error = %v", err)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
@@ -141,9 +149,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
assertClaimsHistory(t, db, false)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("final Up(v4-v15) error = %v", err)
|
||||
} else if applied != 12 {
|
||||
t.Fatalf("final Up(v4-v15) applied = %d, want 12", applied)
|
||||
t.Fatalf("final Up(v4-v16) error = %v", err)
|
||||
} else if applied != 13 {
|
||||
t.Fatalf("final Up(v4-v16) applied = %d, want 13", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
}
|
||||
@@ -379,6 +387,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
|
||||
t.Fatalf("insert v4 audit event: %v", err)
|
||||
}
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v16) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v15) error = %v", err)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Up() error = %v", err)
|
||||
}
|
||||
if applied != 15 {
|
||||
t.Fatalf("Up() applied = %d, want 15", applied)
|
||||
if applied != 16 {
|
||||
t.Fatalf("Up() applied = %d, want 16", applied)
|
||||
}
|
||||
assertStatuses(t, runner, map[int64]bool{
|
||||
1: true,
|
||||
@@ -46,6 +46,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
13: true,
|
||||
14: true,
|
||||
15: true,
|
||||
16: true,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -74,7 +75,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
12: true,
|
||||
13: true,
|
||||
14: true,
|
||||
15: false,
|
||||
15: true,
|
||||
16: false,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -100,6 +102,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
13: true,
|
||||
14: true,
|
||||
15: true,
|
||||
16: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package shunyunbao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
const (
|
||||
ProductImagePath = "/api/p/file"
|
||||
maxProductImageBytes = 20 << 20
|
||||
)
|
||||
|
||||
func (manager *SessionManager) FetchProductImage(
|
||||
ctx context.Context,
|
||||
productThumbRef string,
|
||||
) (usecase.FreightSourceImage, error) {
|
||||
productThumbRef = strings.TrimSpace(productThumbRef)
|
||||
value, err := strconv.ParseUint(productThumbRef, 10, 64)
|
||||
if err != nil || value == 0 ||
|
||||
strconv.FormatUint(value, 10) != productThumbRef {
|
||||
return usecase.FreightSourceImage{}, domain.ErrFreightImageInvalid
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
if !manager.configuredLocked() {
|
||||
manager.mu.Unlock()
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightSourceNotConfigured
|
||||
}
|
||||
if !manager.authenticated {
|
||||
manager.mu.Unlock()
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightSourceSessionNeeded
|
||||
}
|
||||
baseURL := manager.baseURL
|
||||
headers := manager.headers.Clone()
|
||||
client := manager.http
|
||||
manager.mu.Unlock()
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("id", productThumbRef)
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
baseURL+ProductImagePath+"?"+query.Encode(),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightSourceUnavailable
|
||||
}
|
||||
for name, values := range headers {
|
||||
request.Header[name] = append([]string(nil), values...)
|
||||
}
|
||||
request.Header.Set("Accept", "image/jpeg, image/png, image/webp")
|
||||
manager.logERPRequest(request)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
manager.logERPTransportFailure(request)
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightSourceUnavailable
|
||||
}
|
||||
if response.StatusCode < http.StatusOK ||
|
||||
response.StatusCode >= http.StatusMultipleChoices {
|
||||
defer response.Body.Close()
|
||||
manager.logERPBinaryResponse(request, response)
|
||||
switch response.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
manager.mu.Lock()
|
||||
manager.clearAuthenticatedLocked()
|
||||
manager.mu.Unlock()
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightSourceSessionNeeded
|
||||
case http.StatusNotFound:
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightImageNotFound
|
||||
default:
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightSourceUnavailable
|
||||
}
|
||||
}
|
||||
mediaType, _, err := mime.ParseMediaType(
|
||||
response.Header.Get("Content-Type"),
|
||||
)
|
||||
if err != nil || !supportedProductImageType(mediaType) ||
|
||||
response.ContentLength > maxProductImageBytes {
|
||||
response.Body.Close()
|
||||
manager.logERPBinaryResponse(request, response)
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightImageInvalid
|
||||
}
|
||||
manager.logERPBinaryResponse(request, response)
|
||||
return usecase.FreightSourceImage{
|
||||
Content: response.Body,
|
||||
MediaType: mediaType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func supportedProductImageType(value string) bool {
|
||||
switch strings.ToLower(value) {
|
||||
case "image/jpeg", "image/png", "image/webp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *SessionManager) logERPBinaryResponse(
|
||||
request *http.Request,
|
||||
response *http.Response,
|
||||
) {
|
||||
if !manager.diagnosticsOn {
|
||||
return
|
||||
}
|
||||
byteCount := "unknown"
|
||||
if response.ContentLength >= 0 {
|
||||
byteCount = strconv.FormatInt(response.ContentLength, 10)
|
||||
}
|
||||
manager.diagnosticLog(
|
||||
"erp_response method=" + request.Method +
|
||||
" path=" + request.URL.EscapedPath() +
|
||||
" status=" + strconv.Itoa(response.StatusCode) +
|
||||
" content_type=" +
|
||||
diagnosticContentType(response.Header.Get("Content-Type")) +
|
||||
" bytes=" + byteCount + " body=omitted_binary",
|
||||
)
|
||||
}
|
||||
|
||||
var _ usecase.FreightImageSource = (*SessionManager)(nil)
|
||||
@@ -0,0 +1,190 @@
|
||||
package shunyunbao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
func TestSessionManagerFetchProductImageUsesFixedAuthenticatedEndpoint(
|
||||
t *testing.T,
|
||||
) {
|
||||
var requestPath, rawQuery, accept string
|
||||
manager, closeServer := imageSessionManager(
|
||||
t,
|
||||
func(writer http.ResponseWriter, request *http.Request) {
|
||||
requestPath = request.URL.Path
|
||||
rawQuery = request.URL.RawQuery
|
||||
accept = request.Header.Get("Accept")
|
||||
if cookie, err := request.Cookie("authenticated"); err != nil ||
|
||||
cookie.Value != "yes" {
|
||||
t.Fatalf("image cookie = %v, %v", cookie, err)
|
||||
}
|
||||
writer.Header().Set("Content-Type", "image/png")
|
||||
writer.Header().Set("Content-Length", "4")
|
||||
_, _ = writer.Write([]byte("png!"))
|
||||
},
|
||||
)
|
||||
defer closeServer()
|
||||
|
||||
image, err := manager.FetchProductImage(context.Background(), "190")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchProductImage() error = %v", err)
|
||||
}
|
||||
defer image.Content.Close()
|
||||
content, err := io.ReadAll(image.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("read image: %v", err)
|
||||
}
|
||||
if requestPath != ProductImagePath || rawQuery != "id=190" ||
|
||||
image.MediaType != "image/png" || string(content) != "png!" ||
|
||||
!strings.Contains(accept, "image/jpeg") {
|
||||
t.Fatalf(
|
||||
"image request/result = %q / %q / %q / %q / %q",
|
||||
requestPath,
|
||||
rawQuery,
|
||||
accept,
|
||||
image.MediaType,
|
||||
content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerFetchProductImageClassifiesFailures(t *testing.T) {
|
||||
manager, closeServer := imageSessionManager(
|
||||
t,
|
||||
func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Query().Get("id") {
|
||||
case "190":
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
case "191":
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = writer.Write([]byte(`{"status":false}`))
|
||||
case "192":
|
||||
writer.Header().Set("Location", ProductImagePath+"?id=190")
|
||||
writer.WriteHeader(http.StatusFound)
|
||||
case "193":
|
||||
writer.Header().Set(
|
||||
"Content-Type",
|
||||
"image/jpeg",
|
||||
)
|
||||
writer.Header().Set(
|
||||
"Content-Length",
|
||||
"20971521",
|
||||
)
|
||||
default:
|
||||
writer.WriteHeader(http.StatusUnauthorized)
|
||||
}
|
||||
},
|
||||
)
|
||||
defer closeServer()
|
||||
|
||||
tests := []struct {
|
||||
ref string
|
||||
want error
|
||||
}{
|
||||
{"190", domain.ErrFreightImageNotFound},
|
||||
{"191", domain.ErrFreightImageInvalid},
|
||||
{"192", domain.ErrFreightSourceUnavailable},
|
||||
{"193", domain.ErrFreightImageInvalid},
|
||||
{"194", domain.ErrFreightSourceSessionNeeded},
|
||||
}
|
||||
for _, test := range tests {
|
||||
image, err := manager.FetchProductImage(
|
||||
context.Background(),
|
||||
test.ref,
|
||||
)
|
||||
if image.Content != nil {
|
||||
image.Content.Close()
|
||||
}
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("FetchProductImage(%q) error = %v", test.ref, err)
|
||||
}
|
||||
}
|
||||
if _, err := manager.FetchProductImage(
|
||||
context.Background(),
|
||||
"195",
|
||||
); !errors.Is(err, domain.ErrFreightSourceSessionNeeded) {
|
||||
t.Fatalf("request after unauthorized error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerFetchProductImageRejectsUntrustedReferences(
|
||||
t *testing.T,
|
||||
) {
|
||||
manager := testSessionManager(t, "http://127.0.0.1:1", "user", "password")
|
||||
for _, value := range []string{
|
||||
"",
|
||||
"0",
|
||||
"0190",
|
||||
"-1",
|
||||
"1.5",
|
||||
"https://invalid.example/image",
|
||||
} {
|
||||
if _, err := manager.FetchProductImage(
|
||||
context.Background(),
|
||||
value,
|
||||
); !errors.Is(err, domain.ErrFreightImageInvalid) {
|
||||
t.Fatalf("FetchProductImage(%q) error = %v", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func imageSessionManager(
|
||||
t *testing.T,
|
||||
imageHandler http.HandlerFunc,
|
||||
) (*SessionManager, func()) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
switch request.URL.Path {
|
||||
case CaptchaPath:
|
||||
http.SetCookie(
|
||||
writer,
|
||||
&http.Cookie{Name: "captcha", Value: "ready", Path: "/"},
|
||||
)
|
||||
writer.Header().Set("Content-Type", "image/png")
|
||||
_, _ = writer.Write([]byte("captcha"))
|
||||
case LoginPath:
|
||||
http.SetCookie(
|
||||
writer,
|
||||
&http.Cookie{
|
||||
Name: "authenticated",
|
||||
Value: "yes",
|
||||
Path: "/",
|
||||
},
|
||||
)
|
||||
_, _ = writer.Write(
|
||||
[]byte(
|
||||
`{"status":true,"data":{"user":{"id":1,"username":"test-user"}}}`,
|
||||
),
|
||||
)
|
||||
case UserPath:
|
||||
_, _ = writer.Write(
|
||||
[]byte(
|
||||
`{"status":true,"data":{"id":1,"username":"test-user"}}`,
|
||||
),
|
||||
)
|
||||
case ProductImagePath:
|
||||
imageHandler(writer, request)
|
||||
default:
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
manager := testSessionManager(
|
||||
t,
|
||||
server.URL,
|
||||
"test-user",
|
||||
"test-password",
|
||||
)
|
||||
loginForSource(t, manager)
|
||||
return manager, server.Close
|
||||
}
|
||||
@@ -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(v16) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("Down(v15) error = %v", err)
|
||||
}
|
||||
@@ -435,9 +438,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-v15) error = %v", err)
|
||||
} else if applied != 13 {
|
||||
t.Fatalf("Up(v3-v15) applied = %d, want 13", applied)
|
||||
t.Fatalf("Up(v3-v16) error = %v", err)
|
||||
} else if applied != 14 {
|
||||
t.Fatalf("Up(v3-v16) applied = %d, want 14", applied)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
func (store *Store) ListFreightImageJobs(
|
||||
ctx context.Context,
|
||||
creatorSubject, runID string,
|
||||
limit int,
|
||||
) ([]domain.FreightItemImageJob, error) {
|
||||
rows, err := store.db.QueryContext(
|
||||
ctx,
|
||||
`SELECT item.id, item.product_thumb_ref
|
||||
FROM freight_order_items AS item
|
||||
JOIN freight_orders AS freight
|
||||
ON freight.id = item.freight_order_id
|
||||
LEFT JOIN freight_item_images AS image
|
||||
ON image.freight_order_item_id = item.id
|
||||
WHERE freight.creator_subject = ?
|
||||
AND item.last_sync_run_id = ?
|
||||
AND item.is_present = 1
|
||||
AND item.product_thumb_ref IS NOT NULL
|
||||
AND (
|
||||
image.freight_order_item_id IS NULL
|
||||
OR image.product_thumb_ref != item.product_thumb_ref
|
||||
OR image.status != 'READY'
|
||||
)
|
||||
ORDER BY item.id
|
||||
LIMIT ?`,
|
||||
creatorSubject,
|
||||
runID,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
jobs := make([]domain.FreightItemImageJob, 0)
|
||||
for rows.Next() {
|
||||
var job domain.FreightItemImageJob
|
||||
job.CreatorSubject = creatorSubject
|
||||
if err := rows.Scan(
|
||||
&job.FreightOrderItemID,
|
||||
&job.ProductThumbRef,
|
||||
); err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
func (store *Store) SaveFreightItemImage(
|
||||
ctx context.Context,
|
||||
candidate domain.FreightItemImage,
|
||||
) (*string, error) {
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var currentThumb sql.NullString
|
||||
err = tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT item.product_thumb_ref
|
||||
FROM freight_order_items AS item
|
||||
JOIN freight_orders AS freight
|
||||
ON freight.id = item.freight_order_id
|
||||
WHERE freight.creator_subject = ?
|
||||
AND item.id = ?
|
||||
AND item.is_present = 1`,
|
||||
candidate.CreatorSubject,
|
||||
candidate.FreightOrderItemID,
|
||||
).Scan(¤tThumb)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, usecase.ErrRepositoryNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if !currentThumb.Valid ||
|
||||
currentThumb.String != candidate.ProductThumbRef {
|
||||
return nil, usecase.ErrTaskStateConflict
|
||||
}
|
||||
|
||||
var existingThumb string
|
||||
var existingStorage sql.NullString
|
||||
var existingAttempts int
|
||||
err = tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT product_thumb_ref, storage_key, attempt_count
|
||||
FROM freight_item_images
|
||||
WHERE freight_order_item_id = ?`,
|
||||
candidate.FreightOrderItemID,
|
||||
).Scan(&existingThumb, &existingStorage, &existingAttempts)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
existingAttempts = 0
|
||||
case err != nil:
|
||||
return nil, repositoryFailure(err)
|
||||
case existingThumb != candidate.ProductThumbRef:
|
||||
existingAttempts = 0
|
||||
}
|
||||
attemptCount := existingAttempts + 1
|
||||
_, err = tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO freight_item_images (
|
||||
freight_order_item_id, product_thumb_ref, status, media_type,
|
||||
size_bytes, sha256, storage_key, error_code, attempt_count,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (freight_order_item_id)
|
||||
DO UPDATE SET
|
||||
product_thumb_ref = excluded.product_thumb_ref,
|
||||
status = excluded.status,
|
||||
media_type = excluded.media_type,
|
||||
size_bytes = excluded.size_bytes,
|
||||
sha256 = excluded.sha256,
|
||||
storage_key = excluded.storage_key,
|
||||
error_code = excluded.error_code,
|
||||
attempt_count = excluded.attempt_count,
|
||||
updated_at = excluded.updated_at`,
|
||||
candidate.FreightOrderItemID,
|
||||
candidate.ProductThumbRef,
|
||||
candidate.Status,
|
||||
nullableFreightImageText(candidate.MediaType),
|
||||
nullableFreightImageSize(candidate),
|
||||
nullableFreightImageText(candidate.SHA256),
|
||||
nullableFreightImageText(candidate.StorageKey),
|
||||
nullableString(candidate.ErrorCode),
|
||||
attemptCount,
|
||||
formatTimestamp(candidate.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if existingStorage.Valid &&
|
||||
existingStorage.String != candidate.StorageKey {
|
||||
return &existingStorage.String, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (store *Store) GetReadyFreightItemImage(
|
||||
ctx context.Context,
|
||||
creatorSubject, itemID string,
|
||||
) (domain.FreightItemImage, error) {
|
||||
var image domain.FreightItemImage
|
||||
var updatedAt string
|
||||
err := store.db.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT freight.creator_subject, image.freight_order_item_id,
|
||||
image.product_thumb_ref, image.status, image.media_type,
|
||||
image.size_bytes, image.sha256, image.storage_key,
|
||||
image.attempt_count, image.updated_at
|
||||
FROM freight_item_images AS image
|
||||
JOIN freight_order_items AS item
|
||||
ON item.id = image.freight_order_item_id
|
||||
JOIN freight_orders AS freight
|
||||
ON freight.id = item.freight_order_id
|
||||
WHERE freight.creator_subject = ?
|
||||
AND item.id = ?
|
||||
AND item.is_present = 1
|
||||
AND item.product_thumb_ref = image.product_thumb_ref
|
||||
AND image.status = 'READY'`,
|
||||
creatorSubject,
|
||||
itemID,
|
||||
).Scan(
|
||||
&image.CreatorSubject,
|
||||
&image.FreightOrderItemID,
|
||||
&image.ProductThumbRef,
|
||||
&image.Status,
|
||||
&image.MediaType,
|
||||
&image.SizeBytes,
|
||||
&image.SHA256,
|
||||
&image.StorageKey,
|
||||
&image.AttemptCount,
|
||||
&updatedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.FreightItemImage{}, usecase.ErrRepositoryNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.FreightItemImage{}, repositoryFailure(err)
|
||||
}
|
||||
image.UpdatedAt, err = parseTimestamp(updatedAt)
|
||||
if err != nil {
|
||||
return domain.FreightItemImage{}, repositoryFailure(err)
|
||||
}
|
||||
return image, nil
|
||||
}
|
||||
|
||||
func (store *Store) applyFreightItemImageStatuses(
|
||||
ctx context.Context,
|
||||
orderID string,
|
||||
items []domain.FreightOrderItem,
|
||||
) error {
|
||||
byID := make(map[string]int, len(items))
|
||||
for index := range items {
|
||||
byID[items[index].ID] = index
|
||||
if items[index].ProductThumbRef == nil {
|
||||
items[index].ImageStatus = domain.FreightItemImageNone
|
||||
} else {
|
||||
items[index].ImageStatus = domain.FreightItemImagePending
|
||||
}
|
||||
}
|
||||
rows, err := store.db.QueryContext(
|
||||
ctx,
|
||||
`SELECT item.id, image.product_thumb_ref, image.status,
|
||||
image.error_code
|
||||
FROM freight_order_items AS item
|
||||
JOIN freight_item_images AS image
|
||||
ON image.freight_order_item_id = item.id
|
||||
WHERE item.freight_order_id = ?
|
||||
AND item.is_present = 1`,
|
||||
orderID,
|
||||
)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var itemID, imageThumb string
|
||||
var status domain.FreightItemImageStatus
|
||||
var errorCode sql.NullString
|
||||
if err := rows.Scan(
|
||||
&itemID,
|
||||
&imageThumb,
|
||||
&status,
|
||||
&errorCode,
|
||||
); err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
index, exists := byID[itemID]
|
||||
if !exists || items[index].ProductThumbRef == nil ||
|
||||
*items[index].ProductThumbRef != imageThumb {
|
||||
continue
|
||||
}
|
||||
items[index].ImageStatus = status
|
||||
items[index].ImageErrorCode = optionalString(errorCode)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullableFreightImageText(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func nullableFreightImageSize(image domain.FreightItemImage) any {
|
||||
if image.Status != domain.FreightItemImageReady {
|
||||
return nil
|
||||
}
|
||||
return image.SizeBytes
|
||||
}
|
||||
|
||||
var _ usecase.FreightImageRepository = (*Store)(nil)
|
||||
@@ -0,0 +1,196 @@
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/platform/migration"
|
||||
repository "cmroubao/backend-api/internal/repository/sqlite"
|
||||
)
|
||||
|
||||
func TestFreightItemImagesAreCurrentRetryableAndRollbackGuarded(
|
||||
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, 29, 3, 0, 0, 0, time.UTC)
|
||||
userID := uuid(1000)
|
||||
seedFreightUser(t, db, userID, now)
|
||||
|
||||
first := freightRun(1001, userID, now)
|
||||
createAndStartFreightRun(t, store, first, "image-run-1", "1")
|
||||
batch := freightBatch(1010, "a", "b")
|
||||
firstThumb := "190"
|
||||
secondThumb := "191"
|
||||
batch.Orders[0].Items[0].ProductThumbRef = &firstThumb
|
||||
batch.Orders[0].Items[1].ProductThumbRef = &secondThumb
|
||||
if err := store.CompleteFreightSync(
|
||||
ctx,
|
||||
first,
|
||||
batch,
|
||||
now.Add(time.Second),
|
||||
); err != nil {
|
||||
t.Fatalf("CompleteFreightSync() error = %v", err)
|
||||
}
|
||||
jobs, err := store.ListFreightImageJobs(
|
||||
ctx,
|
||||
"local-admin",
|
||||
first.ID,
|
||||
10,
|
||||
)
|
||||
if err != nil || len(jobs) != 2 {
|
||||
t.Fatalf("initial image jobs = %+v, %v", jobs, err)
|
||||
}
|
||||
byThumb := make(map[string]domain.FreightItemImageJob, len(jobs))
|
||||
for _, job := range jobs {
|
||||
byThumb[job.ProductThumbRef] = job
|
||||
}
|
||||
ready := domain.FreightItemImage{
|
||||
CreatorSubject: "local-admin",
|
||||
FreightOrderItemID: byThumb[firstThumb].FreightOrderItemID,
|
||||
ProductThumbRef: firstThumb,
|
||||
Status: domain.FreightItemImageReady,
|
||||
MediaType: "image/jpeg",
|
||||
SizeBytes: 100,
|
||||
SHA256: repeatHex("c"),
|
||||
StorageKey: "aa/first.jpg",
|
||||
UpdatedAt: now.Add(2 * time.Second),
|
||||
}
|
||||
replaced, err := store.SaveFreightItemImage(ctx, ready)
|
||||
if err != nil || replaced != nil {
|
||||
t.Fatalf("save ready image = %v, %v", replaced, err)
|
||||
}
|
||||
errorCode := "ERP_IMAGE_NOT_FOUND"
|
||||
failed := domain.FreightItemImage{
|
||||
CreatorSubject: "local-admin",
|
||||
FreightOrderItemID: byThumb[secondThumb].FreightOrderItemID,
|
||||
ProductThumbRef: secondThumb,
|
||||
Status: domain.FreightItemImageMissing,
|
||||
ErrorCode: &errorCode,
|
||||
UpdatedAt: now.Add(2 * time.Second),
|
||||
}
|
||||
if _, err := store.SaveFreightItemImage(ctx, failed); err != nil {
|
||||
t.Fatalf("save missing image: %v", err)
|
||||
}
|
||||
|
||||
jobs, err = store.ListFreightImageJobs(
|
||||
ctx,
|
||||
"local-admin",
|
||||
first.ID,
|
||||
10,
|
||||
)
|
||||
if err != nil || len(jobs) != 1 ||
|
||||
jobs[0].ProductThumbRef != secondThumb {
|
||||
t.Fatalf("retry jobs = %+v, %v", jobs, err)
|
||||
}
|
||||
orders, _ := store.ListFreightOrders(ctx, "local-admin", 10)
|
||||
detail, err := store.GetFreightOrder(
|
||||
ctx,
|
||||
"local-admin",
|
||||
orders[0].ID,
|
||||
)
|
||||
if err != nil || len(detail.Items) != 2 {
|
||||
t.Fatalf("detail = %+v, %v", detail, err)
|
||||
}
|
||||
statuses := map[string]domain.FreightItemImageStatus{}
|
||||
for _, item := range detail.Items {
|
||||
statuses[*item.ProductThumbRef] = item.ImageStatus
|
||||
}
|
||||
if statuses[firstThumb] != domain.FreightItemImageReady ||
|
||||
statuses[secondThumb] != domain.FreightItemImageMissing {
|
||||
t.Fatalf("image statuses = %+v", statuses)
|
||||
}
|
||||
stored, err := store.GetReadyFreightItemImage(
|
||||
ctx,
|
||||
"local-admin",
|
||||
ready.FreightOrderItemID,
|
||||
)
|
||||
if err != nil || stored.StorageKey != ready.StorageKey ||
|
||||
stored.AttemptCount != 1 {
|
||||
t.Fatalf("ready image = %+v, %v", stored, err)
|
||||
}
|
||||
if _, err := store.SaveFreightItemImage(
|
||||
ctx,
|
||||
failed,
|
||||
); err != nil {
|
||||
t.Fatalf("retry missing image: %v", err)
|
||||
}
|
||||
var attempts int
|
||||
if err := db.QueryRow(
|
||||
`SELECT attempt_count FROM freight_item_images
|
||||
WHERE freight_order_item_id = ?`,
|
||||
failed.FreightOrderItemID,
|
||||
).Scan(&attempts); err != nil || attempts != 2 {
|
||||
t.Fatalf("missing attempts = %d, %v", attempts, err)
|
||||
}
|
||||
|
||||
second := freightRun(1002, userID, now.Add(time.Minute))
|
||||
createAndStartFreightRun(t, store, second, "image-run-2", "2")
|
||||
changed := freightBatch(1020, "d", "b")
|
||||
changedThumb := "192"
|
||||
changed.Orders[0].Items[0].ProductThumbRef = &changedThumb
|
||||
changed.Orders[0].Items[1].ProductThumbRef = &secondThumb
|
||||
if err := store.CompleteFreightSync(
|
||||
ctx,
|
||||
second,
|
||||
changed,
|
||||
now.Add(time.Minute+time.Second),
|
||||
); err != nil {
|
||||
t.Fatalf("changed CompleteFreightSync() error = %v", err)
|
||||
}
|
||||
jobs, err = store.ListFreightImageJobs(
|
||||
ctx,
|
||||
"local-admin",
|
||||
second.ID,
|
||||
10,
|
||||
)
|
||||
if err != nil || len(jobs) != 2 {
|
||||
t.Fatalf("changed image jobs = %+v, %v", jobs, err)
|
||||
}
|
||||
var changedJob domain.FreightItemImageJob
|
||||
for _, job := range jobs {
|
||||
if job.ProductThumbRef == changedThumb {
|
||||
changedJob = job
|
||||
}
|
||||
}
|
||||
changedReady := ready
|
||||
changedReady.FreightOrderItemID = changedJob.FreightOrderItemID
|
||||
changedReady.ProductThumbRef = changedThumb
|
||||
changedReady.StorageKey = "bb/changed.jpg"
|
||||
changedReady.SHA256 = repeatHex("d")
|
||||
changedReady.UpdatedAt = now.Add(time.Minute + 2*time.Second)
|
||||
replaced, err = store.SaveFreightItemImage(ctx, changedReady)
|
||||
if err != nil || replaced == nil || *replaced != ready.StorageKey {
|
||||
t.Fatalf("replace ready image = %v, %v", replaced, err)
|
||||
}
|
||||
stored, err = store.GetReadyFreightItemImage(
|
||||
ctx,
|
||||
"local-admin",
|
||||
ready.FreightOrderItemID,
|
||||
)
|
||||
if err != nil || stored.ProductThumbRef != changedThumb ||
|
||||
stored.AttemptCount != 1 {
|
||||
t.Fatalf("changed ready image = %+v, %v", stored, err)
|
||||
}
|
||||
|
||||
runner, err := migration.New(db)
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err == nil {
|
||||
t.Fatal("image migration down succeeded with retained image metadata")
|
||||
}
|
||||
if _, err := store.GetReadyFreightItemImage(
|
||||
ctx,
|
||||
"local-admin",
|
||||
ready.FreightOrderItemID,
|
||||
); err != nil {
|
||||
t.Fatalf("failed rollback lost ready image: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -548,6 +548,13 @@ func (store *Store) GetFreightOrder(
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.FreightOrderDetail{}, repositoryFailure(err)
|
||||
}
|
||||
if err := store.applyFreightItemImageStatuses(
|
||||
ctx,
|
||||
order.ID,
|
||||
items,
|
||||
); err != nil {
|
||||
return domain.FreightOrderDetail{}, err
|
||||
}
|
||||
return domain.FreightOrderDetail{Order: order, Items: items}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,9 @@ func TestFreightImportIsAtomicIdempotentAndRevisioned(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("image migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("metadata migration down: %v", err)
|
||||
}
|
||||
@@ -268,6 +271,9 @@ func TestFreightDateSyncAdvancesWatermarkOnlyOnWholeBatchSuccess(
|
||||
}
|
||||
|
||||
runner, _ := migration.New(db)
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("image migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("metadata migration down: %v", err)
|
||||
}
|
||||
|
||||
@@ -238,6 +238,9 @@ func TestProcurementRequestsArePerItemAndTaskSnapshotIsImmutable(
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("image migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("metadata migration down: %v", err)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -159,6 +159,9 @@ type FreightOrderItem struct {
|
||||
ProductThumbRef string
|
||||
OriginalUnitPriceMinor *int64
|
||||
OriginalCurrency string
|
||||
ImageStatus string
|
||||
ImageErrorCode string
|
||||
ImageURL string
|
||||
PurchaseStatus string
|
||||
Revision int
|
||||
}
|
||||
|
||||
@@ -168,12 +168,17 @@ func (adapter *UsecaseAdapter) GetFreightOrder(
|
||||
ProductThumbRef: stringValue(item.ProductThumbRef),
|
||||
OriginalUnitPriceMinor: item.OriginalUnitPriceMinor,
|
||||
OriginalCurrency: item.OriginalCurrency,
|
||||
ImageStatus: string(item.ImageStatus),
|
||||
ImageErrorCode: stringValue(item.ImageErrorCode),
|
||||
PurchaseStatus: stringValue(item.PurchaseStatus),
|
||||
Revision: item.Revision,
|
||||
}
|
||||
if item.Quantity != nil {
|
||||
view.Quantity = *item.Quantity
|
||||
}
|
||||
if item.ImageStatus == domain.FreightItemImageReady {
|
||||
view.ImageURL = "/api/v1/freight-items/" + item.ID + "/image"
|
||||
}
|
||||
review := FreightItemReview{Item: view}
|
||||
if request, exists := requestByItem[item.ID]; exists {
|
||||
requestView := procurementRequestFrom(request)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
type FreightSourceImage struct {
|
||||
Content io.ReadCloser
|
||||
MediaType string
|
||||
}
|
||||
|
||||
type FreightImageSource interface {
|
||||
FetchProductImage(
|
||||
context.Context,
|
||||
string,
|
||||
) (FreightSourceImage, error)
|
||||
}
|
||||
|
||||
type FreightImageRepository interface {
|
||||
ListFreightImageJobs(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
int,
|
||||
) ([]domain.FreightItemImageJob, error)
|
||||
SaveFreightItemImage(
|
||||
context.Context,
|
||||
domain.FreightItemImage,
|
||||
) (*string, error)
|
||||
GetReadyFreightItemImage(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.FreightItemImage, error)
|
||||
}
|
||||
|
||||
type FreightImageCache interface {
|
||||
CacheRun(context.Context, string, string) error
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
maxFreightImageJobsPerRun = 1000
|
||||
freightImageWorkers = 4
|
||||
freightImageCleanup = 5 * time.Second
|
||||
)
|
||||
|
||||
type FreightImageService struct {
|
||||
repository FreightImageRepository
|
||||
source FreightImageSource
|
||||
store ReferenceImageStore
|
||||
clock Clock
|
||||
}
|
||||
|
||||
type FreightItemImageContent struct {
|
||||
Image domain.FreightItemImage
|
||||
Content io.ReadCloser
|
||||
}
|
||||
|
||||
func NewFreightImageService(
|
||||
repository FreightImageRepository,
|
||||
source FreightImageSource,
|
||||
store ReferenceImageStore,
|
||||
clock Clock,
|
||||
) (*FreightImageService, error) {
|
||||
if repository == nil || source == nil || store == nil || clock == nil {
|
||||
return nil, errors.New("freight image service dependencies are required")
|
||||
}
|
||||
return &FreightImageService{
|
||||
repository: repository,
|
||||
source: source,
|
||||
store: store,
|
||||
clock: clock,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *FreightImageService) CacheRun(
|
||||
ctx context.Context,
|
||||
creatorSubject, runID string,
|
||||
) error {
|
||||
jobs, err := service.repository.ListFreightImageJobs(
|
||||
ctx,
|
||||
creatorSubject,
|
||||
runID,
|
||||
maxFreightImageJobsPerRun,
|
||||
)
|
||||
if err != nil {
|
||||
return wrapRepositoryError(err)
|
||||
}
|
||||
if len(jobs) == 0 {
|
||||
return nil
|
||||
}
|
||||
workerCount := freightImageWorkers
|
||||
if len(jobs) < workerCount {
|
||||
workerCount = len(jobs)
|
||||
}
|
||||
queue := make(chan domain.FreightItemImageJob)
|
||||
var workers sync.WaitGroup
|
||||
workers.Add(workerCount)
|
||||
for range workerCount {
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
for job := range queue {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
service.cacheJob(ctx, job)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, job := range jobs {
|
||||
select {
|
||||
case queue <- job:
|
||||
case <-ctx.Done():
|
||||
close(queue)
|
||||
workers.Wait()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
close(queue)
|
||||
workers.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *FreightImageService) cacheJob(
|
||||
ctx context.Context,
|
||||
job domain.FreightItemImageJob,
|
||||
) {
|
||||
sourceImage, err := service.source.FetchProductImage(
|
||||
ctx,
|
||||
job.ProductThumbRef,
|
||||
)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
service.saveFailure(ctx, job, freightImageSourceFailure(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
defer sourceImage.Content.Close()
|
||||
normalized, err := service.store.Put(
|
||||
ctx,
|
||||
job.FreightOrderItemID,
|
||||
sourceImage.MediaType,
|
||||
sourceImage.Content,
|
||||
)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
service.saveFailure(ctx, job, freightImageStoreFailure(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
candidate := domain.FreightItemImage{
|
||||
CreatorSubject: job.CreatorSubject,
|
||||
FreightOrderItemID: job.FreightOrderItemID,
|
||||
ProductThumbRef: job.ProductThumbRef,
|
||||
Status: domain.FreightItemImageReady,
|
||||
MediaType: normalized.MediaType,
|
||||
SizeBytes: normalized.SizeBytes,
|
||||
SHA256: normalized.SHA256,
|
||||
StorageKey: normalized.StorageKey,
|
||||
UpdatedAt: service.clock.Now().UTC(),
|
||||
}
|
||||
replaced, err := service.repository.SaveFreightItemImage(ctx, candidate)
|
||||
if err != nil {
|
||||
service.deleteStoredImage(normalized.StorageKey)
|
||||
return
|
||||
}
|
||||
if replaced != nil {
|
||||
service.deleteStoredImage(*replaced)
|
||||
}
|
||||
}
|
||||
|
||||
func (service *FreightImageService) saveFailure(
|
||||
ctx context.Context,
|
||||
job domain.FreightItemImageJob,
|
||||
failure freightImageFailure,
|
||||
) {
|
||||
errorCode := failure.code
|
||||
replaced, err := service.repository.SaveFreightItemImage(
|
||||
ctx,
|
||||
domain.FreightItemImage{
|
||||
CreatorSubject: job.CreatorSubject,
|
||||
FreightOrderItemID: job.FreightOrderItemID,
|
||||
ProductThumbRef: job.ProductThumbRef,
|
||||
Status: failure.status,
|
||||
ErrorCode: &errorCode,
|
||||
UpdatedAt: service.clock.Now().UTC(),
|
||||
},
|
||||
)
|
||||
if err == nil && replaced != nil {
|
||||
service.deleteStoredImage(*replaced)
|
||||
}
|
||||
}
|
||||
|
||||
func (service *FreightImageService) deleteStoredImage(storageKey string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), freightImageCleanup)
|
||||
defer cancel()
|
||||
_ = service.store.Delete(ctx, storageKey)
|
||||
}
|
||||
|
||||
func (service *FreightImageService) OpenItemImage(
|
||||
ctx context.Context,
|
||||
creatorSubject, itemID string,
|
||||
) (FreightItemImageContent, error) {
|
||||
if creatorSubject == "" || !isUUID(itemID) {
|
||||
return FreightItemImageContent{}, freightImageNotFoundError(nil)
|
||||
}
|
||||
image, err := service.repository.GetReadyFreightItemImage(
|
||||
ctx,
|
||||
creatorSubject,
|
||||
itemID,
|
||||
)
|
||||
if err != nil {
|
||||
wrapped := wrapRepositoryError(err)
|
||||
var typed *Error
|
||||
if errors.As(wrapped, &typed) &&
|
||||
typed.Kind == ErrorKindNotFound {
|
||||
return FreightItemImageContent{},
|
||||
freightImageNotFoundError(err)
|
||||
}
|
||||
return FreightItemImageContent{}, wrapped
|
||||
}
|
||||
content, err := service.store.Open(ctx, image.StorageKey)
|
||||
if err != nil {
|
||||
var storeError *ImageStoreError
|
||||
if errors.As(err, &storeError) &&
|
||||
storeError.Kind == ImageStoreErrorNotFound {
|
||||
return FreightItemImageContent{},
|
||||
freightImageNotFoundError(err)
|
||||
}
|
||||
return FreightItemImageContent{}, mapImageStoreError(err)
|
||||
}
|
||||
return FreightItemImageContent{Image: image, Content: content}, nil
|
||||
}
|
||||
|
||||
type freightImageFailure struct {
|
||||
status domain.FreightItemImageStatus
|
||||
code string
|
||||
}
|
||||
|
||||
func freightImageSourceFailure(err error) freightImageFailure {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrFreightImageNotFound):
|
||||
return freightImageFailure{
|
||||
status: domain.FreightItemImageMissing,
|
||||
code: "ERP_IMAGE_NOT_FOUND",
|
||||
}
|
||||
case errors.Is(err, domain.ErrFreightImageInvalid):
|
||||
return freightImageFailure{
|
||||
status: domain.FreightItemImageFailed,
|
||||
code: "ERP_IMAGE_INVALID",
|
||||
}
|
||||
case errors.Is(err, domain.ErrFreightSourceSessionNeeded):
|
||||
return freightImageFailure{
|
||||
status: domain.FreightItemImageFailed,
|
||||
code: "ERP_SESSION_REQUIRED",
|
||||
}
|
||||
case errors.Is(err, domain.ErrFreightSourceNotConfigured):
|
||||
return freightImageFailure{
|
||||
status: domain.FreightItemImageFailed,
|
||||
code: "ERP_NOT_CONFIGURED",
|
||||
}
|
||||
default:
|
||||
return freightImageFailure{
|
||||
status: domain.FreightItemImageFailed,
|
||||
code: "ERP_IMAGE_UNAVAILABLE",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func freightImageStoreFailure(err error) freightImageFailure {
|
||||
var storeError *ImageStoreError
|
||||
if errors.As(err, &storeError) {
|
||||
switch storeError.Kind {
|
||||
case ImageStoreErrorTooLarge,
|
||||
ImageStoreErrorUnsupported,
|
||||
ImageStoreErrorInvalid:
|
||||
return freightImageFailure{
|
||||
status: domain.FreightItemImageFailed,
|
||||
code: "ERP_IMAGE_INVALID",
|
||||
}
|
||||
}
|
||||
}
|
||||
return freightImageFailure{
|
||||
status: domain.FreightItemImageFailed,
|
||||
code: "IMAGE_STORE_UNAVAILABLE",
|
||||
}
|
||||
}
|
||||
|
||||
func freightImageNotFoundError(cause error) error {
|
||||
return newError(
|
||||
ErrorKindNotFound,
|
||||
"FREIGHT_IMAGE_NOT_FOUND",
|
||||
"freight item image not found",
|
||||
cause,
|
||||
)
|
||||
}
|
||||
|
||||
var _ FreightImageCache = (*FreightImageService)(nil)
|
||||
@@ -0,0 +1,219 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
func TestFreightImageCacheIsBestEffortAndCleansReplacedFiles(t *testing.T) {
|
||||
repository := &freightImageRepositoryFake{
|
||||
jobs: []domain.FreightItemImageJob{
|
||||
{
|
||||
CreatorSubject: "local-admin",
|
||||
FreightOrderItemID: "00000000-0000-4000-8000-000000000001",
|
||||
ProductThumbRef: "190",
|
||||
},
|
||||
{
|
||||
CreatorSubject: "local-admin",
|
||||
FreightOrderItemID: "00000000-0000-4000-8000-000000000002",
|
||||
ProductThumbRef: "191",
|
||||
},
|
||||
},
|
||||
replaced: "old/image.jpg",
|
||||
}
|
||||
source := &freightImageSourceFake{}
|
||||
store := &freightImageStoreFake{}
|
||||
service, err := NewFreightImageService(
|
||||
repository,
|
||||
source,
|
||||
store,
|
||||
fakeClock{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFreightImageService() error = %v", err)
|
||||
}
|
||||
if err := service.CacheRun(
|
||||
context.Background(),
|
||||
"local-admin",
|
||||
"00000000-0000-4000-8000-000000000099",
|
||||
); err != nil {
|
||||
t.Fatalf("CacheRun() error = %v", err)
|
||||
}
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
if len(repository.saved) != 2 {
|
||||
t.Fatalf("saved images = %+v", repository.saved)
|
||||
}
|
||||
statusByRef := map[string]domain.FreightItemImage{}
|
||||
for _, image := range repository.saved {
|
||||
statusByRef[image.ProductThumbRef] = image
|
||||
}
|
||||
if statusByRef["190"].Status != domain.FreightItemImageReady ||
|
||||
statusByRef["190"].StorageKey != "new/image.jpg" ||
|
||||
statusByRef["191"].Status != domain.FreightItemImageMissing ||
|
||||
statusByRef["191"].ErrorCode == nil ||
|
||||
*statusByRef["191"].ErrorCode != "ERP_IMAGE_NOT_FOUND" {
|
||||
t.Fatalf("saved image statuses = %+v", statusByRef)
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
if len(store.deleted) != 1 || store.deleted[0] != "old/image.jpg" {
|
||||
t.Fatalf("deleted storage keys = %#v", store.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightImageOpenDoesNotRevealUnavailableItems(t *testing.T) {
|
||||
repository := &freightImageRepositoryFake{
|
||||
ready: domain.FreightItemImage{
|
||||
CreatorSubject: "local-admin",
|
||||
FreightOrderItemID: "00000000-0000-4000-8000-000000000001",
|
||||
Status: domain.FreightItemImageReady,
|
||||
StorageKey: "ready/image.jpg",
|
||||
},
|
||||
}
|
||||
store := &freightImageStoreFake{
|
||||
openContent: []byte("normalized-jpeg"),
|
||||
}
|
||||
service, err := NewFreightImageService(
|
||||
repository,
|
||||
&freightImageSourceFake{},
|
||||
store,
|
||||
fakeClock{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFreightImageService() error = %v", err)
|
||||
}
|
||||
result, err := service.OpenItemImage(
|
||||
context.Background(),
|
||||
"local-admin",
|
||||
repository.ready.FreightOrderItemID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenItemImage() error = %v", err)
|
||||
}
|
||||
defer result.Content.Close()
|
||||
content, _ := io.ReadAll(result.Content)
|
||||
if string(content) != "normalized-jpeg" {
|
||||
t.Fatalf("image content = %q", content)
|
||||
}
|
||||
if _, err := service.OpenItemImage(
|
||||
context.Background(),
|
||||
"another-subject",
|
||||
repository.ready.FreightOrderItemID,
|
||||
); err == nil {
|
||||
t.Fatal("cross-subject OpenItemImage() error = nil")
|
||||
} else {
|
||||
var typed *Error
|
||||
if !errors.As(err, &typed) ||
|
||||
typed.Code != "FREIGHT_IMAGE_NOT_FOUND" {
|
||||
t.Fatalf("cross-subject error = %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type freightImageRepositoryFake struct {
|
||||
mu sync.Mutex
|
||||
jobs []domain.FreightItemImageJob
|
||||
saved []domain.FreightItemImage
|
||||
replaced string
|
||||
ready domain.FreightItemImage
|
||||
}
|
||||
|
||||
func (repository *freightImageRepositoryFake) ListFreightImageJobs(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
int,
|
||||
) ([]domain.FreightItemImageJob, error) {
|
||||
return append([]domain.FreightItemImageJob(nil), repository.jobs...), nil
|
||||
}
|
||||
|
||||
func (repository *freightImageRepositoryFake) SaveFreightItemImage(
|
||||
_ context.Context,
|
||||
image domain.FreightItemImage,
|
||||
) (*string, error) {
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
repository.saved = append(repository.saved, image)
|
||||
if image.Status == domain.FreightItemImageReady &&
|
||||
repository.replaced != "" {
|
||||
value := repository.replaced
|
||||
repository.replaced = ""
|
||||
return &value, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (repository *freightImageRepositoryFake) GetReadyFreightItemImage(
|
||||
_ context.Context,
|
||||
creatorSubject, itemID string,
|
||||
) (domain.FreightItemImage, error) {
|
||||
if repository.ready.CreatorSubject != creatorSubject ||
|
||||
repository.ready.FreightOrderItemID != itemID {
|
||||
return domain.FreightItemImage{}, ErrRepositoryNotFound
|
||||
}
|
||||
return repository.ready, nil
|
||||
}
|
||||
|
||||
type freightImageSourceFake struct{}
|
||||
|
||||
func (*freightImageSourceFake) FetchProductImage(
|
||||
_ context.Context,
|
||||
productThumbRef string,
|
||||
) (FreightSourceImage, error) {
|
||||
if productThumbRef == "191" {
|
||||
return FreightSourceImage{}, domain.ErrFreightImageNotFound
|
||||
}
|
||||
return FreightSourceImage{
|
||||
Content: io.NopCloser(bytes.NewReader([]byte("source-image"))),
|
||||
MediaType: "image/png",
|
||||
}, nil
|
||||
}
|
||||
|
||||
type freightImageStoreFake struct {
|
||||
mu sync.Mutex
|
||||
deleted []string
|
||||
openContent []byte
|
||||
}
|
||||
|
||||
func (*freightImageStoreFake) Put(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
io.Reader,
|
||||
) (NormalizedReferenceImage, error) {
|
||||
return NormalizedReferenceImage{
|
||||
StorageKey: "new/image.jpg",
|
||||
MediaType: "image/jpeg",
|
||||
SizeBytes: 100,
|
||||
SHA256: repeatSHA("a"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (store *freightImageStoreFake) Open(
|
||||
context.Context,
|
||||
string,
|
||||
) (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(store.openContent)), nil
|
||||
}
|
||||
|
||||
func (store *freightImageStoreFake) Delete(
|
||||
_ context.Context,
|
||||
storageKey string,
|
||||
) error {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
store.deleted = append(store.deleted, storageKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func repeatSHA(value string) string {
|
||||
return strings.Repeat(value, 64)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
freightWatermarkOverlap = 10 * time.Minute
|
||||
orderFreightSyncTimeout = 55 * time.Second
|
||||
freightCleanupTimeout = 5 * time.Second
|
||||
freightImageCacheBudget = 15 * time.Second
|
||||
)
|
||||
|
||||
type FreightService struct {
|
||||
@@ -33,6 +34,19 @@ type FreightService struct {
|
||||
orderTimeout time.Duration
|
||||
cleanupTimeout time.Duration
|
||||
orderSyncGate chan struct{}
|
||||
imageCache FreightImageCache
|
||||
}
|
||||
|
||||
type FreightServiceOption func(*FreightService) error
|
||||
|
||||
func WithFreightImageCache(cache FreightImageCache) FreightServiceOption {
|
||||
return func(service *FreightService) error {
|
||||
if cache == nil {
|
||||
return errors.New("freight image cache is required")
|
||||
}
|
||||
service.imageCache = cache
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type FreightSourcePreflight interface {
|
||||
@@ -66,12 +80,13 @@ func NewFreightService(
|
||||
clock Clock,
|
||||
ids IDGenerator,
|
||||
timeout time.Duration,
|
||||
options ...FreightServiceOption,
|
||||
) (*FreightService, error) {
|
||||
if repository == nil || source == nil || clock == nil || ids == nil ||
|
||||
timeout <= 0 {
|
||||
return nil, errors.New("freight service dependencies are required")
|
||||
}
|
||||
return &FreightService{
|
||||
service := &FreightService{
|
||||
repository: repository,
|
||||
source: source,
|
||||
clock: clock,
|
||||
@@ -80,7 +95,16 @@ func NewFreightService(
|
||||
orderTimeout: orderFreightSyncTimeout,
|
||||
cleanupTimeout: freightCleanupTimeout,
|
||||
orderSyncGate: make(chan struct{}, 1),
|
||||
}, nil
|
||||
}
|
||||
for _, option := range options {
|
||||
if option == nil {
|
||||
return nil, errors.New("freight service option is required")
|
||||
}
|
||||
if err := option(service); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) CreateOrderSync(
|
||||
@@ -424,6 +448,18 @@ func (service *FreightService) executeRun(
|
||||
run.OrderCount = len(batch.Orders)
|
||||
run.ItemCount = itemCount
|
||||
run.FinishedAt = &finishedAt
|
||||
if service.imageCache != nil {
|
||||
cacheCtx, cancel := context.WithTimeout(
|
||||
ctx,
|
||||
freightImageCacheBudget,
|
||||
)
|
||||
_ = service.imageCache.CacheRun(
|
||||
cacheCtx,
|
||||
run.CreatorSubject,
|
||||
run.ID,
|
||||
)
|
||||
cancel()
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -184,12 +184,16 @@ func TestCreateFreightOrderSyncStopsBeforePersistingWhenOCRIsInvalid(t *testing.
|
||||
|
||||
func TestCreateFreightOrderSyncReturnsCommittedResult(t *testing.T) {
|
||||
repository := &syncTrackingRepository{}
|
||||
imageCache := &recordingFreightImageCache{
|
||||
err: errors.New("best effort image failure"),
|
||||
}
|
||||
service, err := NewFreightService(
|
||||
repository,
|
||||
&recordingDateSource{},
|
||||
fakeClock{},
|
||||
&sequenceIDs{},
|
||||
time.Minute,
|
||||
WithFreightImageCache(imageCache),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFreightService() error = %v", err)
|
||||
@@ -209,6 +213,11 @@ func TestCreateFreightOrderSyncReturnsCommittedResult(t *testing.T) {
|
||||
result.Run.StartedAt == nil || result.Run.FinishedAt == nil {
|
||||
t.Fatalf("CreateOrderSync() result = %+v", result)
|
||||
}
|
||||
if imageCache.calls != 1 ||
|
||||
imageCache.creatorSubject != "local-admin" ||
|
||||
imageCache.runID != result.Run.ID {
|
||||
t.Fatalf("image cache calls = %+v", imageCache)
|
||||
}
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
if repository.startCalls != 1 || repository.completeCalls != 1 ||
|
||||
@@ -222,6 +231,23 @@ func TestCreateFreightOrderSyncReturnsCommittedResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type recordingFreightImageCache struct {
|
||||
calls int
|
||||
creatorSubject string
|
||||
runID string
|
||||
err error
|
||||
}
|
||||
|
||||
func (cache *recordingFreightImageCache) CacheRun(
|
||||
_ context.Context,
|
||||
creatorSubject, runID string,
|
||||
) error {
|
||||
cache.calls++
|
||||
cache.creatorSubject = creatorSubject
|
||||
cache.runID = runID
|
||||
return cache.err
|
||||
}
|
||||
|
||||
func TestCreateFreightOrderSyncTimeoutUsesLiveCleanupContext(t *testing.T) {
|
||||
repository := &syncTrackingRepository{}
|
||||
source := &blockingOrderSource{}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE freight_item_images (
|
||||
freight_order_item_id TEXT PRIMARY KEY NOT NULL
|
||||
REFERENCES freight_order_items(id)
|
||||
ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||
product_thumb_ref TEXT NOT NULL
|
||||
CHECK (
|
||||
product_thumb_ref GLOB '[1-9]*'
|
||||
AND product_thumb_ref NOT GLOB '*[^0-9]*'
|
||||
AND length(product_thumb_ref) <= 20
|
||||
),
|
||||
status TEXT NOT NULL CHECK (status IN ('READY', 'MISSING', 'FAILED')),
|
||||
media_type TEXT,
|
||||
size_bytes INTEGER,
|
||||
sha256 TEXT,
|
||||
storage_key TEXT UNIQUE,
|
||||
error_code TEXT
|
||||
CHECK (
|
||||
error_code IS NULL
|
||||
OR (
|
||||
length(trim(error_code)) > 0
|
||||
AND length(CAST(error_code AS BLOB)) <= 64
|
||||
)
|
||||
),
|
||||
attempt_count INTEGER NOT NULL CHECK (attempt_count >= 1),
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (
|
||||
(
|
||||
status = 'READY'
|
||||
AND media_type = 'image/jpeg'
|
||||
AND size_bytes > 0
|
||||
AND length(sha256) = 64
|
||||
AND sha256 NOT GLOB '*[^0-9a-f]*'
|
||||
AND length(trim(storage_key)) > 0
|
||||
AND length(CAST(storage_key AS BLOB)) <= 512
|
||||
AND error_code IS NULL
|
||||
)
|
||||
OR (
|
||||
status IN ('MISSING', 'FAILED')
|
||||
AND media_type IS NULL
|
||||
AND size_bytes IS NULL
|
||||
AND sha256 IS NULL
|
||||
AND storage_key IS NULL
|
||||
AND error_code IS NOT NULL
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX freight_item_images_status_idx
|
||||
ON freight_item_images (status, updated_at, freight_order_item_id);
|
||||
|
||||
-- +goose Down
|
||||
CREATE TEMP TABLE freight_item_images_v16_down_guard (
|
||||
allowed INTEGER NOT NULL CHECK (allowed = 1)
|
||||
);
|
||||
|
||||
INSERT INTO freight_item_images_v16_down_guard (allowed)
|
||||
SELECT CASE
|
||||
WHEN EXISTS (SELECT 1 FROM freight_item_images)
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END;
|
||||
|
||||
DROP TABLE freight_item_images_v16_down_guard;
|
||||
DROP TABLE freight_item_images;
|
||||
Reference in New Issue
Block a user