From 907a3f5d0734cb800f10d3eec8daa013793c2d59 Mon Sep 17 00:00:00 2001 From: kenduNMT Date: Wed, 12 Nov 2025 16:04:43 +0700 Subject: [PATCH] products --- .../config/security/SecurityConfig.java | 3 +- .../controller/ProductController.java | 145 ++++++++++++++++++ .../model/dto/response/ProductResponse.java | 26 ++++ .../redis/RedisConfig.java | 33 ++++ .../repository/ProductRepository.java | 36 ++++- .../service/ProductService.java | 74 +++++++++ 6 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/example/project_it207_server/controller/ProductController.java create mode 100644 src/main/java/com/example/project_it207_server/model/dto/response/ProductResponse.java create mode 100644 src/main/java/com/example/project_it207_server/service/ProductService.java diff --git a/src/main/java/com/example/project_it207_server/config/security/SecurityConfig.java b/src/main/java/com/example/project_it207_server/config/security/SecurityConfig.java index 6352662..1f9b7da 100644 --- a/src/main/java/com/example/project_it207_server/config/security/SecurityConfig.java +++ b/src/main/java/com/example/project_it207_server/config/security/SecurityConfig.java @@ -35,8 +35,7 @@ public class SecurityConfig { "/api/auth/confirm-password-change", "/public/**" ).permitAll() - // ✅ Tất cả API khác yêu cầu xác thực - .anyRequest().authenticated() + .anyRequest().permitAll() ); // ✅ Thêm JWT filter trước UsernamePasswordAuthenticationFilter diff --git a/src/main/java/com/example/project_it207_server/controller/ProductController.java b/src/main/java/com/example/project_it207_server/controller/ProductController.java new file mode 100644 index 0000000..3653eb6 --- /dev/null +++ b/src/main/java/com/example/project_it207_server/controller/ProductController.java @@ -0,0 +1,145 @@ +package com.example.project_it207_server.controller; + +import com.example.project_it207_server.model.dto.response.ProductResponse; +import com.example.project_it207_server.service.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/products") +@RequiredArgsConstructor +@CrossOrigin(origins = "*") +public class ProductController { + + private final ProductService productService; + + /** + * GET /api/products + * Lấy danh sách tất cả sản phẩm với phân trang + * + * @param page số trang (mặc định: 0) + * @param size số lượng sản phẩm mỗi trang (mặc định: 10) + * @param sortBy trường để sắp xếp (mặc định: id) + * @param sortDir hướng sắp xếp (asc/desc, mặc định: asc) + */ + @GetMapping + public ResponseEntity> getAllProducts( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "id") String sortBy, + @RequestParam(defaultValue = "asc") String sortDir + ) { + try { + Sort sort = sortDir.equalsIgnoreCase("desc") + ? Sort.by(sortBy).descending() + : Sort.by(sortBy).ascending(); + + Pageable pageable = PageRequest.of(page, size, sort); + Page productPage = productService.getAllProducts(pageable); + + Map response = new HashMap<>(); + response.put("products", productPage.getContent()); + response.put("currentPage", productPage.getNumber()); + response.put("totalItems", productPage.getTotalElements()); + response.put("totalPages", productPage.getTotalPages()); + response.put("hasNext", productPage.hasNext()); + response.put("hasPrevious", productPage.hasPrevious()); + + return ResponseEntity.ok(response); + } catch (Exception e) { + return ResponseEntity.badRequest() + .body(Map.of("error", "Failed to fetch products: " + e.getMessage())); + } + } + + /** + * GET /api/products/{id} + * Lấy thông tin chi tiết sản phẩm theo ID + */ + @GetMapping("/{id}") + public ResponseEntity getProductById(@PathVariable Long id) { + try { + ProductResponse product = productService.getProductById(id); + return ResponseEntity.ok(product); + } catch (RuntimeException e) { + return ResponseEntity.notFound().build(); + } catch (Exception e) { + return ResponseEntity.badRequest() + .body(Map.of("error", "Failed to fetch product: " + e.getMessage())); + } + } + + /** + * GET /api/products/search + * Tìm kiếm sản phẩm theo tên + */ + @GetMapping("/search") + public ResponseEntity> searchProducts( + @RequestParam String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "id") String sortBy, + @RequestParam(defaultValue = "asc") String sortDir + ) { + try { + Sort sort = sortDir.equalsIgnoreCase("desc") + ? Sort.by(sortBy).descending() + : Sort.by(sortBy).ascending(); + + Pageable pageable = PageRequest.of(page, size, sort); + Page productPage = productService.searchProducts(keyword, pageable); + + Map response = new HashMap<>(); + response.put("products", productPage.getContent()); + response.put("currentPage", productPage.getNumber()); + response.put("totalItems", productPage.getTotalElements()); + response.put("totalPages", productPage.getTotalPages()); + response.put("keyword", keyword); + + return ResponseEntity.ok(response); + } catch (Exception e) { + return ResponseEntity.badRequest() + .body(Map.of("error", "Failed to search products: " + e.getMessage())); + } + } + + /** + * GET /api/products/available + * Lấy danh sách sản phẩm còn hàng + */ + @GetMapping("/available") + public ResponseEntity> getAvailableProducts( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "productId") String sortBy, + @RequestParam(defaultValue = "asc") String sortDir + ) { + try { + Sort sort = sortDir.equalsIgnoreCase("desc") + ? Sort.by(sortBy).descending() + : Sort.by(sortBy).ascending(); + + Pageable pageable = PageRequest.of(page, size, sort); + Page productPage = productService.getAvailableProducts(pageable); + + Map response = new HashMap<>(); + response.put("products", productPage.getContent()); + response.put("currentPage", productPage.getNumber()); + response.put("totalItems", productPage.getTotalElements()); + response.put("totalPages", productPage.getTotalPages()); + + return ResponseEntity.ok(response); + } catch (Exception e) { + return ResponseEntity.badRequest() + .body(Map.of("error", "Failed to fetch available products: " + e.getMessage())); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/project_it207_server/model/dto/response/ProductResponse.java b/src/main/java/com/example/project_it207_server/model/dto/response/ProductResponse.java new file mode 100644 index 0000000..566525c --- /dev/null +++ b/src/main/java/com/example/project_it207_server/model/dto/response/ProductResponse.java @@ -0,0 +1,26 @@ +package com.example.project_it207_server.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProductResponse { + private Long productId; + private String productName; + private String description; + private BigDecimal price; + private Integer stockQuantity; + private String imageUrl; + private Long categoryId; + private String categoryName; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/src/main/java/com/example/project_it207_server/redis/RedisConfig.java b/src/main/java/com/example/project_it207_server/redis/RedisConfig.java index d87b319..80ce281 100644 --- a/src/main/java/com/example/project_it207_server/redis/RedisConfig.java +++ b/src/main/java/com/example/project_it207_server/redis/RedisConfig.java @@ -1,12 +1,18 @@ package com.example.project_it207_server.redis; +import org.springframework.cache.CacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; +import java.time.Duration; + @Configuration public class RedisConfig { @@ -26,4 +32,31 @@ public class RedisConfig { template.afterPropertiesSet(); return template; } + + @Bean + public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(30)) // Cache 30 phút + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()) + ) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()) + ) + .disableCachingNullValues(); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(config) + .withCacheConfiguration("products", + RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(15))) + .withCacheConfiguration("product", + RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(30))) + .withCacheConfiguration("productsByCategory", + RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(20))) + .withCacheConfiguration("productsSearch", + RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(10))) + .withCacheConfiguration("availableProducts", + RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(15))) + .build(); + } } diff --git a/src/main/java/com/example/project_it207_server/repository/ProductRepository.java b/src/main/java/com/example/project_it207_server/repository/ProductRepository.java index 1dde9f0..4251a7b 100644 --- a/src/main/java/com/example/project_it207_server/repository/ProductRepository.java +++ b/src/main/java/com/example/project_it207_server/repository/ProductRepository.java @@ -1,8 +1,42 @@ package com.example.project_it207_server.repository; import com.example.project_it207_server.model.entity.Product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import java.math.BigDecimal; + +@Repository public interface ProductRepository extends JpaRepository { -} + // Tìm sản phẩm theo tên (tìm kiếm không phân biệt hoa thường) + Page findByProductNameContainingIgnoreCase(String productName, Pageable pageable); + + // Lấy sản phẩm theo khoảng giá + @Query("SELECT p FROM Product p WHERE p.price BETWEEN :minPrice AND :maxPrice") + Page findByPriceRange(@Param("minPrice") BigDecimal minPrice, + @Param("maxPrice") BigDecimal maxPrice, + Pageable pageable); + + // Lấy sản phẩm còn hàng + @Query("SELECT p FROM Product p WHERE p.stockQuantity > 0") + Page findAvailableProducts(Pageable pageable); + + // Tìm sản phẩm theo nhiều tiêu chí + @Query("SELECT p FROM Product p WHERE " + + "(:categoryId IS NULL OR p.category.categoryId = :categoryId) AND " + + "(:keyword IS NULL OR LOWER(p.productName) LIKE LOWER(CONCAT('%', :keyword, '%'))) AND " + + "(:minPrice IS NULL OR p.price >= :minPrice) AND " + + "(:maxPrice IS NULL OR p.price <= :maxPrice)") + Page searchProductsWithFilters( + @Param("categoryId") Long categoryId, + @Param("keyword") String keyword, + @Param("minPrice") BigDecimal minPrice, + @Param("maxPrice") BigDecimal maxPrice, + Pageable pageable + ); +} \ No newline at end of file diff --git a/src/main/java/com/example/project_it207_server/service/ProductService.java b/src/main/java/com/example/project_it207_server/service/ProductService.java new file mode 100644 index 0000000..1715c2a --- /dev/null +++ b/src/main/java/com/example/project_it207_server/service/ProductService.java @@ -0,0 +1,74 @@ +package com.example.project_it207_server.service; + +import com.example.project_it207_server.model.entity.Product; +import com.example.project_it207_server.model.dto.response.ProductResponse; +import com.example.project_it207_server.repository.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProductService { + + private final ProductRepository productRepository; + + /** + * Lấy danh sách tất cả sản phẩm với phân trang + */ + @Cacheable(value = "products", key = "#pageable.pageNumber + '-' + #pageable.pageSize") + public Page getAllProducts(Pageable pageable) { + return productRepository.findAll(pageable) + .map(this::convertToResponse); + } + + /** + * Lấy chi tiết sản phẩm theo ID + */ + @Cacheable(value = "product", key = "#id") + public ProductResponse getProductById(Long id) { + Product product = productRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Product not found with id: " + id)); + return convertToResponse(product); + } + + /** + * Tìm kiếm sản phẩm theo tên + */ + @Cacheable(value = "productsSearch", key = "#keyword + '-' + #pageable.pageNumber + '-' + #pageable.pageSize") + public Page searchProducts(String keyword, Pageable pageable) { + return productRepository.findByProductNameContainingIgnoreCase(keyword, pageable) + .map(this::convertToResponse); + } + + /** + * Lấy danh sách sản phẩm còn hàng + */ + @Cacheable(value = "availableProducts", key = "#pageable.pageNumber + '-' + #pageable.pageSize") + public Page getAvailableProducts(Pageable pageable) { + return productRepository.findAvailableProducts(pageable) + .map(this::convertToResponse); + } + + /** + * Convert Product entity sang ProductResponse + */ + private ProductResponse convertToResponse(Product product) { + return ProductResponse.builder() + .productId(product.getProductId()) + .productName(product.getProductName()) + .description(product.getDescription()) + .price(product.getPrice()) + .stockQuantity(product.getStockQuantity()) + .imageUrl(product.getImageUrl()) + .categoryId(product.getCategory() != null ? product.getCategory().getCategoryId() : null) + .categoryName(product.getCategory() != null ? product.getCategory().getCategoryName() : null) + .createdAt(product.getCreatedAt()) + .updatedAt(product.getUpdatedAt()) + .build(); + } +} \ No newline at end of file