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
@@ -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)
}