diff --git a/pom.xml b/pom.xml index 70c79e5..719d894 100644 --- a/pom.xml +++ b/pom.xml @@ -41,6 +41,11 @@ spring-boot-starter-thymeleaf + + org.springframework.boot + spring-boot-starter-validation + + org.thymeleaf.extras diff --git a/src/main/java/kr/ac/hansung/DataInitializer.java b/src/main/java/kr/ac/hansung/DataInitializer.java index f31ff36..c5a6ad3 100644 --- a/src/main/java/kr/ac/hansung/DataInitializer.java +++ b/src/main/java/kr/ac/hansung/DataInitializer.java @@ -46,7 +46,25 @@ public void run(ApplicationArguments args) { productRepository.save(new Product("Spring Security 7 핵심 원리", 28000, "세션·JWT·OAuth2 기반 보안 구현", 30)); productRepository.save(new Product("JPA 프로그래밍 실전", 32000, "Hibernate 7 기반 ORM 마스터", 25)); productRepository.save(new Product("Thymeleaf 완전 정복", 22000, "서버사이드 템플릿 엔진 가이드", 40)); - log.info("샘플 상품 4건 생성 완료"); + productRepository.save(new Product("React 입문", 27000, "컴포넌트 기반 프론트엔드 개발", 0)); + productRepository.save(new Product("Docker & Kubernetes", 38000, "컨테이너 오케스트레이션 실전", 15)); + productRepository.save(new Product("Git & GitHub 협업", 18000, "브랜치 전략과 Pull Request 가이드", 60)); + productRepository.save(new Product("MySQL 성능 최적화", 33000, "인덱스·쿼리 튜닝 실전 가이드", 20)); + productRepository.save(new Product("Redis 캐싱 전략", 29000, "캐시 설계와 세션 관리 실습", 10)); + productRepository.save(new Product("AWS 클라우드 입문", 42000, "EC2·S3·RDS 실전 배포 가이드", 35)); + + productRepository.save(new Product("삼성전자 갤럭시 S25", 1290000, "최신 플래그십 스마트폰", 100)); + productRepository.save(new Product("삼성전자 갤럭시 탭 S10", 890000, "고해상도 AMOLED 태블릿", 45)); + productRepository.save(new Product("LG 그램 17 노트북", 1890000, "초경량 17인치 업무용 노트북", 8)); + productRepository.save(new Product("애플 맥북 프로 M4", 2990000, "M4 칩 탑재 고성능 노트북", 0)); + productRepository.save(new Product("소니 WH-1000XM6", 420000, "업계 최고 노이즈 캔슬링 헤드폰", 22)); + productRepository.save(new Product("로지텍 MX Master 3S", 129000, "무선 고정밀 업무용 마우스", 75)); + productRepository.save(new Product("삼성전자 65인치 QLED TV", 1590000, "Neo QLED 4K 스마트 TV", 5)); + productRepository.save(new Product("다이슨 V15 청소기", 890000, "레이저 먼지 감지 무선 청소기", 18)); + productRepository.save(new Product("닌텐도 스위치 2", 459000, "휴대·거치 겸용 게임 콘솔", 0)); + productRepository.save(new Product("애플 에어팟 프로 3", 359000, "공간 음향 지원 무선 이어폰", 55)); + + log.info("샘플 상품 20건 생성 완료"); } } } diff --git a/src/main/java/kr/ac/hansung/config/SecurityConfig.java b/src/main/java/kr/ac/hansung/config/SecurityConfig.java index bc04eca..606b62a 100644 --- a/src/main/java/kr/ac/hansung/config/SecurityConfig.java +++ b/src/main/java/kr/ac/hansung/config/SecurityConfig.java @@ -32,7 +32,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/", "/login", "/signup", "/css/**", "/js/**", "/images/**", "/favicon.ico").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") - .requestMatchers("/products/add", "/products/*/delete").hasRole("ADMIN") + .requestMatchers("/products/add", "/products/*/delete", "/products/*/edit").hasRole("ADMIN") .requestMatchers(HttpMethod.POST, "/products").hasRole("ADMIN") .anyRequest().authenticated() ) diff --git a/src/main/java/kr/ac/hansung/controller/ProductController.java b/src/main/java/kr/ac/hansung/controller/ProductController.java index 3009fc2..4f03291 100644 --- a/src/main/java/kr/ac/hansung/controller/ProductController.java +++ b/src/main/java/kr/ac/hansung/controller/ProductController.java @@ -1,11 +1,16 @@ package kr.ac.hansung.controller; import kr.ac.hansung.dto.ProductDto; +import kr.ac.hansung.entity.Product; import kr.ac.hansung.service.ProductService; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; @Controller @RequestMapping("/products") @@ -15,15 +20,27 @@ public class ProductController { private final ProductService productService; @GetMapping - public String list(Model model) { - model.addAttribute("products", productService.findAll()); - return "products/list"; - } + public String list(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "5") int size, + @RequestParam(required = false) String keyword, + Model model) { - @GetMapping("/{id}") - public String detail(@PathVariable Long id, Model model) { - model.addAttribute("product", productService.findById(id)); - return "products/detail"; + PageRequest pageRequest = PageRequest.of(page, size, Sort.by("id")); + + String normalizedKeyword = (keyword != null && !keyword.isBlank()) ? keyword : null; + + Page productPage; + + if (normalizedKeyword != null) { + productPage = productService.searchProducts(normalizedKeyword, pageRequest); + } else { + productPage = productService.getProducts(pageRequest); + } + + model.addAttribute("productPage", productPage); + model.addAttribute("keyword", normalizedKeyword); + + return "products/list"; } @GetMapping("/add") @@ -38,6 +55,37 @@ public String save(@ModelAttribute ProductDto dto) { return "redirect:/products"; } + @GetMapping("/{id}/edit") + public String editForm(@PathVariable Long id, Model model) { + Product product = productService.findById(id); + + ProductDto dto = new ProductDto(); + dto.setName(product.getName()); + dto.setPrice(product.getPrice()); + dto.setDescription(product.getDescription()); + dto.setStock(product.getStock()); + + model.addAttribute("product", dto); + model.addAttribute("productId", id); + + return "products/edit"; + } + + @PostMapping("/{id}/edit") + public String edit(@PathVariable Long id, + @ModelAttribute ProductDto dto, + RedirectAttributes ra) { + productService.updateProduct(id, dto); + ra.addFlashAttribute("successMessage", "상품이 수정되었습니다."); + return "redirect:/products"; + } + + @GetMapping("/{id}") + public String detail(@PathVariable Long id, Model model) { + model.addAttribute("product", productService.findById(id)); + return "products/detail"; + } + @PostMapping("/{id}/delete") public String delete(@PathVariable Long id) { productService.deleteById(id); diff --git a/src/main/java/kr/ac/hansung/controller/UserController.java b/src/main/java/kr/ac/hansung/controller/UserController.java new file mode 100644 index 0000000..d63254e --- /dev/null +++ b/src/main/java/kr/ac/hansung/controller/UserController.java @@ -0,0 +1,71 @@ +package kr.ac.hansung.controller; + +import jakarta.validation.Valid; +import kr.ac.hansung.dto.PasswordChangeDto; +import kr.ac.hansung.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Controller; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @GetMapping("/user/password") + public String passwordForm(org.springframework.ui.Model model) { + model.addAttribute("passwordChangeDto", new PasswordChangeDto()); + return "user/password"; + } + + @PostMapping("/user/password") + public String changePassword( + @AuthenticationPrincipal UserDetails userDetails, + @Valid @ModelAttribute("passwordChangeDto") PasswordChangeDto dto, + BindingResult bindingResult, + RedirectAttributes ra) { + + if (bindingResult.hasErrors()) { + return "user/password"; + } + + if (!dto.getNewPassword().equals(dto.getConfirmPassword())) { + bindingResult.rejectValue( + "confirmPassword", + "mismatch", + "새 비밀번호가 일치하지 않습니다" + ); + return "user/password"; + } + + try { + userService.changePassword( + userDetails.getUsername(), + dto.getCurrentPassword(), + dto.getNewPassword() + ); + + ra.addFlashAttribute( + "successMessage", + "비밀번호가 변경되었습니다" + ); + + } catch (IllegalArgumentException e) { + + bindingResult.rejectValue( + "currentPassword", + "wrong", + e.getMessage() + ); + + return "user/password"; + } + + return "redirect:/home"; + } +} \ No newline at end of file diff --git a/src/main/java/kr/ac/hansung/dto/PasswordChangeDto.java b/src/main/java/kr/ac/hansung/dto/PasswordChangeDto.java new file mode 100644 index 0000000..394d0d9 --- /dev/null +++ b/src/main/java/kr/ac/hansung/dto/PasswordChangeDto.java @@ -0,0 +1,21 @@ +package kr.ac.hansung.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PasswordChangeDto { + + @NotBlank(message = "현재 비밀번호를 입력하세요") + private String currentPassword; + + @NotBlank + @Size(min = 8, message = "새 비밀번호는 8자 이상이어야 합니다") + private String newPassword; + + @NotBlank(message = "새 비밀번호 확인을 입력하세요") + private String confirmPassword; +} \ No newline at end of file diff --git a/src/main/java/kr/ac/hansung/repository/ProductRepository.java b/src/main/java/kr/ac/hansung/repository/ProductRepository.java index f171d47..589a7d5 100644 --- a/src/main/java/kr/ac/hansung/repository/ProductRepository.java +++ b/src/main/java/kr/ac/hansung/repository/ProductRepository.java @@ -1,8 +1,15 @@ package kr.ac.hansung.repository; import kr.ac.hansung.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; public interface ProductRepository extends JpaRepository { long countByStockEquals(int stock); -} + + @Query("SELECT p FROM Product p WHERE p.name LIKE %:keyword%") + Page findByNameContaining(@Param("keyword") String keyword, Pageable pageable); +} \ No newline at end of file diff --git a/src/main/java/kr/ac/hansung/service/ProductService.java b/src/main/java/kr/ac/hansung/service/ProductService.java index 35c7f05..adecd53 100644 --- a/src/main/java/kr/ac/hansung/service/ProductService.java +++ b/src/main/java/kr/ac/hansung/service/ProductService.java @@ -4,6 +4,8 @@ import kr.ac.hansung.entity.Product; import kr.ac.hansung.repository.ProductRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,6 +23,16 @@ public List findAll() { return productRepository.findAll(); } + @Transactional(readOnly = true) + public Page getProducts(Pageable pageable) { + return productRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public Page searchProducts(String keyword, Pageable pageable) { + return productRepository.findByNameContaining(keyword, pageable); + } + @Transactional(readOnly = true) public Product findById(Long id) { return productRepository.findById(id) @@ -39,4 +51,16 @@ public Product save(ProductDto dto) { public void deleteById(Long id) { productRepository.deleteById(id); } + + @Transactional + public Product updateProduct(Long id, ProductDto dto) { + Product product = findById(id); + + product.setName(dto.getName()); + product.setPrice(dto.getPrice()); + product.setDescription(dto.getDescription()); + product.setStock(dto.getStock()); + + return product; + } } diff --git a/src/main/java/kr/ac/hansung/service/UserService.java b/src/main/java/kr/ac/hansung/service/UserService.java index 13cdfb5..9e55a0a 100644 --- a/src/main/java/kr/ac/hansung/service/UserService.java +++ b/src/main/java/kr/ac/hansung/service/UserService.java @@ -7,6 +7,7 @@ import kr.ac.hansung.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -38,4 +39,20 @@ public void signup(UserDto dto) { public boolean existsByEmail(String email) { return userRepository.existsByEmail(email); } + + @Transactional + public void changePassword(String email, + String currentPassword, + String newPassword) { + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException(email)); + + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { + throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다"); + } + + user.setPassword(passwordEncoder.encode(newPassword)); + userRepository.save(user); + } } diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html index c062c3b..ccddbdc 100644 --- a/src/main/resources/templates/admin/dashboard.html +++ b/src/main/resources/templates/admin/dashboard.html @@ -96,5 +96,8 @@
접속 계정
+
+

+
diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index d6ce33a..b0d8088 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -20,6 +20,7 @@