This commit is contained in:
2025-11-12 16:04:43 +07:00
parent 53e6a98ce4
commit 907a3f5d07
6 changed files with 314 additions and 3 deletions

View File

@@ -35,8 +35,7 @@ public class SecurityConfig {
"/api/auth/confirm-password-change", "/api/auth/confirm-password-change",
"/public/**" "/public/**"
).permitAll() ).permitAll()
// ✅ Tất cả API khác yêu cầu xác thực .anyRequest().permitAll()
.anyRequest().authenticated()
); );
// ✅ Thêm JWT filter trước UsernamePasswordAuthenticationFilter // ✅ Thêm JWT filter trước UsernamePasswordAuthenticationFilter

View File

@@ -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<Map<String, Object>> 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<ProductResponse> productPage = productService.getAllProducts(pageable);
Map<String, Object> 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<Map<String, Object>> 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<ProductResponse> productPage = productService.searchProducts(keyword, pageable);
Map<String, Object> 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<Map<String, Object>> 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<ProductResponse> productPage = productService.getAvailableProducts(pageable);
Map<String, Object> 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()));
}
}
}

View File

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

View File

@@ -1,12 +1,18 @@
package com.example.project_it207_server.redis; package com.example.project_it207_server.redis;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration @Configuration
public class RedisConfig { public class RedisConfig {
@@ -26,4 +32,31 @@ public class RedisConfig {
template.afterPropertiesSet(); template.afterPropertiesSet();
return template; 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();
}
} }

View File

@@ -1,8 +1,42 @@
package com.example.project_it207_server.repository; package com.example.project_it207_server.repository;
import com.example.project_it207_server.model.entity.Product; 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.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<Product, Long> { public interface ProductRepository extends JpaRepository<Product, Long> {
// Tìm sản phẩm theo tên (tìm kiếm không phân biệt hoa thường)
Page<Product> 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<Product> 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<Product> 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<Product> searchProductsWithFilters(
@Param("categoryId") Long categoryId,
@Param("keyword") String keyword,
@Param("minPrice") BigDecimal minPrice,
@Param("maxPrice") BigDecimal maxPrice,
Pageable pageable
);
} }

View File

@@ -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<ProductResponse> 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<ProductResponse> 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<ProductResponse> 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();
}
}