mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2025-12-06 11:06:45 -05:00
added java caching and optimized controller queries
This commit is contained in:
@@ -5,8 +5,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableCaching
|
||||
@ComponentScan("group.goforward.ballistic.controllers")
|
||||
@ComponentScan("group.goforward.ballistic.repos")
|
||||
@ComponentScan("group.goforward.ballistic.services")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package group.goforward.ballistic.configuration;
|
||||
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class CacheConfig {
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
// Simple in-memory cache for dev/local
|
||||
return new ConcurrentMapCacheManager("gunbuilderProducts");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import group.goforward.ballistic.web.dto.ProductOfferDto;
|
||||
import group.goforward.ballistic.repos.ProductRepository;
|
||||
import group.goforward.ballistic.web.dto.ProductSummaryDto;
|
||||
import group.goforward.ballistic.web.mapper.ProductMapper;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -30,35 +31,54 @@ public class ProductController {
|
||||
}
|
||||
|
||||
@GetMapping("/gunbuilder")
|
||||
@Cacheable(
|
||||
value = "gunbuilderProducts",
|
||||
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())"
|
||||
)
|
||||
public List<ProductSummaryDto> getGunbuilderProducts(
|
||||
@RequestParam(defaultValue = "AR-15") String platform,
|
||||
@RequestParam(required = false, name = "partRoles") List<String> partRoles
|
||||
) {
|
||||
// 1) Load products
|
||||
long started = System.currentTimeMillis();
|
||||
System.out.println("getGunbuilderProducts: start, platform=" + platform +
|
||||
", partRoles=" + (partRoles == null ? "null" : partRoles));
|
||||
|
||||
// 1) Load products (with brand pre-fetched)
|
||||
long tProductsStart = System.currentTimeMillis();
|
||||
List<Product> products;
|
||||
if (partRoles == null || partRoles.isEmpty()) {
|
||||
products = productRepository.findByPlatform(platform);
|
||||
products = productRepository.findByPlatformWithBrand(platform);
|
||||
} else {
|
||||
products = productRepository.findByPlatformAndPartRoleIn(platform, partRoles);
|
||||
products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
|
||||
}
|
||||
long tProductsEnd = System.currentTimeMillis();
|
||||
System.out.println("getGunbuilderProducts: loaded products: " +
|
||||
products.size() + " in " + (tProductsEnd - tProductsStart) + " ms");
|
||||
|
||||
if (products.isEmpty()) {
|
||||
long took = System.currentTimeMillis() - started;
|
||||
System.out.println("getGunbuilderProducts: 0 products in " + took + " ms");
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// 2) Load offers for these product IDs (Integer IDs)
|
||||
// 2) Load offers for these product IDs
|
||||
long tOffersStart = System.currentTimeMillis();
|
||||
List<Integer> productIds = products.stream()
|
||||
.map(Product::getId)
|
||||
.toList();
|
||||
|
||||
List<ProductOffer> allOffers =
|
||||
productOfferRepository.findByProductIdIn(productIds);
|
||||
long tOffersEnd = System.currentTimeMillis();
|
||||
System.out.println("getGunbuilderProducts: loaded offers: " +
|
||||
allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms");
|
||||
|
||||
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
||||
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
||||
|
||||
// 3) Map to DTOs with price and buyUrl
|
||||
return products.stream()
|
||||
long tMapStart = System.currentTimeMillis();
|
||||
List<ProductSummaryDto> result = products.stream()
|
||||
.map(p -> {
|
||||
List<ProductOffer> offersForProduct =
|
||||
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
|
||||
@@ -71,25 +91,36 @@ public class ProductController {
|
||||
return ProductMapper.toSummary(p, price, buyUrl);
|
||||
})
|
||||
.toList();
|
||||
long tMapEnd = System.currentTimeMillis();
|
||||
long took = System.currentTimeMillis() - started;
|
||||
|
||||
System.out.println("getGunbuilderProducts: mapping to DTOs took " +
|
||||
(tMapEnd - tMapStart) + " ms");
|
||||
System.out.println("getGunbuilderProducts: TOTAL " + took + " ms (" +
|
||||
"products=" + (tProductsEnd - tProductsStart) + " ms, " +
|
||||
"offers=" + (tOffersEnd - tOffersStart) + " ms, " +
|
||||
"map=" + (tMapEnd - tMapStart) + " ms)");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/{id}/offers")
|
||||
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
|
||||
List<ProductOffer> offers = productOfferRepository.findByProductId(productId);
|
||||
|
||||
return offers.stream()
|
||||
.map(offer -> {
|
||||
ProductOfferDto dto = new ProductOfferDto();
|
||||
dto.setId(offer.getId().toString());
|
||||
dto.setMerchantName(offer.getMerchant().getName());
|
||||
dto.setPrice(offer.getEffectivePrice());
|
||||
dto.setOriginalPrice(offer.getOriginalPrice());
|
||||
dto.setInStock(Boolean.TRUE.equals(offer.getInStock()));
|
||||
dto.setBuyUrl(offer.getBuyUrl());
|
||||
dto.setLastUpdated(offer.getLastSeenAt());
|
||||
return dto;
|
||||
})
|
||||
.toList();
|
||||
.map(offer -> {
|
||||
ProductOfferDto dto = new ProductOfferDto();
|
||||
dto.setId(offer.getId().toString());
|
||||
dto.setMerchantName(offer.getMerchant().getName());
|
||||
dto.setPrice(offer.getEffectivePrice());
|
||||
dto.setOriginalPrice(offer.getOriginalPrice());
|
||||
dto.setInStock(Boolean.TRUE.equals(offer.getInStock()));
|
||||
dto.setBuyUrl(offer.getBuyUrl());
|
||||
dto.setLastUpdated(offer.getLastSeenAt());
|
||||
return dto;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
|
||||
|
||||
@@ -3,6 +3,8 @@ package group.goforward.ballistic.repos;
|
||||
import group.goforward.ballistic.model.Product;
|
||||
import group.goforward.ballistic.model.Brand;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -24,4 +26,28 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
|
||||
|
||||
// Products filtered by platform + part roles (e.g. upper-receiver, barrel, etc.)
|
||||
List<Product> findByPlatformAndPartRoleIn(String platform, Collection<String> partRoles);
|
||||
|
||||
// ---------- Optimized variants for Gunbuilder (fetch brand to avoid N+1) ----------
|
||||
|
||||
@Query("""
|
||||
SELECT p
|
||||
FROM Product p
|
||||
JOIN FETCH p.brand b
|
||||
WHERE p.platform = :platform
|
||||
AND p.deletedAt IS NULL
|
||||
""")
|
||||
List<Product> findByPlatformWithBrand(@Param("platform") String platform);
|
||||
|
||||
@Query("""
|
||||
SELECT p
|
||||
FROM Product p
|
||||
JOIN FETCH p.brand b
|
||||
WHERE p.platform = :platform
|
||||
AND p.partRole IN :partRoles
|
||||
AND p.deletedAt IS NULL
|
||||
""")
|
||||
List<Product> findByPlatformAndPartRoleInWithBrand(
|
||||
@Param("platform") String platform,
|
||||
@Param("partRoles") Collection<String> partRoles
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,15 @@ import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
|
||||
import group.goforward.ballistic.imports.MerchantFeedRow;
|
||||
import group.goforward.ballistic.services.MerchantFeedImportService;
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVParser;
|
||||
import org.apache.commons.csv.CSVRecord;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import group.goforward.ballistic.model.Brand;
|
||||
import group.goforward.ballistic.model.Merchant;
|
||||
@@ -36,6 +39,7 @@ import java.time.OffsetDateTime;
|
||||
@Service
|
||||
@Transactional
|
||||
public class MerchantFeedImportServiceImpl implements MerchantFeedImportService {
|
||||
private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class);
|
||||
|
||||
private final MerchantRepository merchantRepository;
|
||||
private final BrandRepository brandRepository;
|
||||
@@ -56,27 +60,22 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
}
|
||||
|
||||
@Override
|
||||
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
|
||||
public void importMerchantFeed(Integer merchantId) {
|
||||
System.out.println("IMPORT >>> importMerchantFeed(" + merchantId + ")");
|
||||
log.info("Starting full import for merchantId={}", merchantId);
|
||||
|
||||
Merchant merchant = merchantRepository.findById(merchantId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
||||
|
||||
// Read all rows from the merchant feed
|
||||
List<MerchantFeedRow> rows = readFeedRowsForMerchant(merchant);
|
||||
System.out.println("IMPORT >>> read " + rows.size() + " rows for merchant=" + merchant.getName());
|
||||
log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName());
|
||||
|
||||
for (MerchantFeedRow row : rows) {
|
||||
// Resolve brand from the row (fallback to "Aero Precision" or whatever you want as default)
|
||||
Brand brand = resolveBrand(row);
|
||||
Product p = upsertProduct(merchant, brand, row);
|
||||
|
||||
System.out.println("IMPORT >>> upserted product id=" + p.getId()
|
||||
+ ", name=" + p.getName()
|
||||
+ ", slug=" + p.getSlug()
|
||||
+ ", platform=" + p.getPlatform()
|
||||
+ ", partRole=" + p.getPartRole()
|
||||
+ ", merchant=" + merchant.getName());
|
||||
log.debug("Upserted product id={}, name={}, slug={}, platform={}, partRole={}, merchant={}",
|
||||
p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,9 +84,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) {
|
||||
System.out.println("IMPORT >>> upsertProduct brand=" + brand.getName()
|
||||
+ ", sku=" + row.sku()
|
||||
+ ", productName=" + row.productName());
|
||||
log.debug("Upserting product for brand={}, sku={}, name={}", brand.getName(), row.sku(), row.productName());
|
||||
|
||||
String mpn = trimOrNull(row.manufacturerId());
|
||||
String upc = trimOrNull(row.sku()); // placeholder until real UPC field
|
||||
@@ -109,9 +106,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
p.setBrand(brand);
|
||||
} else {
|
||||
if (candidates.size() > 1) {
|
||||
System.out.println("IMPORT !!! WARNING: multiple existing products found for brand="
|
||||
+ brand.getName() + ", mpn=" + mpn + ", upc=" + upc
|
||||
+ ". Using the first match (id=" + candidates.get(0).getId() + ")");
|
||||
log.warn("Multiple existing products found for brand={}, mpn={}, upc={}. Using first match id={}",
|
||||
brand.getName(), mpn, upc, candidates.get(0).getId());
|
||||
}
|
||||
p = candidates.get(0);
|
||||
}
|
||||
@@ -127,10 +123,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
return saved;
|
||||
}
|
||||
private List<Map<String, String>> fetchFeedRows(String feedUrl) {
|
||||
System.out.println("OFFERS >>> reading offer feed from: " + feedUrl);
|
||||
|
||||
log.info("Reading offer feed from {}", feedUrl);
|
||||
|
||||
List<Map<String, String>> rows = new ArrayList<>();
|
||||
|
||||
|
||||
try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://"))
|
||||
? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8)
|
||||
: java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8);
|
||||
@@ -139,10 +135,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
.withIgnoreSurroundingSpaces()
|
||||
.withTrim()
|
||||
.parse(reader)) {
|
||||
|
||||
|
||||
// capture header names from the CSV
|
||||
List<String> headers = new ArrayList<>(parser.getHeaderMap().keySet());
|
||||
|
||||
|
||||
for (CSVRecord rec : parser) {
|
||||
Map<String, String> row = new HashMap<>();
|
||||
for (String header : headers) {
|
||||
@@ -153,8 +149,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
} catch (Exception ex) {
|
||||
throw new RuntimeException("Failed to read offer feed from " + feedUrl, ex);
|
||||
}
|
||||
|
||||
System.out.println("OFFERS >>> parsed " + rows.size() + " offer rows");
|
||||
|
||||
log.info("Parsed {} offer rows from offer feed {}", rows.size(), feedUrl);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -255,7 +251,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
String avantlinkProductId = trimOrNull(row.sku());
|
||||
if (avantlinkProductId == null) {
|
||||
// If there's truly no SKU, bail out – we can't match this offer reliably.
|
||||
System.out.println("IMPORT !!! skipping offer: no SKU for product id=" + product.getId());
|
||||
log.debug("Skipping offer row with no SKU for product id={}", product.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -358,11 +354,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
|
||||
Map<String, Integer> headerMap = parser.getHeaderMap();
|
||||
if (headerMap != null && headerMap.keySet().containsAll(requiredHeaders)) {
|
||||
System.out.println(
|
||||
"IMPORT >>> detected delimiter '" +
|
||||
(delimiter == '\t' ? "\\t" : String.valueOf(delimiter)) +
|
||||
"' for feed: " + feedUrl
|
||||
);
|
||||
log.info("Detected delimiter '{}' for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), feedUrl);
|
||||
|
||||
return CSVFormat.DEFAULT.builder()
|
||||
.setDelimiter(delimiter)
|
||||
@@ -372,16 +364,11 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
.setTrim(true)
|
||||
.build();
|
||||
} else if (headerMap != null) {
|
||||
System.out.println(
|
||||
"IMPORT !!! delimiter '" +
|
||||
(delimiter == '\t' ? "\\t" : String.valueOf(delimiter)) +
|
||||
"' produced headers: " + headerMap.keySet()
|
||||
);
|
||||
log.debug("Delimiter '{}' produced headers {} for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), headerMap.keySet(), feedUrl);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
lastException = ex;
|
||||
System.out.println("IMPORT !!! error probing delimiter '" + delimiter +
|
||||
"' for " + feedUrl + ": " + ex.getMessage());
|
||||
log.warn("Error probing delimiter '{}' for {}: {}", delimiter, feedUrl, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,7 +385,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
}
|
||||
|
||||
String feedUrl = rawFeedUrl.trim();
|
||||
System.out.println("IMPORT >>> reading feed for merchant=" + merchant.getName() + " from: " + feedUrl);
|
||||
log.info("Reading product feed for merchant={} from {}", merchant.getName(), feedUrl);
|
||||
|
||||
List<MerchantFeedRow> rows = new ArrayList<>();
|
||||
|
||||
@@ -409,7 +396,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
try (Reader reader = openFeedReader(feedUrl);
|
||||
CSVParser parser = new CSVParser(reader, format)) {
|
||||
|
||||
System.out.println("IMPORT >>> detected headers: " + parser.getHeaderMap().keySet());
|
||||
log.debug("Detected feed headers for merchant {}: {}", merchant.getName(), parser.getHeaderMap().keySet());
|
||||
|
||||
for (CSVRecord rec : parser) {
|
||||
MerchantFeedRow row = new MerchantFeedRow(
|
||||
@@ -447,7 +434,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
+ merchant.getName() + " from " + feedUrl, ex);
|
||||
}
|
||||
|
||||
System.out.println("IMPORT >>> parsed " + rows.size() + " rows for merchant=" + merchant.getName());
|
||||
log.info("Parsed {} product rows for merchant={}", rows.size(), merchant.getName());
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -474,7 +461,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
try {
|
||||
return new BigDecimal(trimmed);
|
||||
} catch (NumberFormatException ex) {
|
||||
System.out.println("IMPORT !!! bad BigDecimal value: '" + raw + "', skipping");
|
||||
log.debug("Skipping invalid numeric value '{}'", raw);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -495,8 +482,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
try {
|
||||
return rec.get(header);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
System.out.println("IMPORT !!! short record #" + rec.getRecordNumber()
|
||||
+ " missing column '" + header + "', treating as null");
|
||||
log.debug("Short CSV record #{} missing column '{}', treating as null", rec.getRecordNumber(), header);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -593,32 +579,35 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
|
||||
public void syncOffersOnly(Integer merchantId) {
|
||||
log.info("Starting offers-only sync for merchantId={}", merchantId);
|
||||
Merchant merchant = merchantRepository.findById(merchantId)
|
||||
.orElseThrow(() -> new RuntimeException("Merchant not found"));
|
||||
|
||||
|
||||
if (Boolean.FALSE.equals(merchant.getIsActive())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use offerFeedUrl if present, else fall back to feedUrl
|
||||
|
||||
String feedUrl = merchant.getOfferFeedUrl() != null
|
||||
? merchant.getOfferFeedUrl()
|
||||
: merchant.getFeedUrl();
|
||||
|
||||
|
||||
if (feedUrl == null) {
|
||||
throw new RuntimeException("No offer feed URL configured for merchant " + merchantId);
|
||||
}
|
||||
|
||||
|
||||
List<Map<String, String>> rows = fetchFeedRows(feedUrl);
|
||||
|
||||
|
||||
for (Map<String, String> row : rows) {
|
||||
upsertOfferOnlyFromRow(merchant, row);
|
||||
}
|
||||
|
||||
|
||||
merchant.setLastOfferSyncAt(OffsetDateTime.now());
|
||||
merchantRepository.save(merchant);
|
||||
log.info("Completed offers-only sync for merchantId={} ({} rows processed)", merchantId, rows.size());
|
||||
}
|
||||
|
||||
private void upsertOfferOnlyFromRow(Merchant merchant, Map<String, String> row) {
|
||||
// For the offer-only sync, we key offers by the same identifier we used when creating them.
|
||||
// In the current AvantLink-style feed, that is the SKU column.
|
||||
|
||||
Reference in New Issue
Block a user