feat(t240): cache freight item images

This commit is contained in:
QiuSW
2026-07-29 15:12:05 +08:00
parent 5f17fc11aa
commit c7e152aaa1
28 changed files with 1728 additions and 30 deletions
+32
View File
@@ -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(&currentThumb)
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{}