feat(t240): cache freight item images
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user