diff --git a/.github/workflows/azure-webapps-deploy.yml b/.github/workflows/azure-webapps-deploy.yml new file mode 100644 index 0000000..5a5cdcc --- /dev/null +++ b/.github/workflows/azure-webapps-deploy.yml @@ -0,0 +1,66 @@ +name: Build and deploy a container to an Azure Web App + +env: + AZURE_WEBAPP_NAME: java-board + +on: + push: + branches: + - main + +permissions: + contents: 'read' + packages: 'write' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Check out the repository + uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + - name: Build with Maven + run: mvn --batch-mode --update-snapshots package -DskipTests + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GitHub container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: java-board-webapp + run: echo "REPO=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV} + - name: Build and push container image to registry + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ghcr.io/${{ env.REPO }}:${{ github.sha }} + file: ./Dockerfile + + deploy: + runs-on: ubuntu-latest + needs: build + steps: + - name: java_board + run: echo "REPO=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV} + - name: Deploy to Azure Web App + id: deploy-to-webapp + uses: azure/webapps-deploy@v3 + with: + app-name: ${{ env.AZURE_WEBAPP_NAME }} + publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} + images: 'ghcr.io/${{ env.REPO }}:${{ github.sha }}' + - name: Check deployment status # deployment가 fail했을 때 main 브랜치로 push되는 것을 막기 + if: ${{ failure() }} + run: exit 1 + - name: Prevent push to main branch on deployment failure + if: ${{ failure() }} + run: git reset --hard HEAD^ \ No newline at end of file diff --git a/.github/workflows/maven-build.yml b/.github/workflows/maven-build.yml new file mode 100644 index 0000000..1b89dff --- /dev/null +++ b/.github/workflows/maven-build.yml @@ -0,0 +1,22 @@ +name: Java CI with Maven + +on: + pull_request: + branches: ["main"] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - name: Check out the repository + uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + - name: Build and test with Maven + run: mvn --batch-mode package \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ed327f6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM eclipse-temurin:17-jre as builder +WORKDIR application +ARG JAR_FILE=target/*.jar +COPY ${JAR_FILE} application.jar +RUN java -Djarmode=layertools -jar application.jar extract + +FROM eclipse-temurin:17-jre +WORKDIR application +COPY --from=builder application/dependencies/ ./ +COPY --from=builder application/spring-boot-loader/ ./ +COPY --from=builder application/snapshot-dependencies/ ./ +COPY --from=builder application/application/ ./ +ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"] diff --git a/README.md b/README.md index dc881ef..07919b2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,69 @@ +## 사용 라이브러리 +- data-jpa : spring-data-jpa로 손쉽게 datsource에 접근 하기 위함 +- thymeleaf : +- bootstrap +- devtools : 재시작 없이 하려고 +- mysql : 데이터베이스 +- lombok : @Getter, @Setter 등 간편하게 하려고 +- mockito-core : unit test를 위해 mock 객체 생성용 ## 기능 구현 목록 ### Article -- [] id, title, content, createdAt, updatedAt, writer -- [] 작성하기(Create) -- [] 개별 조회하기(retrieve) -- [] 목록 조회하기 +- [x] id, title, content, createdAt, updatedAt, writer + - [x] comments +- [x] 작성하기(Create) + - [x] writer의 값으로 User의 nickname을 넣는다. +- [x] 개별 조회하기(retrieve) +- [x] 목록 조회하기 + - [x] 페이징 적용 +- [x] 수정하기(update) + - [x] 작성한 User만 수정이 가능하게 한다. + - [x] 이를 위해선 사용자 nickname(writer)가 중복이 안 되게 해야한다. + - [x] 이를 위해 nicknme 기반으로 email 찾기 기능 추가 +- [x] 삭제하기(delete) + - [x] 작성한 User만 삭제가 가능하게 한다. + - [x] 관련된 comment를 먼저 지우고 현재 게시글이 지워지게 한다. +- [x] comments 가져오기 +### Comment +- [x] id, content, createdAt, updatedAt, article(owner) +- [x] 작성하기(create) + - [x] writer의 값으로 User의 nickname을 넣는다. +- [x] 삭제하기(delete) + - [x] 작성한 사용자만 수정가능하게 한다. + - 이를 위해 `customAuthenticationSuccessHandler`와 `CustomLogoutSuccessHandler`를 만들고 `SecurityConfig`의 필터체인에 등록해준다. + - 사용 하고 자 하는 부분인 ArticleController에 `@SessionAttributes(key)`로 등록한 세션을 접근가능하게 만든다. + - 필요한 get 함수에서 Model을 통해 view로 보내준다. + - view에서 작성한 사용자의 nickname 기반으로 게시글의 writer와 현재 로그인한 사용자의 nickname이 일치하지 않으면 삭제, 수정 버튼이 보이지 않게 한다. +- [x] 수정하기(update) + - [x] 삭제하기와 마찬가지로 작성한 사용자만 수정가능 하게 한다. +### User +- [x] id, nickname, email, password, name +- [x] 등록하기(register) - [] 수정하기(update) -- [] 삭제하기(delete) + - [x] password수정 + - 데이터베이스에 저장된 그 비밀번호와 비교할 수 있어야 한다. + - 올바른 비밀번호를 입력해도 비교가 제대로 안 되는 문제가 있었다. + - 이는 비밀번호를 encode해서 입력될 때 같은 값이여도 다르게 되는 문제 때문이다. (random salt사용) + - 정확히 authentication context의 것을 가져와서 수행한다. + - [] email 수정 + - 세션에 등록된 email도 변경 되게 해야 한다. + - [x] 이미지 수정 + - [x] nickname 수정 + - 닉네임 수정시 현재 사용자가 만든 article과 작성한 comment에 대한 닉네임을 모두 변경 + - 세션에 등록된 닉네임도 변경 +- [x] 탈퇴하기(delete) + - [x] 사용자의 저장된 프로필 이미지를 삭제한다. + - [x] 사용자의 댓글을 전부 삭제 한다. + - [x] 사용자가 작성했던 게시글을 전부 삭제한다. +- [x] 내가 쓴 게시글 목록 보기 + - [x] 페이징 처리 + - [x] 게시글의 다른 페이지를 클랙해도 기존 댓글 페이지는 유지하기 + - [x] 게시글 삭제 후, 개인정보 페이지에 있게 하기 + - [x] 게시글 보기에서 뒤로가기 버튼 클릭시, 개인정보 페이지에 있게 하기 + - [x] 사용자 정보에서 게시글 보기를 들어가서 아래 행위 후, 뒤로가기 버튼을 누르면 개인정보가 아니라 게시판 있던 부분으로 가지는 문제 + - [x] 새 댓글 작성 + - [x] 댓글 수정 + - [x] 댓글 삭제 + - [x] 게시글 수정 + - [x] 게시글 삭제 +- [x] 내가 쓴 댓글 보기, paging 처리 + - [x] 댓글의 다른 페이지를 클릭해도 기존 게시글 페이지는 유지하기 \ No newline at end of file diff --git a/pom.xml b/pom.xml index a70341a..0804bb0 100644 --- a/pom.xml +++ b/pom.xml @@ -17,33 +17,81 @@ 17 + + + + + org.springframework.boot - spring-boot-starter-data-jdbc + spring-boot-starter-data-jpa + + + + + + + + + com.h2database + h2 + runtime + + org.springframework.boot - spring-boot-starter-data-jpa + spring-boot-starter-web + org.springframework.boot spring-boot-starter-thymeleaf + + + org.webjars + bootstrap + 5.1.3 + + org.springframework.boot - spring-boot-starter-web + spring-boot-devtools + - org.projectlombok - lombok - true + org.thymeleaf.extras + thymeleaf-extras-springsecurity6 + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot spring-boot-starter-test test + + + org.mockito + mockito-core + 5.11.0 + + + + + + org.projectlombok + lombok + true + + diff --git a/src/main/java/com/y/java_board/config/SpringSecurityConfig.java b/src/main/java/com/y/java_board/config/SpringSecurityConfig.java new file mode 100644 index 0000000..8095783 --- /dev/null +++ b/src/main/java/com/y/java_board/config/SpringSecurityConfig.java @@ -0,0 +1,81 @@ +package com.y.java_board.config; + +import com.y.java_board.config.security.CustomAuthenticationSuccessHandler; +import com.y.java_board.config.security.CustomLogoutSuccessHandler; +import com.y.java_board.config.security.CustomUserDetailsService; +import lombok.AllArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@AllArgsConstructor +public class SpringSecurityConfig { + + private final CustomUserDetailsService userDetailsService; + private final CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler; + private final CustomLogoutSuccessHandler customLogoutSuccessHandler; + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @Bean + public InMemoryUserDetailsManager userDetailsManager() { + UserDetails admin = User.withUsername("admin") + .password(passwordEncoder().encode("1234")) + .roles("ADMIN") + .build(); + + UserDetails user1 = User.withUsername("user1") + .password(passwordEncoder().encode("1234")) + .roles("USER") + .build(); + + return new InMemoryUserDetailsManager(admin, user1); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .httpBasic(HttpBasicConfigurer::disable) + .authorizeHttpRequests(auth -> + auth.requestMatchers("/", "/user/register", + "/webjars/**", "/css/**", "/js/**").permitAll() + .anyRequest().authenticated() + ) + .formLogin(login -> + login.successHandler(customAuthenticationSuccessHandler)) + .logout(logout -> + logout.logoutSuccessHandler(customLogoutSuccessHandler) + .permitAll() + ); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(userDetailsService); + provider.setPasswordEncoder(passwordEncoder()); + return new ProviderManager(provider); + } +} diff --git a/src/main/java/com/y/java_board/config/UserInfoSession.java b/src/main/java/com/y/java_board/config/UserInfoSession.java new file mode 100644 index 0000000..92daa48 --- /dev/null +++ b/src/main/java/com/y/java_board/config/UserInfoSession.java @@ -0,0 +1,17 @@ +package com.y.java_board.config; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Builder +@AllArgsConstructor +public class UserInfoSession { + private String email; + private String nickname; + private String profileImage; + private String name; +} diff --git a/src/main/java/com/y/java_board/config/security/CustomAuthenticationSuccessHandler.java b/src/main/java/com/y/java_board/config/security/CustomAuthenticationSuccessHandler.java new file mode 100644 index 0000000..96979fb --- /dev/null +++ b/src/main/java/com/y/java_board/config/security/CustomAuthenticationSuccessHandler.java @@ -0,0 +1,47 @@ +package com.y.java_board.config.security; + +import com.y.java_board.config.UserInfoSession; +import com.y.java_board.domain.User; +import com.y.java_board.repository.UserRepository; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler { + + private final UserRepository userRepository; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) + throws IOException, ServletException { + HttpSession session = request.getSession(false); + if (session != null) { + String email = authentication.getName(); + User user = userRepository.findByEmail(email).get(); + String nickname = user.getNickname(); + String name = user.getName(); + String profileImg = "default_profile.png"; + if (user.getProfileImage() != null && !user.getProfileImage().isEmpty()) { + profileImg = user.getProfileImage(); + } + session.setAttribute("userInfo", UserInfoSession.builder() + .nickname(nickname) + .email(email) + .name(name) + .profileImage(profileImg) + .build() + ); + } + response.sendRedirect("/"); + } +} diff --git a/src/main/java/com/y/java_board/config/security/CustomLogoutSuccessHandler.java b/src/main/java/com/y/java_board/config/security/CustomLogoutSuccessHandler.java new file mode 100644 index 0000000..505dbc4 --- /dev/null +++ b/src/main/java/com/y/java_board/config/security/CustomLogoutSuccessHandler.java @@ -0,0 +1,24 @@ +package com.y.java_board.config.security; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class CustomLogoutSuccessHandler implements LogoutSuccessHandler { + @Override + public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + HttpSession session = request.getSession(); + if (session != null) { + session.removeAttribute("userInfo"); + session.invalidate(); + } + response.sendRedirect("/"); + } +} diff --git a/src/main/java/com/y/java_board/config/security/CustomUserDetailsService.java b/src/main/java/com/y/java_board/config/security/CustomUserDetailsService.java new file mode 100644 index 0000000..5eaac6a --- /dev/null +++ b/src/main/java/com/y/java_board/config/security/CustomUserDetailsService.java @@ -0,0 +1,25 @@ +package com.y.java_board.config.security; + +import com.y.java_board.domain.User; +import com.y.java_board.repository.UserRepository; +import lombok.AllArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@AllArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + private UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Optional user = userRepository.findByEmail(username); + return user.map(UserDetailModel::new).orElseThrow( + () -> new UsernameNotFoundException("[사용자 인증 파트] 타당하지 않은 이메일!") + ); + } +} diff --git a/src/main/java/com/y/java_board/config/security/UserDetailModel.java b/src/main/java/com/y/java_board/config/security/UserDetailModel.java new file mode 100644 index 0000000..d407adb --- /dev/null +++ b/src/main/java/com/y/java_board/config/security/UserDetailModel.java @@ -0,0 +1,52 @@ +package com.y.java_board.config.security; + +import com.y.java_board.domain.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; + +public class UserDetailModel implements UserDetails { + private final String userEmail; + private final String password; + + public UserDetailModel(User user) { + this.userEmail = user.getEmail(); + this.password = user.getPassword(); + } + + @Override + public Collection getAuthorities() { + return null; + } + + @Override + public String getPassword() { + return this.password; + } + + @Override + public String getUsername() { + return this.userEmail; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/y/java_board/controller/ArticleController.java b/src/main/java/com/y/java_board/controller/ArticleController.java new file mode 100644 index 0000000..a503740 --- /dev/null +++ b/src/main/java/com/y/java_board/controller/ArticleController.java @@ -0,0 +1,141 @@ +package com.y.java_board.controller; + +import com.y.java_board.config.UserInfoSession; +import com.y.java_board.domain.Article; +import com.y.java_board.domain.Comment; +import com.y.java_board.dto.ArticleDto; +import com.y.java_board.service.ArticleService; +import com.y.java_board.service.CommentService; +import lombok.AllArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.util.List; +import java.util.Optional; + +@Controller +@AllArgsConstructor +@SessionAttributes("userInfo") +public class ArticleController { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + private final ArticleService articleService; + private final CommentService commentService; + + @GetMapping("/articles") + public String articles( + Model model, + @RequestParam(value = "currentPage", required = false, defaultValue = "1") int currentPage + ) { + pagingArticles(model, currentPage); + return "article/articles"; + } + + @GetMapping("/articles/{pageNumber}") + public String pagingArticles(Model model, @PathVariable("pageNumber") int currentPage) { + Page
page = articleService.findPagingArticles(currentPage); + long totalItems = page.getTotalElements(); + int totalPages = page.getTotalPages(); + + List
articles = page.getContent(); + + model.addAttribute("totalArticles", totalItems); + model.addAttribute("totalPages", totalPages); + model.addAttribute("currentPage", currentPage); + model.addAttribute("articles", articles); + return "redirect:/articles"; + } + + @GetMapping("/articles/new") + public String showCreateForm(Model model, @ModelAttribute("userInfo") UserInfoSession userInfoSession) { + model.addAttribute("articleDto", new ArticleDto("", "", userInfoSession.getNickname())); + return "article/create"; + } + + @PostMapping("/articles") + public String create(ArticleDto articleDto, BindingResult result, Model model) { + if (result.hasErrors()) { + // model.addAttribute() 오류의 원인을 보낸다. + return "redirect:/articles/new"; + } + articleService.createOne(articleDto.toEntity()); + return "redirect:/articles"; + } + + @GetMapping("/articles/detail/{id}") + public String showArticle( + @PathVariable("id") long id, + Model model, + @ModelAttribute("userInfo") UserInfoSession userInfoSession, + @RequestParam(value = "info", required = false) boolean isFromInfo + ) { + Optional
articleOptional = articleService.findOne(id); + if (articleOptional.isPresent()) { + List comments = commentService.findCommentsByArticleId(id); + model.addAttribute("article", articleOptional.get()); + model.addAttribute("comments", comments); + model.addAttribute("isFromInfo", isFromInfo); + return "article/detail"; + } + // TODO : 해당 id 가 없다는 안내가 필요할 것 같다. + return "redirect:/articles"; + } + + @DeleteMapping("/articles/{id}") + public String deleteArticle( + @PathVariable("id") long id, + @ModelAttribute("userInfo") UserInfoSession userInfoSession, + @RequestParam(value = "info", required = false) boolean isFromInfo + ) { + Article article = articleService.findOne(id) + .orElseThrow(() -> new IllegalArgumentException("Invalid article Id : " + id)); + articleService.deleteOne(id); + if (isFromInfo) { + return "redirect:/user/info"; + } + return "redirect:/articles"; + } + + @GetMapping("/articles/update/{id}") + public String showUpdateForm( + @PathVariable long id, + Model model, + @ModelAttribute("userInfo") UserInfoSession userInfoSession, + @RequestParam(value = "info", required = false, defaultValue = "false") boolean isFromInfo + ) { + Article article = articleService.findOne(id) + .orElseThrow(() -> new IllegalArgumentException("Invalid article Id: " + id)); + + model.addAttribute("articleDto", new ArticleDto(article.getTitle(), article.getContent(), article.getWriter())); + model.addAttribute("isFromInfo", isFromInfo); + return "article/update"; + } + + @PutMapping("/articles/{id}") + public String updateArticle( + @PathVariable("id") long id, + ArticleDto articleDto, + BindingResult result, + @RequestParam(value = "info", required = false, defaultValue = "false") boolean isFromInfo + ) { + if (result.hasErrors()) { + // TODO : 오류 메시지 등 처리 + return "redirect:/articles/detail/{id}"; + } + Article article = articleDto.toEntity(); + article.setId(id); + + articleService.updateOne(article); + if (isFromInfo) { + return "redirect:/articles/detail/{id}?info=true"; + } + return "redirect:/articles/detail/{id}"; + } + +} diff --git a/src/main/java/com/y/java_board/controller/CommentController.java b/src/main/java/com/y/java_board/controller/CommentController.java new file mode 100644 index 0000000..75c703f --- /dev/null +++ b/src/main/java/com/y/java_board/controller/CommentController.java @@ -0,0 +1,56 @@ +package com.y.java_board.controller; + +import com.y.java_board.config.UserInfoSession; +import com.y.java_board.dto.CommentDto; +import com.y.java_board.service.CommentService; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + + +@Controller +@AllArgsConstructor +@SessionAttributes("userInfo") +public class CommentController { + + private final CommentService commentService; + + @PostMapping("/articles/{articleId}/comments") + public String create( + @PathVariable Long articleId, + @ModelAttribute("userInfo") UserInfoSession userInfoSession, + CommentDto dto, + @RequestParam(value = "info", required = false, defaultValue = "false") boolean isFromInfo + ) { + CommentDto commentDto = new CommentDto(userInfoSession.getNickname(), dto.content(), articleId); + commentService.createOne(commentDto, articleId); + if (isFromInfo) { + return "redirect:/articles/detail/{articleId}?info=true"; + } + return "redirect:/articles/detail/{articleId}"; + } + + @DeleteMapping("/articles/{articleId}/comments/{id}") + public String delete( + @PathVariable Long id, + @PathVariable Long articleId, + @RequestParam(value = "info", required = false, defaultValue = "false") boolean isFromInfo + ) { + commentService.deleteComment(id); + if (isFromInfo) { + return "redirect:/articles/detail/{articleId}?info=true"; + } + return "redirect:/articles/detail/{articleId}"; + } + + + @PutMapping("/articles/{articleId}/comments/{id}") + public String update(@PathVariable Long articleId, @PathVariable Long id, String content, @RequestParam(value = "info", required = false, defaultValue = "false") boolean isFromInfo) { + commentService.updateComment(id, content); + if (isFromInfo) { + return "redirect:/articles/detail/{articleId}?info=true"; + } + return "redirect:/articles/detail/{articleId}"; + } + +} diff --git a/src/main/java/com/y/java_board/controller/HomeController.java b/src/main/java/com/y/java_board/controller/HomeController.java new file mode 100644 index 0000000..983dd79 --- /dev/null +++ b/src/main/java/com/y/java_board/controller/HomeController.java @@ -0,0 +1,13 @@ +package com.y.java_board.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class HomeController { + + @GetMapping("/") + public String home() { + return "home"; + } +} diff --git a/src/main/java/com/y/java_board/controller/UserController.java b/src/main/java/com/y/java_board/controller/UserController.java new file mode 100644 index 0000000..ae3eaab --- /dev/null +++ b/src/main/java/com/y/java_board/controller/UserController.java @@ -0,0 +1,185 @@ +package com.y.java_board.controller; + +import com.y.java_board.config.UserInfoSession; +import com.y.java_board.domain.Article; +import com.y.java_board.domain.Comment; +import com.y.java_board.domain.User; +import com.y.java_board.dto.UserDto; +import com.y.java_board.service.ArticleService; +import com.y.java_board.service.CommentService; +import com.y.java_board.service.ImageService; +import com.y.java_board.service.UserService; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.io.IOException; +import java.util.Base64; +import java.util.List; + +@Controller +@RequiredArgsConstructor +@SessionAttributes("userInfo") +public class UserController { + private final String PROFILE_IMG_DIRECTORY = "src/main/resources/static/images/profile"; + + private final UserService userService; + private final ImageService imageService; + private final CommentService commentService; + private final ArticleService articleService; + private final HttpSession session; + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @GetMapping("/user/register") + public String showSignUpForm(ModelMap model) { + model.addAttribute("userDto", new User()); + return "user/signup"; + } + + @PostMapping("/user/register") + public String signUp(UserDto userDto, RedirectAttributes redirectAttributes) { + try { + User registered = userService.registerNewUserAccount(userDto); + return "redirect:/"; + } catch (IllegalStateException e) { + redirectAttributes.addFlashAttribute("error", e.getMessage()); + return "redirect:/user/register"; + } + } + + @GetMapping("/user/info") + public String showUserInfo( + ModelMap model, + @ModelAttribute("userInfo") UserInfoSession userInfoSession, + @RequestParam(name = "commentPage", required = false, defaultValue = "1") int commentPage, + @RequestParam(name = "articlePage", required = false, defaultValue = "1") int articlePage) { + try { + byte[] profileImageBytes = imageService.getImage(PROFILE_IMG_DIRECTORY, userInfoSession.getProfileImage()); // Unhandled exception : java.io.IOException + String profileImageBase64 = Base64.getEncoder().encodeToString(profileImageBytes); + model.put("profileImage", profileImageBase64); + articlesByPage(model, articlePage, userInfoSession.getNickname()); + commentsByPage(model, commentPage, userInfoSession.getNickname()); + } catch (IOException e) { + model.put("profileImage", ""); + } + return "user/info"; + } + + @GetMapping("/user/profile") + public String showUserProfileForm(@ModelAttribute("userInfo") UserInfoSession userInfoSession) { + return "user/updateProfile"; + } + + @PutMapping("/user/profile") + public String updateUserProfile( + @RequestParam("nickname") String nickname, + @RequestParam("profileImg") MultipartFile profileImage, + @ModelAttribute("userInfo") UserInfoSession userInfoSession) throws IOException { + + if (profileImage.isEmpty()) { + userService.updateUserProfile(User.builder() + .nickname(nickname) + .email(userInfoSession.getEmail()) + .build()); + } else { + String previousProfileImagePath = userInfoSession.getProfileImage(); + if (!previousProfileImagePath.isEmpty() && !previousProfileImagePath.equals("default_profile.png")) { + imageService.deleteImage(PROFILE_IMG_DIRECTORY, previousProfileImagePath); + } + String profileImageString = imageService.saveImageToStorage(PROFILE_IMG_DIRECTORY, profileImage); + + userService.updateUserProfile(User.builder() + .profileImage(profileImageString) + .nickname(nickname) + .email(userInfoSession.getEmail()) + .build()); + + userInfoSession.setProfileImage(profileImageString); + } + + userInfoSession.setNickname(nickname); + return "redirect:/user/info"; + } + + + @DeleteMapping("/user/{email}") + public String deleteUser( + @PathVariable("email") String email, + @ModelAttribute("userInfo") UserInfoSession userInfoSession + ) throws IOException { + String profileImg = userInfoSession.getProfileImage(); + userService.deleteUser(email); + if (!profileImg.equals("default_profile.png")) + imageService.deleteImage(PROFILE_IMG_DIRECTORY, profileImg); + session.invalidate(); + return "redirect:/"; + } + + @GetMapping("/commentInfo/{commentPageNumber}") + public String commentsByPage(ModelMap model, @PathVariable("commentPageNumber") int currentPage, String nickname) { + Page page = commentService.findCommentByNicknameAndPaginate(nickname, currentPage); + long totalItems = page.getTotalElements(); + int totalPages = page.getTotalPages(); + + List myComments = page.getContent(); + + model.put("myCommentTotalItems", totalItems); + model.put("myCommentTotalPages", totalPages); + model.put("myCommentCurrentPage", currentPage); + model.put("myComments", myComments); + return "redirect:/user/info"; + } + + @GetMapping("/articleInfo/{articlePageNumber}") + public String articlesByPage(ModelMap model, @PathVariable("articlePageNumber") int currentPage, String nickname) { + Page
page = articleService.findPagingArticlesByWriter(currentPage, nickname); + long totalItems = page.getTotalElements(); + int totalPages = page.getTotalPages(); + + List
myArticles = page.getContent(); + + model.put("myArticleTotalItems", totalItems); + model.put("myArticleTotalPages", totalPages); + model.put("myArticleCurrentPage", currentPage); + model.put("myArticles", myArticles); + + return "redirect:/user/info"; + } + + @DeleteMapping("/comments/{commentId}") + public String deleteCommentById(@PathVariable Long commentId) { + commentService.deleteComment(commentId); + return "redirect:/user/info"; + } + + @GetMapping("/user/password") + public String showUserPasswordForm() { + return "user/updatePassword"; + } + + @PutMapping("/user/updatePassword") + public String updatePassword( + @RequestParam("password") String password, + @RequestParam("oldPassword") String oldPassword, + RedirectAttributes redirectAttributes + ) { + User user = userService.findUserByEmail(SecurityContextHolder.getContext().getAuthentication().getName()); + if (!userService.checkIfValidOldPassword(user, oldPassword)) { + redirectAttributes.addFlashAttribute("isWrong", true); + return "redirect:/user/password"; + } + userService.changeUserPassword(user, password); + return "redirect:/user/info"; + } + + +} diff --git a/src/main/java/com/y/java_board/domain/Article.java b/src/main/java/com/y/java_board/domain/Article.java new file mode 100644 index 0000000..76182d5 --- /dev/null +++ b/src/main/java/com/y/java_board/domain/Article.java @@ -0,0 +1,43 @@ +package com.y.java_board.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@Entity +public class Article { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + private String title; + private String content; + private String writer; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @OneToMany(mappedBy = "article") + private List comments; + + public Article() { + createdAt = updatedAt = LocalDateTime.now(); + } + + public Article(String title, String content, String writer) { + this.title = title; + this.content = content; + this.writer = writer; + this.createdAt = this.updatedAt = LocalDateTime.now(); + } + + public void patch(Article article) { + this.title = article.title; + this.content = article.content; + this.writer = article.writer; + this.updatedAt = LocalDateTime.now(); + } + +} diff --git a/src/main/java/com/y/java_board/domain/Comment.java b/src/main/java/com/y/java_board/domain/Comment.java new file mode 100644 index 0000000..7690f77 --- /dev/null +++ b/src/main/java/com/y/java_board/domain/Comment.java @@ -0,0 +1,42 @@ +package com.y.java_board.domain; + +import com.y.java_board.dto.CommentDto; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +public class Comment { + @Id + @GeneratedValue + private Long id; + private String writer; + private String content; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @ManyToOne + private Article article; + + public Comment() { + createdAt = updatedAt = LocalDateTime.now(); + } + + public Comment(String writer, String content, Article article) { + this.writer = writer; + this.content = content; + this.article = article; + createdAt = updatedAt = LocalDateTime.now(); + } + + public static Comment createComment(CommentDto commentDto, Article article) { + return new Comment(commentDto.writer(), commentDto.content(), article); + } + +} diff --git a/src/main/java/com/y/java_board/domain/User.java b/src/main/java/com/y/java_board/domain/User.java new file mode 100644 index 0000000..f8f82dc --- /dev/null +++ b/src/main/java/com/y/java_board/domain/User.java @@ -0,0 +1,32 @@ +package com.y.java_board.domain; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Data +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class User { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + @Column(nullable = false, unique = true, length = 45) + private String email; + private String password; + private String name; + private String nickname; + private String profileImage; + + public User(String email, String password, String name, String nickname) { + this.email = email; + this.password = password; + this.name = name; + this.nickname = nickname; + } + +} diff --git a/src/main/java/com/y/java_board/dto/ArticleDto.java b/src/main/java/com/y/java_board/dto/ArticleDto.java new file mode 100644 index 0000000..a0d1775 --- /dev/null +++ b/src/main/java/com/y/java_board/dto/ArticleDto.java @@ -0,0 +1,10 @@ +package com.y.java_board.dto; + +import com.y.java_board.domain.Article; + +public record ArticleDto(String title, String content, String writer) { + + public Article toEntity() { + return new Article(title, content, writer); + } +} diff --git a/src/main/java/com/y/java_board/dto/CommentDto.java b/src/main/java/com/y/java_board/dto/CommentDto.java new file mode 100644 index 0000000..014eafe --- /dev/null +++ b/src/main/java/com/y/java_board/dto/CommentDto.java @@ -0,0 +1,5 @@ +package com.y.java_board.dto; + +public record CommentDto(String writer, String content, Long articleId) { + +} diff --git a/src/main/java/com/y/java_board/dto/UserDto.java b/src/main/java/com/y/java_board/dto/UserDto.java new file mode 100644 index 0000000..8506f95 --- /dev/null +++ b/src/main/java/com/y/java_board/dto/UserDto.java @@ -0,0 +1,26 @@ +package com.y.java_board.dto; + +import com.y.java_board.domain.User; + +import java.util.regex.Pattern; + +public record UserDto(String email, String password, String name, String nickname) { + private static final Pattern emailPattern = Pattern.compile("^(.+)@(\\S+)$"); + + public UserDto { + if (email == null || password == null || name == null || nickname == null) + throw new IllegalArgumentException("[정보 입력 오류] 회원 가입 정보는 들어가야 합니다!"); + validateEmail(email); + } + + public User toEntity() { + return new User(email, password, name, nickname); + } + + private void validateEmail(String email) { + if (!emailPattern.matcher(email).matches()) { + throw new IllegalArgumentException("[이메일 주소 오류] 이메일 주소 형식에 맞지 않습니다."); + } + } + +} diff --git a/src/main/java/com/y/java_board/repository/ArticleRepository.java b/src/main/java/com/y/java_board/repository/ArticleRepository.java new file mode 100644 index 0000000..e4a8177 --- /dev/null +++ b/src/main/java/com/y/java_board/repository/ArticleRepository.java @@ -0,0 +1,19 @@ +package com.y.java_board.repository; + +import com.y.java_board.domain.Article; + +import java.util.List; +import java.util.Optional; + +public interface ArticleRepository { + + Article save(Article article); + + Optional
findById(Long id); + + List
findByWriter(String writer); + + List
findAll(); + + void deleteById(Long id); +} diff --git a/src/main/java/com/y/java_board/repository/CommentRepository.java b/src/main/java/com/y/java_board/repository/CommentRepository.java new file mode 100644 index 0000000..8bf29fe --- /dev/null +++ b/src/main/java/com/y/java_board/repository/CommentRepository.java @@ -0,0 +1,24 @@ +package com.y.java_board.repository; + +import com.y.java_board.domain.Comment; + +import java.util.List; +import java.util.Optional; + +public interface CommentRepository { + + Comment save(Comment comment); + + Optional findById(Long id); + + List findByArticleId(Long articleId); + + List findByWriter(String writer); + + List findAll(); + + void deleteById(Long id); + + void deleteByArticleId(Long articleId); + +} diff --git a/src/main/java/com/y/java_board/repository/UserRepository.java b/src/main/java/com/y/java_board/repository/UserRepository.java new file mode 100644 index 0000000..0c4bde9 --- /dev/null +++ b/src/main/java/com/y/java_board/repository/UserRepository.java @@ -0,0 +1,17 @@ +package com.y.java_board.repository; + +import com.y.java_board.domain.User; + +import java.util.Optional; + +public interface UserRepository { + User save(User user); + + Optional findById(Long id); + + Optional findByEmail(String email); + + Optional findByNickname(String nickname); + + void deleteById(Long id); +} diff --git a/src/main/java/com/y/java_board/repository/impl/MemoryArticleRepository.java b/src/main/java/com/y/java_board/repository/impl/MemoryArticleRepository.java new file mode 100644 index 0000000..fe08d50 --- /dev/null +++ b/src/main/java/com/y/java_board/repository/impl/MemoryArticleRepository.java @@ -0,0 +1,61 @@ +package com.y.java_board.repository.impl; + +import com.y.java_board.domain.Article; +import com.y.java_board.repository.ArticleRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@Repository +public class MemoryArticleRepository implements ArticleRepository { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private static final Map store = new ConcurrentHashMap<>(); + private static long sequence = 0L; + + @Override + public Article save(Article article) { + if (article.getId() == null) { + article.setId(++sequence); + store.put(article.getId(), article); + } else { + store.put(article.getId(), article); + } + return article; + } + + @Override + public Optional
findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List
findByWriter(String writer) { + return store.values().stream() + .filter(article -> article.getWriter().equals(writer)) + .collect(Collectors.toList()); + } + + @Override + public List
findAll() { + return new ArrayList<>(store.values()); + } + + @Override + public void deleteById(Long id) { + store.remove(id); + } + + public void clearStore() { + store.clear(); + } + +} diff --git a/src/main/java/com/y/java_board/repository/impl/PagingArticleRepository.java b/src/main/java/com/y/java_board/repository/impl/PagingArticleRepository.java new file mode 100644 index 0000000..c80c26a --- /dev/null +++ b/src/main/java/com/y/java_board/repository/impl/PagingArticleRepository.java @@ -0,0 +1,12 @@ +package com.y.java_board.repository.impl; + +import com.y.java_board.domain.Article; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PagingArticleRepository extends PagingAndSortingRepository { + Page
findByWriter(String nickname, Pageable pageable); +} diff --git a/src/main/java/com/y/java_board/repository/impl/PagingCommentRepository.java b/src/main/java/com/y/java_board/repository/impl/PagingCommentRepository.java new file mode 100644 index 0000000..db30873 --- /dev/null +++ b/src/main/java/com/y/java_board/repository/impl/PagingCommentRepository.java @@ -0,0 +1,12 @@ +package com.y.java_board.repository.impl; + +import com.y.java_board.domain.Comment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PagingCommentRepository extends PagingAndSortingRepository { + Page findByWriter(String nickname, Pageable pageable); +} diff --git a/src/main/java/com/y/java_board/repository/impl/SpringDataJpaArticleRepository.java b/src/main/java/com/y/java_board/repository/impl/SpringDataJpaArticleRepository.java new file mode 100644 index 0000000..012b41e --- /dev/null +++ b/src/main/java/com/y/java_board/repository/impl/SpringDataJpaArticleRepository.java @@ -0,0 +1,13 @@ +package com.y.java_board.repository.impl; + +import com.y.java_board.domain.Article; +import com.y.java_board.repository.ArticleRepository; +import org.springframework.context.annotation.Primary; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +@Primary +public interface SpringDataJpaArticleRepository extends JpaRepository, ArticleRepository { + +} diff --git a/src/main/java/com/y/java_board/repository/impl/SpringDataJpaCommentRepository.java b/src/main/java/com/y/java_board/repository/impl/SpringDataJpaCommentRepository.java new file mode 100644 index 0000000..76566e4 --- /dev/null +++ b/src/main/java/com/y/java_board/repository/impl/SpringDataJpaCommentRepository.java @@ -0,0 +1,11 @@ +package com.y.java_board.repository.impl; + +import com.y.java_board.domain.Comment; +import com.y.java_board.repository.CommentRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface SpringDataJpaCommentRepository extends CommentRepository, JpaRepository { + +} diff --git a/src/main/java/com/y/java_board/repository/impl/SpringDataJpaUserRepository.java b/src/main/java/com/y/java_board/repository/impl/SpringDataJpaUserRepository.java new file mode 100644 index 0000000..1b9d96f --- /dev/null +++ b/src/main/java/com/y/java_board/repository/impl/SpringDataJpaUserRepository.java @@ -0,0 +1,11 @@ +package com.y.java_board.repository.impl; + +import com.y.java_board.domain.User; +import com.y.java_board.repository.UserRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface SpringDataJpaUserRepository extends UserRepository, JpaRepository { + +} diff --git a/src/main/java/com/y/java_board/service/ArticleService.java b/src/main/java/com/y/java_board/service/ArticleService.java new file mode 100644 index 0000000..c5f22fd --- /dev/null +++ b/src/main/java/com/y/java_board/service/ArticleService.java @@ -0,0 +1,70 @@ +package com.y.java_board.service; + +import com.y.java_board.domain.Article; +import com.y.java_board.repository.ArticleRepository; +import com.y.java_board.repository.CommentRepository; +import com.y.java_board.repository.impl.PagingArticleRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ArticleService { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + private final ArticleRepository articleRepository; + private final CommentRepository commentRepository; + private final PagingArticleRepository pagingArticleRepository; + + + public List
findArticles() { + return articleRepository.findAll(); + } + + public Optional
findOne(Long id) { + return articleRepository.findById(id); + } + + public Long createOne(Article article) { + // 중복 검사, null 검사 등 수행 + articleRepository.save(article); + return article.getId(); + } + + @Transactional + public void deleteOne(Long id) { + commentRepository.deleteByArticleId(id); + articleRepository.findById(id) + .ifPresent(article -> articleRepository.deleteById(id)); + } + + public Long updateOne(Article article) { + Article origin = articleRepository.findById(article.getId()) + .orElseThrow(() -> new NoSuchElementException("The id doesn't exist!")); + origin.patch(article); + articleRepository.save(origin); + return origin.getId(); + } + + public Page
findPagingArticles(int pageNumber) { + Pageable pageable = PageRequest.of(pageNumber - 1, 10); + return pagingArticleRepository.findAll(pageable); + } + + public Page
findPagingArticlesByWriter(int pageNumber, String nickname) { + Pageable pageable = PageRequest.of(pageNumber - 1, 3); + return pagingArticleRepository.findByWriter(nickname, pageable); + } + + +} diff --git a/src/main/java/com/y/java_board/service/CommentService.java b/src/main/java/com/y/java_board/service/CommentService.java new file mode 100644 index 0000000..d69b96b --- /dev/null +++ b/src/main/java/com/y/java_board/service/CommentService.java @@ -0,0 +1,55 @@ +package com.y.java_board.service; + +import com.y.java_board.domain.Article; +import com.y.java_board.domain.Comment; +import com.y.java_board.dto.CommentDto; +import com.y.java_board.repository.ArticleRepository; +import com.y.java_board.repository.CommentRepository; +import com.y.java_board.repository.impl.PagingCommentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class CommentService { + private final CommentRepository commentRepository; + private final PagingCommentRepository pagingCommentRepository; + private final ArticleRepository articleRepository; + + public Comment createOne(CommentDto dto, Long articleId) { + Optional
articleOptional = articleRepository.findById(articleId); + if (articleOptional.isEmpty()) { + throw new IllegalArgumentException(); + } + Comment comment = Comment.createComment(dto, articleOptional.get()); + return commentRepository.save(comment); + } + + public List findCommentsByArticleId(Long articleId) { + return commentRepository.findByArticleId(articleId); + } + + public void deleteComment(Long id) { + commentRepository.deleteById(id); + } + + public Comment updateComment(Long id, String content) { + Comment origin = commentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 comment 입니다.")); + origin.setUpdatedAt(LocalDateTime.now()); + origin.setContent(content); + return commentRepository.save(origin); + } + + public Page findCommentByNicknameAndPaginate(String nickname, int pageNumber) { + Pageable pageable = PageRequest.of(pageNumber - 1, 3); + return pagingCommentRepository.findByWriter(nickname, pageable); + } +} diff --git a/src/main/java/com/y/java_board/service/ImageService.java b/src/main/java/com/y/java_board/service/ImageService.java new file mode 100644 index 0000000..043dc94 --- /dev/null +++ b/src/main/java/com/y/java_board/service/ImageService.java @@ -0,0 +1,43 @@ +package com.y.java_board.service; + +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.UUID; + +@Service +public class ImageService { + + public String saveImageToStorage(String uploadDirectory, MultipartFile imageFile) throws IOException { + String uniqueFileName = UUID.randomUUID() + "_" + imageFile.getOriginalFilename(); + + Path uploadPath = Path.of(uploadDirectory); + Path filePath = uploadPath.resolve(uniqueFileName); + + if (!Files.exists(uploadPath)) { + Files.createDirectories(uploadPath); + } + + Files.copy(imageFile.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); + + return uniqueFileName; + } + + public byte[] getImage(String imageDirectory, String imageName) throws IOException { + Path imagePath = Path.of(imageDirectory, imageName); + return Files.readAllBytes(imagePath); + } + + public String deleteImage(String imageDirectory, String imageName) throws IOException { + Path imagePath = Path.of(imageDirectory, imageName); + if (Files.exists(imagePath)) { + Files.delete(imagePath); + return "Success"; + } + return "Failed"; // handle missing images; + } +} diff --git a/src/main/java/com/y/java_board/service/UserService.java b/src/main/java/com/y/java_board/service/UserService.java new file mode 100644 index 0000000..ef58580 --- /dev/null +++ b/src/main/java/com/y/java_board/service/UserService.java @@ -0,0 +1,95 @@ +package com.y.java_board.service; + + +import com.y.java_board.domain.User; +import com.y.java_board.dto.UserDto; +import com.y.java_board.repository.ArticleRepository; +import com.y.java_board.repository.CommentRepository; +import com.y.java_board.repository.UserRepository; +import jakarta.transaction.Transactional; +import lombok.AllArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.SessionAttributes; + +import java.util.Optional; + +@Service +@AllArgsConstructor +@SessionAttributes("loggedInUser") +public class UserService { + + private final UserRepository userRepository; + private final ArticleRepository articleRepository; + private final CommentRepository commentRepository; + private final PasswordEncoder passwordEncoder; + + public User registerNewUserAccount(UserDto userDto) throws IllegalStateException { + if (emailExists(userDto.email())) { + throw new IllegalStateException("중복 이메일"); + } + if (nicknameExists(userDto.nickname())) { + throw new IllegalStateException("중복 닉네임"); + } + User user = userDto.toEntity(); + user.setPassword(passwordEncoder.encode(userDto.password())); + return userRepository.save(user); + } + + public User updateUserProfile(User user) { + Optional userOptional = userRepository.findByEmail(user.getEmail()); + if (userOptional.isPresent()) { + String existingNickname = userOptional.get().getNickname(); + + userOptional.get().setNickname(user.getNickname()); + if (user.getProfileImage() != null) { + userOptional.get().setProfileImage(user.getProfileImage()); + } + + if (!existingNickname.equals(user.getNickname())) { + articleRepository.findByWriter(existingNickname) + .forEach(article -> article.setWriter(user.getNickname())); + commentRepository.findByWriter(existingNickname) + .forEach(comment -> comment.setWriter(user.getNickname())); + } + return userRepository.save(userOptional.get()); + } + throw new IllegalStateException("[존재하지 않는 이메일] 해당 이메일 사용자가 없어서 프로필 정보를 업데이트 할 수 없습니다."); + } + + @Transactional + public void deleteUser(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new IllegalStateException("[존재하지 않는 이메일] 해당 이메일의 사용자가 없어서 삭제할 수 없습니다.")); + commentRepository.findByWriter(user.getNickname()) + .forEach(comment -> commentRepository.deleteById(comment.getId())); + articleRepository.findByWriter(user.getNickname()) + .forEach(article -> { + commentRepository.deleteByArticleId(article.getId()); + articleRepository.deleteById(article.getId()); + }); + userRepository.deleteById(user.getId()); + } + + public void changeUserPassword(User user, String password) { + user.setPassword(passwordEncoder.encode(password)); + userRepository.save(user); + } + + public boolean checkIfValidOldPassword(final User user, final String oldPassword) { + return passwordEncoder.matches(oldPassword, user.getPassword()); + } + + public User findUserByEmail(String email) { + return userRepository.findByEmail(email) + .orElseThrow(() -> new IllegalStateException("[존재하지 않는 이메일] ")); + } + + private boolean emailExists(String email) { + return userRepository.findByEmail(email).isPresent(); + } + + private boolean nicknameExists(String nickname) { + return userRepository.findByNickname(nickname).isPresent(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a174d89..101ec5b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,25 @@ spring.application.name=java_board + +# MySQL +#spring.datasource.url=jdbc:mysql://localhost:3306/board +#spring.datasource.username=kim +#spring.datasource.password=dummy + +# H2 +spring.datasource.url=jdbc:h2:mem:mydb +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.h2.console.enabled=true +#spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.globally_quoted_identifiers=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect + +spring.jpa.show-sql=true + +spring.jpa.hibernate.ddl-auto=update +#spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect + +spring.mvc.hiddenmethod.filter.enabled=true + +server.port=80 \ No newline at end of file diff --git "a/src/main/resources/static/images/profile/0e8a703d-5f72-40cb-9a73-03b9ada98470_\354\212\244\355\201\254\353\246\260\354\203\267 2024-01-29 115819.png" "b/src/main/resources/static/images/profile/0e8a703d-5f72-40cb-9a73-03b9ada98470_\354\212\244\355\201\254\353\246\260\354\203\267 2024-01-29 115819.png" new file mode 100644 index 0000000..2b390a0 Binary files /dev/null and "b/src/main/resources/static/images/profile/0e8a703d-5f72-40cb-9a73-03b9ada98470_\354\212\244\355\201\254\353\246\260\354\203\267 2024-01-29 115819.png" differ diff --git "a/src/main/resources/static/images/profile/9b95efac-1bef-4caa-8cff-eeba0824b21e_\354\212\244\355\201\254\353\246\260\354\203\267 2024-01-19 171611.png" "b/src/main/resources/static/images/profile/9b95efac-1bef-4caa-8cff-eeba0824b21e_\354\212\244\355\201\254\353\246\260\354\203\267 2024-01-19 171611.png" new file mode 100644 index 0000000..8adfd0a Binary files /dev/null and "b/src/main/resources/static/images/profile/9b95efac-1bef-4caa-8cff-eeba0824b21e_\354\212\244\355\201\254\353\246\260\354\203\267 2024-01-19 171611.png" differ diff --git a/src/main/resources/static/images/profile/default_profile.png b/src/main/resources/static/images/profile/default_profile.png new file mode 100644 index 0000000..a6913e1 Binary files /dev/null and b/src/main/resources/static/images/profile/default_profile.png differ diff --git a/src/main/resources/templates/article/articles.html b/src/main/resources/templates/article/articles.html new file mode 100644 index 0000000..9767745 --- /dev/null +++ b/src/main/resources/templates/article/articles.html @@ -0,0 +1,52 @@ + + + + + +
+

이곳은 '게시판' 입니다.

+ + + + + + + + + + + + + + + + + + + + + +
#제목글쓴이작성 날짜수정 날짜보기
보기
+ 전체 게시글 : [[${totalArticles}]] - 페이지 [[${currentPage}]] of [[${totalPages}]] + 맨 앞으로 + 맨 앞으로 + + 이전 + 이전 + + + [[${i}]] + [[${i}]] +    + + + 다음 + 다음 + 마지막 + 마지막 + + 새 게시글 작성 +
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/article/create.html b/src/main/resources/templates/article/create.html new file mode 100644 index 0000000..d6e7624 --- /dev/null +++ b/src/main/resources/templates/article/create.html @@ -0,0 +1,29 @@ + + + + +
+

게시글 작성하기

+
+
+
+ + + +
+
+ + + +
+ + +
+ +
+ 뒤로 가기 +
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/article/detail.html b/src/main/resources/templates/article/detail.html new file mode 100644 index 0000000..2b41e60 --- /dev/null +++ b/src/main/resources/templates/article/detail.html @@ -0,0 +1,54 @@ + + + + +
+

+
+
+ 작성자 : +
+

+

+
    +
  • +
    작성일
    + +
  • +
  • +
    수정일
    + +
  • +
+
+ + + +
+
+ +
+ 수정 하기 +
+ 뒤로 가기 + 뒤로 가기 +
+
+ + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/article/update.html b/src/main/resources/templates/article/update.html new file mode 100644 index 0000000..746fb83 --- /dev/null +++ b/src/main/resources/templates/article/update.html @@ -0,0 +1,28 @@ + + + + +
+

게시글 수정하기

+
+
+
+ + + +
+
+ + + +
+ +
+ +
+ 뒤로 가기 +
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/comment/_comments.html b/src/main/resources/templates/comment/_comments.html new file mode 100644 index 0000000..4b2deb2 --- /dev/null +++ b/src/main/resources/templates/comment/_comments.html @@ -0,0 +1,5 @@ + +
+
+
+
diff --git a/src/main/resources/templates/comment/_create.html b/src/main/resources/templates/comment/_create.html new file mode 100644 index 0000000..d1ebccf --- /dev/null +++ b/src/main/resources/templates/comment/_create.html @@ -0,0 +1,13 @@ +
+
새 댓글 작성
+
+
+
+ + +
+
+ +
+
+ diff --git a/src/main/resources/templates/comment/_list.html b/src/main/resources/templates/comment/_list.html new file mode 100644 index 0000000..9da653a --- /dev/null +++ b/src/main/resources/templates/comment/_list.html @@ -0,0 +1,44 @@ + +
+

댓글 목록

+ + + + + + + + + + + + + + + + + + + + + + + +
#글쓴이내용작성 날짜수정 날짜수정 버튼삭제 버튼
+ +
+ + +
+
+ + +
+ +
+
+ +
diff --git a/src/main/resources/templates/common/head.html b/src/main/resources/templates/common/head.html new file mode 100644 index 0000000..b4d15e7 --- /dev/null +++ b/src/main/resources/templates/common/head.html @@ -0,0 +1,5 @@ + + + 게시판 만들기 플젝 + + diff --git a/src/main/resources/templates/common/nav.html b/src/main/resources/templates/common/nav.html new file mode 100644 index 0000000..0efd7bc --- /dev/null +++ b/src/main/resources/templates/common/nav.html @@ -0,0 +1,17 @@ + + diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html new file mode 100644 index 0000000..d8e8d23 --- /dev/null +++ b/src/main/resources/templates/home.html @@ -0,0 +1,12 @@ + + + + + +
+

이곳은 홈 페이지 입니다.ㅎㅎ

+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/user/_myArticles.html b/src/main/resources/templates/user/_myArticles.html new file mode 100644 index 0000000..5b57e20 --- /dev/null +++ b/src/main/resources/templates/user/_myArticles.html @@ -0,0 +1,49 @@ +
+
내 게시글
+
+ + + + + + + + + + + + + + + + + + + +
ID제목생성 일자수정 일자게시글 보기삭제
보기 +
+ +
+
+
+ + 전체 게시글 : [[${myArticleTotalItems}]] - 페이지 : [[${myArticleCurrentPage}]] of [[${myArticleTotalPages}]] +   -   + 맨 앞으로 + 맨 앞으로 + + 이전 + 이전 + + + [[${i}]] + [[${i}]] +    + + + 다음 + 다음 + 마지막 + 마지막 + +
\ No newline at end of file diff --git a/src/main/resources/templates/user/_myComment.html b/src/main/resources/templates/user/_myComment.html new file mode 100644 index 0000000..06955b0 --- /dev/null +++ b/src/main/resources/templates/user/_myComment.html @@ -0,0 +1,45 @@ +
+
내 댓글
+
+ + + + + + + + + + + + + + + + + +
ID작성 내용생성 일자수정 일자삭제
+
+ +
+
+
+ 전체 댓글 : [[${myCommentTotalItems}]] - 페이지 : [[${myCommentCurrentPage}]] of [[${myCommentTotalPages}]] +   -   + 맨 앞으로 + 맨 앞으로 + + 이전 + 이전 + + + [[${i}]] + [[${i}]] +    + + + 다음 + 다음 + 마지막 + 마지막 +
\ No newline at end of file diff --git a/src/main/resources/templates/user/info.html b/src/main/resources/templates/user/info.html new file mode 100644 index 0000000..17ff786 --- /dev/null +++ b/src/main/resources/templates/user/info.html @@ -0,0 +1,27 @@ + + + + + +
+
+ 사용자 이미지 +
+
이름 :
+
이메일 :
+
닉네임 :
+
+
+ +
+ 프로필 수정 + 비밀번호 변경 +
+
+
+ +
+
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/user/signup.html b/src/main/resources/templates/user/signup.html new file mode 100644 index 0000000..b14500b --- /dev/null +++ b/src/main/resources/templates/user/signup.html @@ -0,0 +1,35 @@ + + + + +
+
+

회원 가입 페이지

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+ 뒤로 가기 +
+ + \ No newline at end of file diff --git a/src/main/resources/templates/user/updatePassword.html b/src/main/resources/templates/user/updatePassword.html new file mode 100644 index 0000000..d68d31d --- /dev/null +++ b/src/main/resources/templates/user/updatePassword.html @@ -0,0 +1,41 @@ + + + + +
+

비밀번호 수정 페이지

+ +
+ + + + + + + + + + + + + + +
현재 비밀번호현재 비밀번호가 틀렸습니다.
변경할 비밀번호
변경할 비밀번호 재입력
+ +
+ 뒤로 가기 +
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/user/updateProfile.html b/src/main/resources/templates/user/updateProfile.html new file mode 100644 index 0000000..5abd2ae --- /dev/null +++ b/src/main/resources/templates/user/updateProfile.html @@ -0,0 +1,22 @@ + + + + +
+
+
+

+ + +

+

+ + +

+
+ + 취소 +
+
+ + \ No newline at end of file diff --git a/src/test/java/com/y/java_board/repository/MemoryArticleRepositoryTest.java b/src/test/java/com/y/java_board/repository/MemoryArticleRepositoryTest.java new file mode 100644 index 0000000..d1a677c --- /dev/null +++ b/src/test/java/com/y/java_board/repository/MemoryArticleRepositoryTest.java @@ -0,0 +1,60 @@ +package com.y.java_board.repository; + +import com.y.java_board.domain.Article; +import com.y.java_board.repository.impl.MemoryArticleRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class MemoryArticleRepositoryTest { + + MemoryArticleRepository repository = new MemoryArticleRepository(); + + @AfterEach + public void afterEach(){ + repository.clearStore(); + } + + @Test + public void save(){ + Article article = new Article("테스트제목", "테스트내용", "저자"); + + repository.save(article); + + Article result = repository.findById(article.getId()).get(); + assertThat(article).isEqualTo(result); + } + + @Test + public void findAll(){ + int expected = 3; + + repository.save(new Article("제1","내1","저1")); + repository.save(new Article("제2","내2","저2")); + repository.save(new Article("제3","내3","저3")); + + List
result = repository.findAll(); + + assertThat(result.size()).isEqualTo(expected); + } + + @Test + public void deleteById(){ + boolean expected = false; + + repository.save(new Article("제1","내1","저1")); + repository.save(new Article("제2","내2","저2")); + repository.save(new Article("제3","내3","저3")); + + repository.deleteById(1L); + Optional
result = repository.findById(1L); + + assertThat(result.isPresent()).isEqualTo(expected); + } + + +} \ No newline at end of file diff --git a/src/test/java/com/y/java_board/service/ArticleServiceTest.java b/src/test/java/com/y/java_board/service/ArticleServiceTest.java new file mode 100644 index 0000000..5c89397 --- /dev/null +++ b/src/test/java/com/y/java_board/service/ArticleServiceTest.java @@ -0,0 +1,39 @@ +package com.y.java_board.service; + +import com.y.java_board.domain.Article; +import com.y.java_board.repository.impl.MemoryArticleRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.NoSuchElementException; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ArticleServiceTest { + + ArticleService articleService; + MemoryArticleRepository memoryArticleRepository; + + + @BeforeEach + public void beforeEach(){ + // 임시적으로.. + memoryArticleRepository = new MemoryArticleRepository(); + articleService = new ArticleService(memoryArticleRepository, null, null); + } + + @AfterEach + public void afterEach(){ + memoryArticleRepository.clearStore(); + } + + @Test + void updateOne_nonExistingId_NoSuchElementException(){ + Article article = new Article("테스트제목1", "테스트내용1","테스트저자1"); + article.setId(100L); + + assertThatThrownBy(() -> articleService.updateOne(article)).isInstanceOf(NoSuchElementException.class); + } + +} \ No newline at end of file diff --git a/src/test/java/com/y/java_board/service/CommentServiceTest.java b/src/test/java/com/y/java_board/service/CommentServiceTest.java new file mode 100644 index 0000000..eda2ee4 --- /dev/null +++ b/src/test/java/com/y/java_board/service/CommentServiceTest.java @@ -0,0 +1,119 @@ +package com.y.java_board.service; + +import com.y.java_board.domain.Article; +import com.y.java_board.domain.Comment; +import com.y.java_board.dto.CommentDto; +import com.y.java_board.repository.ArticleRepository; +import com.y.java_board.repository.CommentRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +class CommentServiceTest { + + @InjectMocks + private CommentService commentService; + @Mock + private CommentRepository commentRepository; + @Mock + private ArticleRepository articleRepository; + + @BeforeEach + void beforeEach(){ + MockitoAnnotations.initMocks(this); + + // basic data + // articleId가 1인 article + // articleId가 2인 article에 기본 댓글이 3개(기본 댓글 아이디 1, 2, 3) + // articleId가 3인 article에 기본 댓글이 2개(기본 댓글 아이디 4, 5) + initData(); + } + + + @Test + void createOne_nonExistingArticleId_IllegalArgumentException(){ + //given + CommentDto commentDto = new CommentDto("","테스트내용",20001L); + // when & then + assertThrows(IllegalArgumentException.class, () -> commentService.createOne(commentDto, commentDto.articleId())); + } + + + @Test + void findCommentsByArticleId_validArticleId_commentsFound(){ + // given + Long articleId = 2L; + // when + List foundedComments = commentService.findCommentsByArticleId(articleId); + // then + int expected = 3; + assertThat(foundedComments.size()).isEqualTo(expected); + } + + @Test + void deleteComment_validCommentId_commentDeleted(){ + // given + Long commentId = 1L; + // when + commentService.deleteComment(commentId); + // then + verify(commentRepository, times(1)).deleteById(commentId); + } + + @Test + void updateComment_nonExistingComment_IllegalArgumentException(){ + // given + Long commentId = 100L; + // when & then + assertThrows(IllegalArgumentException.class, () -> commentService.updateComment(commentId, "Hello")); + } + + private void initData(){ + Article article1 = new Article(); + article1.setId(1L); + + Article article2 = new Article(); + article2.setId(2L); + List commentsForArticle2 = IntStream.rangeClosed(1, 3) + .mapToObj(i ->{ + Comment comment = new Comment(); + comment.setId((long) i); + comment.setArticle(article2); + return comment; + }) + .collect(Collectors.toList()); + + Article article3 = new Article(); + article3.setId(3L); + List commentsForArticle3 = IntStream.rangeClosed(4, 5) + .mapToObj(i -> { + Comment comment = new Comment(); + comment.setId((long) i); + comment.setArticle(article3); + return comment; + }) + .collect(Collectors.toList()); + + // Mock 설정 + when(articleRepository.findById(1L)).thenReturn(Optional.of(article1)); + when(articleRepository.findById(2L)).thenReturn(Optional.of(article2)); + when(articleRepository.findById(3L)).thenReturn(Optional.of(article3)); + when(commentRepository.findByArticleId(2L)).thenReturn(commentsForArticle2); + when(commentRepository.findByArticleId(3L)).thenReturn(commentsForArticle3); + } + + + + +} \ No newline at end of file diff --git a/src/test/java/com/y/java_board/service/UserServiceTest.java b/src/test/java/com/y/java_board/service/UserServiceTest.java new file mode 100644 index 0000000..e4994c8 --- /dev/null +++ b/src/test/java/com/y/java_board/service/UserServiceTest.java @@ -0,0 +1,36 @@ +package com.y.java_board.service; + +import com.y.java_board.domain.User; +import com.y.java_board.dto.UserDto; +import com.y.java_board.repository.UserRepository; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + @Mock + private UserRepository userRepository; + @InjectMocks + private UserService userService; + + @Test + void givenDuplicatedEmailUser_whenRegister_thenIllegalStateException(){ + User existingUser = new User("existing@google.com", "password", "testname", "testnickname"); + + when(userRepository.findByEmail(existingUser.getEmail())).thenReturn(Optional.of(existingUser)); + + UserDto newUserDto = new UserDto("existing@google.com", "password", "testname1", "testnickname2"); + + Assertions.assertThatThrownBy(() -> userService.registerNewUserAccount(newUserDto)) + .isInstanceOf(IllegalStateException.class); + } +} \ No newline at end of file