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)