diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index c3f502a1..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# 디폴트 무시된 파일 -/shelf/ -/workspace.xml -# 에디터 기반 HTTP 클라이언트 요청 -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 72b0b0c2..00000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml deleted file mode 100644 index 63e4ea35..00000000 --- a/.idea/dataSources.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - mysql.8 - true - true - $PROJECT_DIR$/back-end/src/main/resources/application.yaml - com.mysql.cj.jdbc.Driver - jdbc:mysql://localhost:3306/techie - - - - - - $ProjectFileDir$ - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 77e1b764..00000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 03d9549e..00000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml deleted file mode 100644 index fdc392fe..00000000 --- a/.idea/jarRepositories.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index ade1ecd1..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index cff8c3c7..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/techie.iml b/.idea/techie.iml deleted file mode 100644 index d6ebd480..00000000 --- a/.idea/techie.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml deleted file mode 100644 index 2b63946d..00000000 --- a/.idea/uiDesigner.xml +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 9927b2ae..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 0ca4d0be..a9e1c131 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "java.compile.nullAnalysis.mode": "automatic" -} \ No newline at end of file + "java.compile.nullAnalysis.mode": "automatic", + "java.debug.settings.onBuildFailureProceed": true +} diff --git a/FETCH_HEAD b/FETCH_HEAD new file mode 100644 index 00000000..e69de29b diff --git a/back-end/build.gradle b/back-end/build.gradle index b1f68881..be9e4a8c 100644 --- a/back-end/build.gradle +++ b/back-end/build.gradle @@ -14,6 +14,9 @@ java { } configurations { + all { + exclude group: 'commons-logging', module: 'commons-logging' + } compileOnly { extendsFrom annotationProcessor } @@ -30,6 +33,8 @@ dependencies { implementation group: 'org.modelmapper', name: 'modelmapper', version: '3.1.1' // modelmapper implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" diff --git a/back-end/src/main/java/com/techie/backend/board/controller/PostController.java b/back-end/src/main/java/com/techie/backend/board/controller/PostController.java new file mode 100644 index 00000000..48433ce8 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/board/controller/PostController.java @@ -0,0 +1,83 @@ +package com.techie.backend.board.controller; + +import com.techie.backend.board.domain.PostCategory; +import com.techie.backend.board.dto.PostRequest; +import com.techie.backend.board.dto.PostResponse; +import com.techie.backend.board.service.PostService; +import com.techie.backend.global.security.UserDetailsCustom; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/post") +@Slf4j +public class PostController { + + private final PostService postService; + + @PostMapping + public ResponseEntity createPost(@RequestBody PostRequest postRequest, + @AuthenticationPrincipal UserDetailsCustom userDetails) { + PostResponse postResponse = postService.createPost(postRequest, userDetails); + return ResponseEntity.ok(postResponse); + } + + @GetMapping("/my") + public ResponseEntity> getMyPosts(@AuthenticationPrincipal UserDetailsCustom userDetails, + Pageable pageable) { + Page myPosts = postService.getMyPosts(userDetails, pageable); + return ResponseEntity.ok(myPosts); + } + + @GetMapping("/{id}") + public ResponseEntity> getYourPosts(@PathVariable Long id, + Pageable pageable) { + Page yourPosts = postService.getYourPosts(id, pageable); + return ResponseEntity.ok(yourPosts); + } + + @GetMapping("/category/{category}") + public ResponseEntity> getPostsByCategory(@PathVariable PostCategory category, + Pageable pageable) { + Page posts = postService.getAllPosts(category, pageable); + return ResponseEntity.ok(posts); + } + + @GetMapping + public ResponseEntity> searchPostByContent(@RequestParam PostCategory category, + @RequestParam String query, + Pageable pageable) { + Page posts = postService.searchPostByContent(category, query, pageable); + return ResponseEntity.ok(posts); + } + + @GetMapping("/nickname/{nickname}") + public ResponseEntity> searchPostByNickname(@PathVariable String nickname, + Pageable pageable) { + Page posts = postService.searchPostByNickname(nickname, pageable); + return ResponseEntity.ok(posts); + } + + + @PutMapping("/{id}") + public ResponseEntity updatePost(@RequestBody PostRequest.Update updateRequest, + @PathVariable Long id, + @AuthenticationPrincipal UserDetailsCustom userDetails) { + PostResponse postResponse = postService.updatePost(id, updateRequest, userDetails); + return ResponseEntity.ok(postResponse); + } + + @DeleteMapping + public ResponseEntity deletePost(@RequestBody PostRequest.Delete delRequest, + @AuthenticationPrincipal UserDetailsCustom userDetails) { + postService.deletePost(delRequest, userDetails); + return ResponseEntity.noContent().build(); + } +} diff --git a/back-end/src/main/java/com/techie/backend/board/domain/Post.java b/back-end/src/main/java/com/techie/backend/board/domain/Post.java new file mode 100644 index 00000000..7da6c64b --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/board/domain/Post.java @@ -0,0 +1,53 @@ +package com.techie.backend.board.domain; + +import com.techie.backend.global.BaseTime; +import com.techie.backend.user.domain.User; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "posts") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Post extends BaseTime { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column @NotBlank(message = "제목은 비어있을 수 없습니다.") + private String title; + + @Column(columnDefinition="TEXT") + @NotBlank(message = "내용은 비어있을 수 없습니다.") + private String content; + + @JoinColumn(name = "user_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private User user; + + @Column @NotNull + @Enumerated(EnumType.STRING) + private PostCategory category; + + @Builder + public Post(String title, String content, User user, PostCategory category) { + this.title = title; + this.content = content; + this.user = user; + this.category = category; + } + + public void changeTitle(String title) { + this.title = title; + } + + public void changeContent(String content) { + this.content = content; + } +} diff --git a/back-end/src/main/java/com/techie/backend/board/domain/PostCategory.java b/back-end/src/main/java/com/techie/backend/board/domain/PostCategory.java new file mode 100644 index 00000000..204880c9 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/board/domain/PostCategory.java @@ -0,0 +1,6 @@ +package com.techie.backend.board.domain; + +public enum PostCategory { + QNA, + FREE +} diff --git a/back-end/src/main/java/com/techie/backend/board/dto/PostRequest.java b/back-end/src/main/java/com/techie/backend/board/dto/PostRequest.java new file mode 100644 index 00000000..bdc36077 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/board/dto/PostRequest.java @@ -0,0 +1,25 @@ +package com.techie.backend.board.dto; + +import com.techie.backend.board.domain.PostCategory; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class PostRequest { + private String title; + private String content; + private PostCategory category; + + @Data + public static class Update { + private String title; + private String content; + } + + @Data + public static class Delete { + private List postIds = new ArrayList<>(); + } +} diff --git a/back-end/src/main/java/com/techie/backend/board/dto/PostResponse.java b/back-end/src/main/java/com/techie/backend/board/dto/PostResponse.java new file mode 100644 index 00000000..5da27b58 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/board/dto/PostResponse.java @@ -0,0 +1,28 @@ +package com.techie.backend.board.dto; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.board.domain.PostCategory; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class PostResponse { + private Long id; + private String title; + private String content; + private PostCategory category; + private LocalDateTime writtenAt; + private String email; + private String nickname; + + public PostResponse(Post post) { + this.title = post.getTitle(); + this.content = post.getContent(); + this.category = post.getCategory(); + this.writtenAt = post.getUpdatedAt(); + this.email = post.getUser().getEmail(); + this.nickname = post.getUser().getNickname(); + this.id = post.getId(); + } +} diff --git a/back-end/src/main/java/com/techie/backend/board/repository/PostRepository.java b/back-end/src/main/java/com/techie/backend/board/repository/PostRepository.java new file mode 100644 index 00000000..387a9ebf --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/board/repository/PostRepository.java @@ -0,0 +1,36 @@ +package com.techie.backend.board.repository; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.board.domain.PostCategory; +import com.techie.backend.user.domain.User; +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.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + + +public interface PostRepository extends JpaRepository { + Page searchAllByUser(Pageable pageable, User user); + Page searchAllByCategory(Pageable pageable, PostCategory category); + + @Query(""" + SELECT p FROM Post p + WHERE p.category = :category + AND (p.title LIKE %:query% OR p.content LIKE %:query%) + """) + Page searchByQuery(Pageable pageable, PostCategory category, @Param("query") String query); + + Page findByUser_Nickname(Pageable pageable, String nickname); + + @Modifying(clearAutomatically = true) + @Query(""" + DELETE FROM Post p + WHERE p.id IN :postIds + AND p.user.id = :userId + """) + void deleteAllByIds(@Param("postIds") List postIds, @Param("userId") Long userId); +} diff --git a/back-end/src/main/java/com/techie/backend/board/service/PostService.java b/back-end/src/main/java/com/techie/backend/board/service/PostService.java new file mode 100644 index 00000000..a9b7c1da --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/board/service/PostService.java @@ -0,0 +1,106 @@ +package com.techie.backend.board.service; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.board.domain.PostCategory; +import com.techie.backend.board.dto.PostRequest; +import com.techie.backend.board.dto.PostResponse; +import com.techie.backend.board.repository.PostRepository; +import com.techie.backend.global.mapper.PostMapper; +import com.techie.backend.global.security.UserDetailsCustom; +import com.techie.backend.user.domain.User; +import com.techie.backend.user.service.UserService; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +@Slf4j +public class PostService { + private final UserService userService; + private final PostRepository postRepository; + private final PostMapper postMapper; + private final EntityManager em; + + public PostResponse createPost(PostRequest postRequest, UserDetailsCustom userDetails) { + User user = userService.getUserFromSecurityContext(userDetails); + Post post = postMapper.toEntity(postRequest, user); + Post createdPost = postRepository.save(post); + return postMapper.toDto(createdPost); + } + + public Page getMyPosts(UserDetailsCustom userDetails, Pageable pageable) { + User user = userService.getUserFromSecurityContext(userDetails); + return getPosts(user, pageable); + } + + public Page getYourPosts(Long userId, Pageable pageable) { + User user = userService.getUserById(userId); + return getPosts(user, pageable); + } + + public Page getPosts(User user, Pageable pageable) { + Page posts = postRepository.searchAllByUser(pageable, user); + if(posts.isEmpty()) { + throw new EntityNotFoundException("해당 사용자의 게시물이 없습니다."); + } + return posts.map(postMapper::toDto); + } + + // 게시글이 없다면 빈 페이지 반환 + public Page getAllPosts(PostCategory category, Pageable pageable) { + return postRepository.searchAllByCategory(pageable, category).map(postMapper::toDto); + } + + public Page searchPostByContent(PostCategory postCategory, String query, Pageable pageable) { + return postRepository.searchByQuery(pageable, postCategory, query).map(postMapper::toDto); + } + + public Page searchPostByNickname(String nickname, Pageable pageable) { + return postRepository.findByUser_Nickname(pageable, nickname).map(postMapper::toDto); + } + + + public PostResponse updatePost(Long id, PostRequest.Update updateRequest, UserDetailsCustom userDetails) { + Post post = postRepository.findById(id).orElseThrow(() + -> new EntityNotFoundException("게시글이 없습니다.")); + User user = userService.getUserFromSecurityContext(userDetails); + validateOwner(user, post); + postMapper.updateDto(updateRequest, post); + em.flush(); + return postMapper.toDto(post); + } + + + @Transactional + public void deletePost(PostRequest.Delete delRequest, UserDetailsCustom userDetails) { + User user = userService.getUserFromSecurityContext(userDetails); + List postIds = delRequest.getPostIds(); + if (postIds.isEmpty()) { + throw new EntityNotFoundException("삭제할 게시글이 없습니다."); + } + + for(Long postId : postIds) { + Post post = postRepository.findById(postId).orElseThrow(() + -> new EntityNotFoundException("존재하지 않는 게시글입니다." + "ID: " + postId)); + validateOwner(user, post); + } + + postRepository.deleteAllByIds(postIds, user.getId()); + } + + private void validateOwner(User user, Post post) { + if(!user.getId().equals(post.getUser().getId())) { + throw new AccessDeniedException("수정/삭제할 권한이 없습니다." + "ID: " + post.getId()); + } + } +} diff --git a/back-end/src/main/java/com/techie/backend/comment/controller/CommentController.java b/back-end/src/main/java/com/techie/backend/comment/controller/CommentController.java new file mode 100644 index 00000000..c6e72500 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/controller/CommentController.java @@ -0,0 +1,51 @@ +package com.techie.backend.comment.controller; + +import com.techie.backend.comment.dto.CommentRequest; +import com.techie.backend.comment.dto.CommentResponse; +import com.techie.backend.comment.service.CommentService; +import com.techie.backend.global.security.UserDetailsCustom; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/post/{postId}/comments") +@RequiredArgsConstructor +public class CommentController { + private final CommentService commentService; + + @PostMapping + public ResponseEntity createComment(@PathVariable Long postId, + @RequestBody CommentRequest commentRequest, + @AuthenticationPrincipal UserDetailsCustom userDetails) { + CommentResponse commentResponse = commentService.createComment(postId, commentRequest, userDetails); + return ResponseEntity.ok(commentResponse); + } + + @GetMapping + public ResponseEntity> getComments(@PathVariable Long postId) { + List commentResponses = commentService.getCommentsByPostId(postId); + return ResponseEntity.ok(commentResponses); + } + + @PutMapping("/{commentId}") + public ResponseEntity updateComment(@PathVariable Long postId, + @PathVariable Long commentId, + @RequestBody CommentRequest.Update updateRequest, + @AuthenticationPrincipal UserDetailsCustom userDetailsCustom) { + commentService.updateComment(postId, commentId, updateRequest, userDetailsCustom); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{commentId}") + public ResponseEntity deleteComment(@PathVariable Long postId, + @PathVariable Long commentId, + @AuthenticationPrincipal UserDetailsCustom userDetailsCustom) { + + commentService.deleteComment(postId, commentId, userDetailsCustom); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/comment/domain/Comment.java b/back-end/src/main/java/com/techie/backend/comment/domain/Comment.java new file mode 100644 index 00000000..e7fa4252 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/domain/Comment.java @@ -0,0 +1,43 @@ +package com.techie.backend.comment.domain; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.global.BaseTime; +import com.techie.backend.user.domain.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "comment") +public class Comment extends BaseTime { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Builder + public Comment(String content, Post post, User user) { + this.content = content; + this.post = post; + this.user = user; + } + + public void updateContent(String content) { + this.content = content; + } +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/comment/dto/CommentRequest.java b/back-end/src/main/java/com/techie/backend/comment/dto/CommentRequest.java new file mode 100644 index 00000000..1449136d --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/dto/CommentRequest.java @@ -0,0 +1,13 @@ +package com.techie.backend.comment.dto; + +import lombok.Data; + +@Data +public class CommentRequest { + private String content; + + @Data + public static class Update { + private String content; + } +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/comment/dto/CommentResponse.java b/back-end/src/main/java/com/techie/backend/comment/dto/CommentResponse.java new file mode 100644 index 00000000..ca490cfc --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/dto/CommentResponse.java @@ -0,0 +1,22 @@ + +package com.techie.backend.comment.dto; + +import com.techie.backend.comment.domain.Comment; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class CommentResponse { + private String content; + private String writerNickname; + private LocalDateTime createDate; + private LocalDateTime updatedDate; + + public CommentResponse(Comment comment) { + this.content = comment.getContent(); + this.writerNickname = comment.getUser().getNickname(); + this.createDate = comment.getCreatedAt(); + this.updatedDate = comment.getUpdatedAt(); + } +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepository.java b/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepository.java new file mode 100644 index 00000000..af678822 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepository.java @@ -0,0 +1,14 @@ +package com.techie.backend.comment.repository; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.comment.domain.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { + List findByPost(Post post); + + Optional findByIdAndPostId(Long commentId, Long postId); +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepositoryCustom.java b/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepositoryCustom.java new file mode 100644 index 00000000..52cb2670 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.techie.backend.comment.repository; + +import com.techie.backend.comment.domain.Comment; + +import java.util.Optional; + +public interface CommentRepositoryCustom { + Optional findCommentByPostIdAndCommentId(Long postId, Long commentId); + +} diff --git a/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepositoryImpl.java b/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepositoryImpl.java new file mode 100644 index 00000000..6c6c47aa --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.techie.backend.comment.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.techie.backend.board.domain.QPost; +import com.techie.backend.comment.domain.Comment; +import com.techie.backend.comment.domain.QComment; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class CommentRepositoryImpl implements CommentRepositoryCustom { + private final JPAQueryFactory queryFactory; + + @Override + public Optional findCommentByPostIdAndCommentId(Long postId, Long commentId) { + QComment comment = QComment.comment; + QPost post = QPost.post; + + Comment result = queryFactory + .selectFrom(comment) + .where(comment.id.eq(commentId) + .and(comment.post.id.eq(postId))) + .fetchOne(); + + return Optional.ofNullable(result); + } +} diff --git a/back-end/src/main/java/com/techie/backend/comment/service/CommentService.java b/back-end/src/main/java/com/techie/backend/comment/service/CommentService.java new file mode 100644 index 00000000..5c724dc2 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/service/CommentService.java @@ -0,0 +1,88 @@ +package com.techie.backend.comment.service; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.board.repository.PostRepository; +import com.techie.backend.comment.dto.CommentRequest; +import com.techie.backend.comment.dto.CommentResponse; +import com.techie.backend.comment.repository.CommentRepository; +import com.techie.backend.global.mapper.CommentMapper; +import com.techie.backend.global.security.UserDetailsCustom; +import com.techie.backend.user.domain.User; +import com.techie.backend.user.service.UserService; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class CommentService { + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final UserService userService; + private final CommentMapper commentMapper; + + public CommentResponse createComment(Long postId, + CommentRequest commentRequest, + UserDetailsCustom userDetails) { + + User user = userService.getUserFromSecurityContext(userDetails); + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다.")); + + com.techie.backend.comment.domain.Comment comment = commentMapper.toEntity(commentRequest, post, user); + com.techie.backend.comment.domain.Comment createComment = commentRepository.save(comment); + + return commentMapper.toDto(createComment); + } + + public List getCommentsByPostId(Long postId) { + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다.")); + + List comments = commentRepository.findByPost(post); + + return comments.stream() + .map(commentMapper::toDto) + .collect(Collectors.toList()); + } + + public void updateComment(Long postId, + Long commentId, + CommentRequest.Update updateRequest, + UserDetailsCustom userDetailsCustom) { + + com.techie.backend.comment.domain.Comment comment = commentRepository.findCommentByPostIdAndCommentId(postId, commentId) + .orElseThrow(() -> new EntityNotFoundException("해당 게시물에 댓글이 존재하지 않습니다.")); + + User user = userService.getUserFromSecurityContext(userDetailsCustom); + + if (!comment.getUser().getId().equals(user.getId())) { + throw new SecurityException("댓글 작성자가 아닙니다."); + } + + comment.updateContent(updateRequest.getContent()); + } + + public void deleteComment(Long postId, + Long commentId, + UserDetailsCustom userDetailsCustom) { + + com.techie.backend.comment.domain.Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new EntityNotFoundException("댓글을 찾을 수 없습니다.")); + User user = userService.getUserFromSecurityContext(userDetailsCustom); + + if (!comment.getUser().getId().equals(user.getId())) { + throw new SecurityException("댓글 작성자가 아닙니다."); + } + commentRepository.delete(comment); + } +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/global/AwsHealthCheck.java b/back-end/src/main/java/com/techie/backend/global/AwsHealthCheck.java new file mode 100644 index 00000000..ec174592 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/global/AwsHealthCheck.java @@ -0,0 +1,13 @@ +package com.techie.backend.global; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class AwsHealthCheck { + @GetMapping("/healthCheck") + public ResponseEntity healthCheck() { + return ResponseEntity.ok().build(); + } +} diff --git a/back-end/src/main/java/com/techie/backend/global/exception/GlobalExceptionHandler.java b/back-end/src/main/java/com/techie/backend/global/exception/GlobalExceptionHandler.java index 95c7d1a9..c413e49a 100644 --- a/back-end/src/main/java/com/techie/backend/global/exception/GlobalExceptionHandler.java +++ b/back-end/src/main/java/com/techie/backend/global/exception/GlobalExceptionHandler.java @@ -12,10 +12,10 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.nio.file.AccessDeniedException; import java.util.HashMap; import java.util.Map; diff --git a/back-end/src/main/java/com/techie/backend/global/exception/user/OAuth2AuthenticationProcessingException.java b/back-end/src/main/java/com/techie/backend/global/exception/user/OAuth2AuthenticationProcessingException.java new file mode 100644 index 00000000..79cbcf7f --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/global/exception/user/OAuth2AuthenticationProcessingException.java @@ -0,0 +1,7 @@ +package com.techie.backend.global.exception.user; + +public class OAuth2AuthenticationProcessingException extends RuntimeException { + public OAuth2AuthenticationProcessingException(String message) { + super(message); + } +} diff --git a/back-end/src/main/java/com/techie/backend/global/mapper/CommentMapper.java b/back-end/src/main/java/com/techie/backend/global/mapper/CommentMapper.java new file mode 100644 index 00000000..1985c4ed --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/global/mapper/CommentMapper.java @@ -0,0 +1,24 @@ +package com.techie.backend.global.mapper; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.comment.domain.Comment; +import com.techie.backend.comment.dto.CommentRequest; +import com.techie.backend.comment.dto.CommentResponse; +import com.techie.backend.user.domain.User; +import org.springframework.stereotype.Component; + +@Component +public class CommentMapper { + + public Comment toEntity(CommentRequest commentRequest, Post post, User user) { + return Comment.builder() + .content(commentRequest.getContent()) + .post(post) + .user(user) + .build(); + } + + public CommentResponse toDto(Comment comment) { + return new CommentResponse(comment); + } +} diff --git a/back-end/src/main/java/com/techie/backend/global/mapper/PostMapper.java b/back-end/src/main/java/com/techie/backend/global/mapper/PostMapper.java new file mode 100644 index 00000000..80df5231 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/global/mapper/PostMapper.java @@ -0,0 +1,29 @@ +package com.techie.backend.global.mapper; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.board.dto.PostRequest; +import com.techie.backend.board.dto.PostResponse; +import com.techie.backend.user.domain.User; +import org.springframework.stereotype.Component; + +@Component +public class PostMapper { + public Post toEntity(PostRequest postRequest, User user) { + return Post.builder() + .title(postRequest.getTitle()) + .content(postRequest.getContent()) + .category(postRequest.getCategory()) + .user(user) + .build(); + } + + public PostResponse toDto(Post post) { + return new PostResponse(post); + } + + public void updateDto(PostRequest.Update updateRequest, Post post) { + post.changeTitle(updateRequest.getTitle()); + post.changeContent(updateRequest.getContent()); + } + +} diff --git a/back-end/src/main/java/com/techie/backend/global/security/JWTFilter.java b/back-end/src/main/java/com/techie/backend/global/security/JWTFilter.java index fac15df1..cd31cc8c 100644 --- a/back-end/src/main/java/com/techie/backend/global/security/JWTFilter.java +++ b/back-end/src/main/java/com/techie/backend/global/security/JWTFilter.java @@ -43,12 +43,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse String role = jwtUtil.getRole(token); String nickname = jwtUtil.getNickname(token); - User user = User.builder() - .email(email) - .password("tempPassword") - .nickname(nickname) - .role(role) - .build(); + User user = User.createLocalUser(email, "tempPassword", nickname, role); UserDetailsCustom userDetailsCustom = new UserDetailsCustom(user); Authentication authToken = new UsernamePasswordAuthenticationToken(userDetailsCustom, null, userDetailsCustom.getAuthorities()); diff --git a/back-end/src/main/java/com/techie/backend/global/security/JWTUtil.java b/back-end/src/main/java/com/techie/backend/global/security/JWTUtil.java index 61ea69c4..491b6f14 100644 --- a/back-end/src/main/java/com/techie/backend/global/security/JWTUtil.java +++ b/back-end/src/main/java/com/techie/backend/global/security/JWTUtil.java @@ -34,7 +34,7 @@ public Boolean isExpired(String token) { return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date()); } - public String createJwt(String email, String role, String nickname, Long expiredMs) { + public String createLocalJwt(String email, String role, String nickname, Long expiredMs) { return Jwts.builder() .claim("email", email) .claim("role", role) @@ -44,4 +44,17 @@ public String createJwt(String email, String role, String nickname, Long expired .signWith(secretKey) .compact(); } + + + public String createSocialJwt(String email, String nickname, String role, String provider, Long expiredMs) { + return Jwts.builder() + .claim("email", email) + .claim("role", role) + .claim("nickname", nickname) + .claim("provider", provider) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } } \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/global/security/LoginFilter.java b/back-end/src/main/java/com/techie/backend/global/security/LoginFilter.java index 564ed73e..c06ac726 100644 --- a/back-end/src/main/java/com/techie/backend/global/security/LoginFilter.java +++ b/back-end/src/main/java/com/techie/backend/global/security/LoginFilter.java @@ -56,7 +56,7 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR GrantedAuthority auth = iterator.next(); String role = auth.getAuthority(); String nickname = userDetailsCustom.getNickname(); - String token = jwtUtil.createJwt(email, role, nickname, 3 * 60 * 60 * 1000L); + String token = jwtUtil.createLocalJwt(email, role, nickname, 3 * 60 * 60 * 1000L); response.addHeader("Authorization", "Bearer " + token); } diff --git a/back-end/src/main/java/com/techie/backend/global/security/SecurityConfig.java b/back-end/src/main/java/com/techie/backend/global/security/SecurityConfig.java index 3d8a96a1..4c3051fb 100644 --- a/back-end/src/main/java/com/techie/backend/global/security/SecurityConfig.java +++ b/back-end/src/main/java/com/techie/backend/global/security/SecurityConfig.java @@ -1,5 +1,9 @@ package com.techie.backend.global.security; +import com.techie.backend.oauth2.HttpCookieOAuth2AuthorizationRequestRepository; +import com.techie.backend.oauth2.handler.OAuth2AuthenticationFailureHandler; +import com.techie.backend.oauth2.handler.OAuth2AuthenticationSuccessHandler; +import com.techie.backend.oauth2.service.CustomOAuth2UserService; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; @@ -17,6 +21,7 @@ import org.springframework.web.cors.CorsConfigurationSource; import java.util.Collections; +import java.util.List; @Configuration @EnableWebSecurity @@ -24,7 +29,10 @@ public class SecurityConfig { private final AuthenticationConfiguration authenticationConfiguration; private final JWTUtil jwtUtil; - + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler; + private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; + private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository; @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); @@ -48,8 +56,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { }) .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class) .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class) - .sessionManagement((session) -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .oauth2Login(configure -> + configure.authorizationEndpoint(config -> config.authorizationRequestRepository(httpCookieOAuth2AuthorizationRequestRepository)) + .userInfoEndpoint(config -> config.userService(customOAuth2UserService)) + .successHandler(oAuth2AuthenticationSuccessHandler) + .failureHandler(oAuth2AuthenticationFailureHandler) + ); http .cors((corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() { @@ -58,12 +71,12 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000")); - configuration.setAllowedMethods(Collections.singletonList("*")); + configuration.setAllowedOrigins(List.of("https://techie-mu.vercel.app")); + configuration.setAllowedMethods(List.of("*")); configuration.setAllowCredentials(true); - configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setAllowedHeaders(List.of("*")); configuration.setMaxAge(3600L); - configuration.setExposedHeaders(Collections.singletonList("Authorization")); + configuration.setExposedHeaders(List.of("Authorization")); return configuration; } diff --git a/back-end/src/main/java/com/techie/backend/gpt/service/GptService.java b/back-end/src/main/java/com/techie/backend/gpt/service/GptService.java index b5d09da4..4fe8cf86 100644 --- a/back-end/src/main/java/com/techie/backend/gpt/service/GptService.java +++ b/back-end/src/main/java/com/techie/backend/gpt/service/GptService.java @@ -1,11 +1,103 @@ package com.techie.backend.gpt.service; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.techie.backend.global.exception.gpt.GptRequestException; +import com.techie.backend.global.exception.gpt.InvalidGptRequestException; import com.techie.backend.global.security.UserDetailsCustom; +import com.techie.backend.gpt.domain.Gpt; import com.techie.backend.gpt.dto.GptRequest; import com.techie.backend.gpt.dto.GptResponse; +import com.techie.backend.gpt.repository.GptRepository; +import com.techie.backend.user.domain.User; +import com.techie.backend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; -public interface GptService { - ResponseEntity questionAndAnswer(GptRequest gptRequest, UserDetailsCustom userDetails); +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; -} +@Service +@RequiredArgsConstructor +public class GptService { + + private final ObjectMapper objectMapper; + private final GptRepository gptRepository; + private final UserRepository userRepository; + + @Value("${openai.api.key}") + private String API_KEY; + @Value("${openai.api.url}") + private String API_URL; + + public ResponseEntity questionAndAnswer(GptRequest gptRequest, UserDetailsCustom userDetails) { + String request = gptRequest.getRequest(); + + if (request == null || request.isEmpty()) { + throw new InvalidGptRequestException(); + } + + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + HttpPost httpPost = new HttpPost(API_URL); + httpPost.setHeader("Authorization", "Bearer " + API_KEY); + httpPost.setHeader("Content-Type", "application/json"); + + String apiHost = new URL(API_URL).getHost(); + HttpHost targetHost = HttpHost.create("https://" + apiHost); + + Map body = new HashMap<>(); + body.put("model", "gpt-3.5-turbo"); + body.put("messages", List.of(Map.of("role", "user", "content", request))); + + StringEntity entity = new StringEntity(objectMapper.writeValueAsString(body), ContentType.APPLICATION_JSON); + httpPost.setEntity(entity); + + HttpClientContext context = HttpClientContext.create(); + + try (ClassicHttpResponse response = httpClient.executeOpen(targetHost, httpPost, context)) { + int statusCode = response.getCode(); + if (statusCode != HttpStatus.OK.value()) { + throw new GptRequestException(); + } + + String responseBody = new String(response.getEntity().getContent().readAllBytes(), StandardCharsets.UTF_8); + + JsonNode rootNode = objectMapper.readTree(responseBody); + String gptContent = rootNode.path("choices").get(0).path("message").path("content").asText(); + User user = userRepository.findByEmail(userDetails.getUsername()); + + Gpt gpt = new Gpt(); + gpt.updateRequest(request); + gpt.updateResponse(gptContent); + gpt.assignUser(user); + gptRepository.save(gpt); + + GptResponse gptResponse = new GptResponse(); + gptResponse.setRequest(request); + gptResponse.setResponse(gptContent); + + return new ResponseEntity<>(gptResponse, HttpStatus.OK); + } + } catch (IOException e) { + throw new GptRequestException(); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/gpt/service/GptServiceImpl.java b/back-end/src/main/java/com/techie/backend/gpt/service/GptServiceImpl.java deleted file mode 100644 index 079abc8f..00000000 --- a/back-end/src/main/java/com/techie/backend/gpt/service/GptServiceImpl.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.techie.backend.gpt.service; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.techie.backend.global.exception.gpt.GptRequestException; -import com.techie.backend.global.exception.gpt.InvalidGptRequestException; -import com.techie.backend.global.security.UserDetailsCustom; -import com.techie.backend.gpt.domain.Gpt; -import com.techie.backend.gpt.dto.GptRequest; -import com.techie.backend.gpt.dto.GptResponse; -import com.techie.backend.gpt.repository.GptRepository; -import com.techie.backend.user.domain.User; -import com.techie.backend.user.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.apache.hc.client5.http.classic.methods.HttpPost; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.client5.http.protocol.HttpClientContext; -import org.apache.hc.core5.http.ClassicHttpResponse; -import org.apache.hc.core5.http.ContentType; -import org.apache.hc.core5.http.HttpHost; -import org.apache.hc.core5.http.io.entity.StringEntity; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; - -import java.io.IOException; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -@Service -@RequiredArgsConstructor -public class GptServiceImpl implements GptService { - - private final ObjectMapper objectMapper; - private final GptRepository gptRepository; - private final UserRepository userRepository; - - @Value("${openai.api.key}") - private String API_KEY; - @Value("${openai.api.url}") - private String API_URL; - - @Override - public ResponseEntity questionAndAnswer(GptRequest gptRequest, UserDetailsCustom userDetails) { - String request = gptRequest.getRequest(); - - if (request == null || request.isEmpty()) { - throw new InvalidGptRequestException(); - } - - try (CloseableHttpClient httpClient = HttpClients.createDefault()) { - HttpPost httpPost = new HttpPost(API_URL); - httpPost.setHeader("Authorization", "Bearer " + API_KEY); - httpPost.setHeader("Content-Type", "application/json"); - - String apiHost = new URL(API_URL).getHost(); - HttpHost targetHost = HttpHost.create("https://" + apiHost); - - Map body = new HashMap<>(); - body.put("model", "gpt-3.5-turbo"); - body.put("messages", List.of(Map.of("role", "user", "content", request))); - - StringEntity entity = new StringEntity(objectMapper.writeValueAsString(body), ContentType.APPLICATION_JSON); - httpPost.setEntity(entity); - - HttpClientContext context = HttpClientContext.create(); - - try (ClassicHttpResponse response = httpClient.executeOpen(targetHost, httpPost, context)) { - int statusCode = response.getCode(); - if (statusCode != HttpStatus.OK.value()) { - throw new GptRequestException(); - } - - String responseBody = new String(response.getEntity().getContent().readAllBytes(), StandardCharsets.UTF_8); - - JsonNode rootNode = objectMapper.readTree(responseBody); - String gptContent = rootNode.path("choices").get(0).path("message").path("content").asText(); - User user = userRepository.findByEmail(userDetails.getUsername()); - - Gpt gpt = new Gpt(); - gpt.updateRequest(request); - gpt.updateResponse(gptContent); - gpt.assignUser(user); - gptRepository.save(gpt); - - GptResponse gptResponse = new GptResponse(); - gptResponse.setRequest(request); - gptResponse.setResponse(gptContent); - - return new ResponseEntity<>(gptResponse, HttpStatus.OK); - } - } catch (IOException e) { - throw new GptRequestException(); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - } -} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/memo/service/MemoService.java b/back-end/src/main/java/com/techie/backend/memo/service/MemoService.java index 15f5058b..a9808eb5 100644 --- a/back-end/src/main/java/com/techie/backend/memo/service/MemoService.java +++ b/back-end/src/main/java/com/techie/backend/memo/service/MemoService.java @@ -1,20 +1,149 @@ package com.techie.backend.memo.service; +import com.techie.backend.global.exception.memo.EmptyContentException; import com.techie.backend.global.security.UserDetailsCustom; +import com.techie.backend.memo.domain.Memo; import com.techie.backend.memo.dto.MemoRequest; import com.techie.backend.memo.dto.MemoResponse; +import com.techie.backend.memo.repository.MemoRepository; +import com.techie.backend.user.domain.User; +import com.techie.backend.user.service.UserService; +import com.techie.backend.video.domain.Video; +import com.techie.backend.video.dto.VideoResponse; +import com.techie.backend.video.repository.VideoRepository; +import com.techie.backend.video.service.VideoService; +import jakarta.persistence.EntityNotFoundException; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.modelmapper.ModelMapper; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; import java.nio.file.AccessDeniedException; +import java.util.Collections; -public interface MemoService { - public ResponseEntity createMemo(MemoRequest memoRequest, UserDetailsCustom userDetails); - public ResponseEntity> getMemoList(UserDetailsCustom userDetails, Pageable pageable); - public ResponseEntity getMemo(UserDetailsCustom userDetails, Long id) throws AccessDeniedException; +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class MemoService { + private final MemoRepository memoRepository; + private final VideoService videoService; + private final VideoRepository videoRepository; + private final UserService userService; + private final ModelMapper modelMapper; - public ResponseEntity> getAllMemosByVideoId(UserDetailsCustom userDetails, String videoId, Pageable pageable); - public ResponseEntity updateMemo(UserDetailsCustom userDetails, Long id, MemoRequest.Update updateRequest) throws AccessDeniedException; - public ResponseEntity deleteMemo(UserDetailsCustom userDetails, Long id) throws AccessDeniedException; -} + // -- 메모 생성 -- + public ResponseEntity createMemo(MemoRequest request, UserDetailsCustom userDetails) { + if (request.getContent() == null || request.getContent().isBlank()) { + throw new EmptyContentException(); + } + + Memo memo = modelMapper.map(request, Memo.class); + + // 메모 소유자 할당 + User user = userService.getUserFromSecurityContext(userDetails); + memo.assignUser(user); + + String videoId = request.getVideoId(); + if (videoId != null){ + Video video = videoRepository.findByVideoId(videoId).orElseGet(()-> { + String videoUri = videoService.buildVideoUri(videoId); + String videoResponseJson = videoService.getYoutubeResponse(videoUri).getBody(); + VideoResponse videoResponse = videoService.convertJsonToVideoDTOWithoutPaging(videoResponseJson).get(0); + String title = videoResponse.getTitle(); + return videoRepository.save(Video + .builder() + .videoId(videoId) + .title(title) + .build()); + } + ); + memo.assignVideo(video); + } + + memoRepository.save(memo); + MemoResponse memoResponse = modelMapper.map(memo, MemoResponse.class); + return ResponseEntity.ok(memoResponse); + } + + // -- 메모 목록 조회 -- + public ResponseEntity> getMemoList(UserDetailsCustom userDetails, Pageable pageable) { + User user = userService.getUserFromSecurityContext(userDetails); + Slice memoSlice = memoRepository.findByUser(user, pageable) + .map(m -> modelMapper.map(m, MemoResponse.class)); + + return ResponseEntity.ok(memoSlice); + } + + // -- 메모 단건 조회 -- + public ResponseEntity getMemo(UserDetailsCustom userDetails, Long id) throws AccessDeniedException { + Memo memo = memoRepository.findById(id) + .orElseThrow(()->new EntityNotFoundException("메모를 찾을 수 없습니다.")); + + User user = userService.getUserFromSecurityContext(userDetails); + // 현재 사용자와 메모 소유자가 일치하는 지 확인 + if(!memo.getUser().equals(user)) { + throw new AccessDeniedException("해당 메모에 접근할 권한이 없습니다."); + } + + MemoResponse memoResponse = modelMapper.map(memo, MemoResponse.class); + return ResponseEntity.ok(memoResponse); + } + + // -- 영상 별 메모 조회 + public ResponseEntity> getAllMemosByVideoId(UserDetailsCustom userDetails, String videoId, Pageable pageable) { + User user = userService.getUserFromSecurityContext(userDetails); + + // 조건에 맞는 정보가 없다면 빈 슬라이스 반환 + return ResponseEntity.ok( + videoRepository.findByVideoId(videoId) + .map(video -> memoRepository.findByUserAndVideo(user, video, pageable) + .map(memo -> modelMapper.map(memo, MemoResponse.class))) + .orElseGet(() -> new SliceImpl<>(Collections.emptyList(), pageable, false)) + ); + } + + // -- 메모 수정 -- + public ResponseEntity updateMemo(UserDetailsCustom userDetails, Long id, MemoRequest.Update updateRequest) throws AccessDeniedException { + Memo memo = memoRepository.findById(id) + .orElseThrow(()->new EntityNotFoundException("메모를 찾을 수 없습니다.")); + + User user = userService.getUserFromSecurityContext(userDetails); + // 현재 사용자와 메모 소유자가 일치하는 지 확인 + if(!memo.getUser().equals(user)) { + throw new AccessDeniedException("해당 메모에 접근할 권한이 없습니다."); + } + + memo.changeTitle(updateRequest.getTitle()); + String content = updateRequest.getContent(); + if (content == null || content.isBlank()) { + throw new EmptyContentException(); + } + memo.changeContent(content); + memoRepository.save(memo); + + MemoResponse memoResponse = modelMapper.map(memo, MemoResponse.class); + return ResponseEntity.ok(memoResponse); + } + + // -- 메모 삭제 -- + public ResponseEntity deleteMemo(UserDetailsCustom userDetails, Long id) throws AccessDeniedException { + Memo memo = memoRepository.findById(id) + .orElseThrow(()->new EntityNotFoundException("메모를 찾을 수 없습니다.")); + + User user = userService.getUserFromSecurityContext(userDetails); + + // 현재 사용자와 메모 소유자가 일치하는 지 확인 + if(!memo.getUser().equals(user)) { + throw new AccessDeniedException("해당 메모에 접근할 권한이 없습니다."); + } + + memoRepository.deleteById(memo.getId()); + return ResponseEntity.ok("메모가 정상적으로 삭제되었습니다."); + } +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/memo/service/MemoServiceImpl.java b/back-end/src/main/java/com/techie/backend/memo/service/MemoServiceImpl.java deleted file mode 100644 index 03bf2cba..00000000 --- a/back-end/src/main/java/com/techie/backend/memo/service/MemoServiceImpl.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.techie.backend.memo.service; - -import com.techie.backend.global.exception.memo.EmptyContentException; -import com.techie.backend.global.security.UserDetailsCustom; -import com.techie.backend.memo.domain.Memo; -import com.techie.backend.memo.dto.MemoRequest; -import com.techie.backend.memo.dto.MemoResponse; -import com.techie.backend.memo.repository.MemoRepository; -import com.techie.backend.user.domain.User; -import com.techie.backend.user.service.UserService; -import com.techie.backend.video.domain.Video; -import com.techie.backend.video.dto.VideoResponse; -import com.techie.backend.video.repository.VideoRepository; -import com.techie.backend.video.service.VideoService; -import jakarta.persistence.EntityNotFoundException; -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.modelmapper.ModelMapper; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.SliceImpl; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.parameters.P; -import org.springframework.stereotype.Service; - -import java.nio.file.AccessDeniedException; -import java.util.Collections; -import java.util.List; - -@Service -@Transactional -@RequiredArgsConstructor -@Slf4j -public class MemoServiceImpl implements MemoService { - private final MemoRepository memoRepository; - private final VideoService videoService; - private final VideoRepository videoRepository; - private final UserService userService; - private final ModelMapper modelMapper; - - // -- 메모 생성 -- - @Override - public ResponseEntity createMemo(MemoRequest request, UserDetailsCustom userDetails) { - if (request.getContent() == null || request.getContent().isBlank()) { - throw new EmptyContentException(); - } - - Memo memo = modelMapper.map(request, Memo.class); - - // 메모 소유자 할당 - User user = userService.getUserFromSecurityContext(userDetails); - memo.assignUser(user); - - String videoId = request.getVideoId(); - if (videoId != null){ - Video video = videoRepository.findByVideoId(videoId).orElseGet(()-> { - String videoUri = videoService.buildVideoUri(videoId); - String videoResponseJson = videoService.getYoutubeResponse(videoUri).getBody(); - VideoResponse videoResponse = videoService.convertJsonToVideoDTOWithoutPaging(videoResponseJson).get(0); - String title = videoResponse.getTitle(); - return videoRepository.save(Video - .builder() - .videoId(videoId) - .title(title) - .build()); - } - ); - memo.assignVideo(video); - } - - memoRepository.save(memo); - MemoResponse memoResponse = modelMapper.map(memo, MemoResponse.class); - return ResponseEntity.ok(memoResponse); - } - - // -- 메모 목록 조회 -- - @Override - public ResponseEntity> getMemoList(UserDetailsCustom userDetails, Pageable pageable) { - User user = userService.getUserFromSecurityContext(userDetails); - Slice memoSlice = memoRepository.findByUser(user, pageable) - .map(m -> modelMapper.map(m, MemoResponse.class)); - - return ResponseEntity.ok(memoSlice); - } - - // -- 메모 단건 조회 -- - @Override - public ResponseEntity getMemo(UserDetailsCustom userDetails, Long id) throws AccessDeniedException { - Memo memo = memoRepository.findById(id) - .orElseThrow(()->new EntityNotFoundException("메모를 찾을 수 없습니다.")); - - User user = userService.getUserFromSecurityContext(userDetails); - // 현재 사용자와 메모 소유자가 일치하는 지 확인 - if(!memo.getUser().equals(user)) { - throw new AccessDeniedException("해당 메모에 접근할 권한이 없습니다."); - } - - MemoResponse memoResponse = modelMapper.map(memo, MemoResponse.class); - return ResponseEntity.ok(memoResponse); - } - - // -- 영상 별 메모 조회 - @Override - public ResponseEntity> getAllMemosByVideoId(UserDetailsCustom userDetails, String videoId, Pageable pageable) { - User user = userService.getUserFromSecurityContext(userDetails); - - // 조건에 맞는 정보가 없다면 빈 슬라이스 반환 - return ResponseEntity.ok( - videoRepository.findByVideoId(videoId) - .map(video -> memoRepository.findByUserAndVideo(user, video, pageable) - .map(memo -> modelMapper.map(memo, MemoResponse.class))) - .orElseGet(() -> new SliceImpl<>(Collections.emptyList(), pageable, false)) - ); - } - - // -- 메모 수정 -- - @Override - public ResponseEntity updateMemo(UserDetailsCustom userDetails, Long id, MemoRequest.Update updateRequest) throws AccessDeniedException { - Memo memo = memoRepository.findById(id) - .orElseThrow(()->new EntityNotFoundException("메모를 찾을 수 없습니다.")); - - User user = userService.getUserFromSecurityContext(userDetails); - // 현재 사용자와 메모 소유자가 일치하는 지 확인 - if(!memo.getUser().equals(user)) { - throw new AccessDeniedException("해당 메모에 접근할 권한이 없습니다."); - } - - memo.changeTitle(updateRequest.getTitle()); - String content = updateRequest.getContent(); - if (content == null || content.isBlank()) { - throw new EmptyContentException(); - } - memo.changeContent(content); - memoRepository.save(memo); - - MemoResponse memoResponse = modelMapper.map(memo, MemoResponse.class); - return ResponseEntity.ok(memoResponse); - } - - // -- 메모 삭제 -- - @Override - public ResponseEntity deleteMemo(UserDetailsCustom userDetails, Long id) throws AccessDeniedException { - Memo memo = memoRepository.findById(id) - .orElseThrow(()->new EntityNotFoundException("메모를 찾을 수 없습니다.")); - - User user = userService.getUserFromSecurityContext(userDetails); - - // 현재 사용자와 메모 소유자가 일치하는 지 확인 - if(!memo.getUser().equals(user)) { - throw new AccessDeniedException("해당 메모에 접근할 권한이 없습니다."); - } - - memoRepository.deleteById(memo.getId()); - return ResponseEntity.ok("메모가 정상적으로 삭제되었습니다."); - } -} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java b/back-end/src/main/java/com/techie/backend/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java new file mode 100644 index 00000000..c7b9f1e4 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java @@ -0,0 +1,61 @@ +package com.techie.backend.oauth2; + +import com.techie.backend.oauth2.util.CookieUtils; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + + +@RequiredArgsConstructor +@Component +public class HttpCookieOAuth2AuthorizationRequestRepository + implements AuthorizationRequestRepository { + + public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request"; + public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri"; + public static final String MODE_PARAM_COOKIE_NAME = "mode"; + private static final int COOKIE_EXPIRE_SECONDS = 180; + + @Override + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { + return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME) + .map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class)) + .orElse(null); + } + + @Override + public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) { + if(authorizationRequest == null) { + this.deleteAuthorizationRequestCookies(request, response); + return; + } + + CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, + CookieUtils.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS); + + String redirectUri = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME); + if(StringUtils.hasText(redirectUri)) { + CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUri, COOKIE_EXPIRE_SECONDS); + } + + String mode = request.getParameter(MODE_PARAM_COOKIE_NAME); + if(StringUtils.hasText(mode)) { + CookieUtils.addCookie(response, MODE_PARAM_COOKIE_NAME, mode, COOKIE_EXPIRE_SECONDS); + } + } + + @Override + public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) { + return this.loadAuthorizationRequest(request); + } + + public void deleteAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) { + CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME); + CookieUtils.deleteCookie(request, response, MODE_PARAM_COOKIE_NAME); + } +} diff --git a/back-end/src/main/java/com/techie/backend/oauth2/handler/OAuth2AuthenticationFailureHandler.java b/back-end/src/main/java/com/techie/backend/oauth2/handler/OAuth2AuthenticationFailureHandler.java new file mode 100644 index 00000000..79b1f25a --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/oauth2/handler/OAuth2AuthenticationFailureHandler.java @@ -0,0 +1,41 @@ +package com.techie.backend.oauth2.handler; + +import com.techie.backend.oauth2.HttpCookieOAuth2AuthorizationRequestRepository; +import com.techie.backend.oauth2.util.CookieUtils; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + +import static com.techie.backend.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME; + + +@RequiredArgsConstructor +@Component +public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException { + + String targetUrl = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME) + .map(Cookie::getValue) + .orElse(("/")); + + targetUrl = UriComponentsBuilder.fromUriString(targetUrl) + .queryParam("error", exception.getLocalizedMessage()) + .build().toUriString(); + + httpCookieOAuth2AuthorizationRequestRepository.deleteAuthorizationRequestCookies(request, response); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/oauth2/handler/OAuth2AuthenticationSuccessHandler.java b/back-end/src/main/java/com/techie/backend/oauth2/handler/OAuth2AuthenticationSuccessHandler.java new file mode 100644 index 00000000..455f376a --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/oauth2/handler/OAuth2AuthenticationSuccessHandler.java @@ -0,0 +1,124 @@ +package com.techie.backend.oauth2.handler; + +import com.techie.backend.global.exception.user.UserNotFoundException; +import com.techie.backend.global.security.JWTUtil; +import com.techie.backend.oauth2.HttpCookieOAuth2AuthorizationRequestRepository; +import com.techie.backend.oauth2.service.OAuth2UserPrincipal; +import com.techie.backend.oauth2.user.OAuth2Provider; +import com.techie.backend.oauth2.user.OAuth2UserUnlinkManager; +import com.techie.backend.oauth2.util.CookieUtils; +import com.techie.backend.user.domain.User; +import com.techie.backend.user.repository.UserRepository; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.util.Optional; + +import static com.techie.backend.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.MODE_PARAM_COOKIE_NAME; +import static com.techie.backend.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME; + +@RequiredArgsConstructor +@Component +@Slf4j +public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository; + private final OAuth2UserUnlinkManager oAuth2UserUnlinkManager; + private final UserRepository userRepository; + private final JWTUtil jwtUtil; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + String targetUrl; + targetUrl = determineTargetUrl(request, response, authentication); + + if (response.isCommitted()) { + log.debug("응답이 이미 완료되었습니다."); + return; + } + + clearAuthenticationAttributes(request, response); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } + + protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) { + + Optional redirectUri = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME) + .map(Cookie::getValue); + + String targetUrl = redirectUri.orElse(getDefaultTargetUrl()); + + String mode = CookieUtils.getCookie(request, MODE_PARAM_COOKIE_NAME) + .map(Cookie::getValue) + .orElse(""); + + OAuth2UserPrincipal principal = getOAuth2UserPrincipal(authentication); + + if (principal == null) { + return UriComponentsBuilder.fromUriString(targetUrl) + .queryParam("error", "login failed - 1") + .build().toUriString(); + } + + String email = principal.getUserInfo().getEmail(); + String providerName = principal.getUserInfo().getProvider().name(); + String name = principal.getUserInfo().getName(); + + // login + if ("login".equalsIgnoreCase(mode)) { + User user = userRepository.findByEmailAndProvider(email, providerName) + .orElseGet(()-> userRepository.save( + User.createSocialUser(email, name, "ROLE_USER", providerName)) + ); + + String accessToken = jwtUtil.createSocialJwt(email, name, "ROLE_USER", providerName, 60 * 60 * 1000L); + + return UriComponentsBuilder.fromUriString(targetUrl) + .queryParam("access_token", accessToken) + .build().toUriString(); + + // unlink + } else if ("unlink".equalsIgnoreCase(mode)) { + + String accessToken = principal.getUserInfo().getAccessToken(); + OAuth2Provider provider = principal.getUserInfo().getProvider(); + User user = userRepository.findByEmailAndProvider(email, providerName).orElseThrow(UserNotFoundException::new); + userRepository.delete(user); + oAuth2UserUnlinkManager.unlink(provider, accessToken); + + return UriComponentsBuilder.fromUriString(targetUrl) + .build().toUriString(); + } + + return UriComponentsBuilder.fromUriString(targetUrl) + .queryParam("error", "login failed - 2") + .build().toUriString(); + } + + private OAuth2UserPrincipal getOAuth2UserPrincipal(Authentication authentication) { + Object principal = authentication.getPrincipal(); + + if (principal instanceof OAuth2UserPrincipal) { + return (OAuth2UserPrincipal) principal; + } + return null; + } + + protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) { + super.clearAuthenticationAttributes(request); + httpCookieOAuth2AuthorizationRequestRepository.deleteAuthorizationRequestCookies(request, response); + } +} diff --git a/back-end/src/main/java/com/techie/backend/oauth2/service/CustomOAuth2UserService.java b/back-end/src/main/java/com/techie/backend/oauth2/service/CustomOAuth2UserService.java new file mode 100644 index 00000000..744da459 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/oauth2/service/CustomOAuth2UserService.java @@ -0,0 +1,41 @@ +package com.techie.backend.oauth2.service; + +import com.techie.backend.global.exception.user.OAuth2AuthenticationProcessingException; +import com.techie.backend.oauth2.user.OAuth2UserInfo; +import com.techie.backend.oauth2.user.OAuth2UserInfoFactory; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + @Override + public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(request); + try { + return processOAuth2User(request, oAuth2User); + } catch (AuthenticationException ex) { + throw ex; + } catch (Exception ex) { + throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause()); + } + } + + private OAuth2User processOAuth2User(OAuth2UserRequest request, OAuth2User oAuth2User) { + String registrationId = request.getClientRegistration().getRegistrationId(); + + String accessToken = request.getAccessToken().getTokenValue(); + + OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, accessToken, oAuth2User.getAttributes()); + if (!StringUtils.hasText(oAuth2UserInfo.getEmail())) { + throw new OAuth2AuthenticationProcessingException("Email not found from OAuth2 provider"); + } + + return new OAuth2UserPrincipal(oAuth2UserInfo); + } +} diff --git a/back-end/src/main/java/com/techie/backend/oauth2/service/OAuth2UserPrincipal.java b/back-end/src/main/java/com/techie/backend/oauth2/service/OAuth2UserPrincipal.java new file mode 100644 index 00000000..09040152 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/oauth2/service/OAuth2UserPrincipal.java @@ -0,0 +1,67 @@ +package com.techie.backend.oauth2.service; + +import com.techie.backend.oauth2.user.OAuth2UserInfo; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +public class OAuth2UserPrincipal implements OAuth2User, UserDetails { + private final OAuth2UserInfo userInfo; + + public OAuth2UserPrincipal(OAuth2UserInfo userInfo) { + this.userInfo = userInfo; + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return userInfo.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public Map getAttributes() { + return userInfo.getAttributes(); + } + + @Override + public Collection getAuthorities() { + return Collections.emptyList(); + } + + @Override + public String getName() { + return userInfo.getEmail(); + } + + public OAuth2UserInfo getUserInfo() { + return userInfo; + } +} diff --git a/back-end/src/main/java/com/techie/backend/oauth2/user/GoogleOAuth2UserInfo.java b/back-end/src/main/java/com/techie/backend/oauth2/user/GoogleOAuth2UserInfo.java new file mode 100644 index 00000000..f3a42adc --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/oauth2/user/GoogleOAuth2UserInfo.java @@ -0,0 +1,70 @@ +package com.techie.backend.oauth2.user; + +import java.util.Map; + +public class GoogleOAuth2UserInfo implements OAuth2UserInfo { + private final Map attributes; + private final String accessToken; + private final String id; + private final String email; + private final String name; + private final String firstName; + private final String lastName; + private final String nickName; + + public GoogleOAuth2UserInfo(String accessToken, Map attributes) { + this.accessToken = accessToken; + this.attributes = attributes; + this.id = (String) attributes.get("sub"); + this.email = (String) attributes.get("email"); + this.name = (String) attributes.get("name"); + this.firstName = (String) attributes.get("given_name"); + this.lastName = (String) attributes.get("family_name"); + this.nickName = null; + } + + @Override + public OAuth2Provider getProvider() { + return OAuth2Provider.GOOGLE; + } + + @Override + public String getAccessToken() { + return accessToken; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getId() { + return id; + } + + @Override + public String getEmail() { + return email; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getFirstName() { + return firstName; + } + + @Override + public String getLastName() { + return lastName; + } + + @Override + public String getNickname() { + return nickName; + } +} diff --git a/back-end/src/main/java/com/techie/backend/oauth2/user/GoogleOAuth2UserUnlink.java b/back-end/src/main/java/com/techie/backend/oauth2/user/GoogleOAuth2UserUnlink.java new file mode 100644 index 00000000..a0b31890 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/oauth2/user/GoogleOAuth2UserUnlink.java @@ -0,0 +1,25 @@ +package com.techie.backend.oauth2.user; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +@RequiredArgsConstructor +@Component +public class GoogleOAuth2UserUnlink implements OAuth2UserUnlink { + private static final String URL = "https://oauth2.googleapis.com/revoke"; + private final RestClient restClient; + + @Override + public void unlink(String accessToken) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("token", accessToken); + restClient.post() + .uri(URL) + .body(params) + .retrieve() + .body(String.class); + } +} diff --git a/back-end/src/main/java/com/techie/backend/oauth2/user/NaverOAuth2UserInfo.java b/back-end/src/main/java/com/techie/backend/oauth2/user/NaverOAuth2UserInfo.java new file mode 100644 index 00000000..583086a5 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/oauth2/user/NaverOAuth2UserInfo.java @@ -0,0 +1,71 @@ +package com.techie.backend.oauth2.user; + +import java.util.Map; + +public class NaverOAuth2UserInfo implements OAuth2UserInfo { + private final Map attributes; + private final String accessToken; + private final String id; + private final String email; + private final String name; + private final String firstName; + private final String lastName; + private final String nickName; + + public NaverOAuth2UserInfo(String accessToken, Map attributes) { + this.accessToken = accessToken; + this.attributes = (Map) attributes.get("response"); + this.id = (String) this.attributes.get("id"); + this.email = (String) this.attributes.get("email"); + this.name = (String) this.attributes.get("name"); + this.firstName = null; + this.lastName = null; + this.nickName = (String) attributes.get("nickname"); + } + + @Override + public OAuth2Provider getProvider() { + return OAuth2Provider.NAVER; + } + + @Override + public String getAccessToken() { + return accessToken; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getId() { + return id; + } + + @Override + public String getEmail() { + return email; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getFirstName() { + return firstName; + } + + @Override + public String getLastName() { + return lastName; + } + + @Override + public String getNickname() { + return nickName; + } + +} diff --git a/back-end/src/main/java/com/techie/backend/oauth2/user/NaverOAuth2UserUnlink.java b/back-end/src/main/java/com/techie/backend/oauth2/user/NaverOAuth2UserUnlink.java new file mode 100644 index 00000000..8d0978b0 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/oauth2/user/NaverOAuth2UserUnlink.java @@ -0,0 +1,51 @@ +package com.techie.backend.oauth2.user; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +@RequiredArgsConstructor +@Component +public class NaverOAuth2UserUnlink implements OAuth2UserUnlink { + + private static final String URL = "https://nid.naver.com/oauth2.0/token"; + private final RestClient restClient; + + @Value("${spring.security.oauth2.client.registration.naver.client-id}") + private String clientId; + @Value("${spring.security.oauth2.client.registration.naver.client-secret}") + private String clientSecret; + + @Override + public void unlink(String accessToken) { + String url = UriComponentsBuilder.fromHttpUrl(URL) + .queryParam("service_provider", "NAVER") + .queryParam("grant_type", "delete") + .queryParam("client_id", clientId) + .queryParam("client_secret", clientSecret) + .queryParam("access_token", accessToken) + .toUriString(); + + UnlinkResponse response = restClient + .get() + .uri(url) + .retrieve() + .body(UnlinkResponse.class); + + if (response != null && !"success".equalsIgnoreCase(response.getResult())) { + throw new RuntimeException("네이버 Unlink에 실패하였습니다."); + } + } + + @Getter + @RequiredArgsConstructor + public static class UnlinkResponse { + @JsonProperty("access_token") + private final String accessToken; + private final String result; + } +} diff --git a/back-end/src/main/java/com/techie/backend/oauth2/user/OAuth2Provider.java b/back-end/src/main/java/com/techie/backend/oauth2/user/OAuth2Provider.java new file mode 100644 index 00000000..60158273 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/oauth2/user/OAuth2Provider.java @@ -0,0 +1,14 @@ +package com.techie.backend.oauth2.user; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum OAuth2Provider { + GOOGLE("google"), + NAVER("naver"), + KAKAO("kakao"); + + private final String provider; +} diff --git a/back-end/src/main/java/com/techie/backend/oauth2/user/OAuth2UserInfo.java b/back-end/src/main/java/com/techie/backend/oauth2/user/OAuth2UserInfo.java new file mode 100644 index 00000000..2b91f108 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/oauth2/user/OAuth2UserInfo.java @@ -0,0 +1,23 @@ +package com.techie.backend.oauth2.user; + +import java.util.Map; + +public interface OAuth2UserInfo { + OAuth2Provider getProvider(); + + String getAccessToken(); + + Map getAttributes(); + + String getId(); + + String getEmail(); + + String getName(); + + String getFirstName(); + + String getLastName(); + + String getNickname(); +} diff --git a/back-end/src/main/java/com/techie/backend/oauth2/user/OAuth2UserInfoFactory.java b/back-end/src/main/java/com/techie/backend/oauth2/user/OAuth2UserInfoFactory.java new file mode 100644 index 00000000..021f2422 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/oauth2/user/OAuth2UserInfoFactory.java @@ -0,0 +1,17 @@ +package com.techie.backend.oauth2.user; + +import com.techie.backend.global.exception.user.OAuth2AuthenticationProcessingException; + +import java.util.Map; + +public class OAuth2UserInfoFactory { + public static OAuth2UserInfo getOAuth2UserInfo(String provider, String accessToken, Map attributes) { + if (OAuth2Provider.GOOGLE.getProvider().equals(provider)) { + return new GoogleOAuth2UserInfo(accessToken, attributes); + } else if (OAuth2Provider.NAVER.getProvider().equals(provider)) { + return new NaverOAuth2UserInfo(accessToken, attributes); + } else { + throw new OAuth2AuthenticationProcessingException(provider + "을/를 통한 로그인은 불가합니다."); + } + } +} diff --git a/back-end/src/main/java/com/techie/backend/oauth2/user/OAuth2UserUnlink.java b/back-end/src/main/java/com/techie/backend/oauth2/user/OAuth2UserUnlink.java new file mode 100644 index 00000000..c6520fb7 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/oauth2/user/OAuth2UserUnlink.java @@ -0,0 +1,5 @@ +package com.techie.backend.oauth2.user; + +public interface OAuth2UserUnlink { + void unlink(String accessToken); +} diff --git a/back-end/src/main/java/com/techie/backend/oauth2/user/OAuth2UserUnlinkManager.java b/back-end/src/main/java/com/techie/backend/oauth2/user/OAuth2UserUnlinkManager.java new file mode 100644 index 00000000..4eccaebb --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/oauth2/user/OAuth2UserUnlinkManager.java @@ -0,0 +1,24 @@ +package com.techie.backend.oauth2.user; + +import com.techie.backend.global.exception.user.OAuth2AuthenticationProcessingException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class OAuth2UserUnlinkManager { + + private final GoogleOAuth2UserUnlink googleOAuth2UserUnlink; + private final NaverOAuth2UserUnlink naverOAuth2UserUnlink; + + public void unlink(OAuth2Provider provider, String accessToken) { + if (OAuth2Provider.GOOGLE.equals(provider)) { + googleOAuth2UserUnlink.unlink(accessToken); + } else if (OAuth2Provider.NAVER.equals(provider)) { + naverOAuth2UserUnlink.unlink(accessToken); + } else { + throw new OAuth2AuthenticationProcessingException( + "Unlink with " + provider.getProvider() + " is not supported"); + } + } +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/oauth2/util/CookieUtils.java b/back-end/src/main/java/com/techie/backend/oauth2/util/CookieUtils.java new file mode 100644 index 00000000..a1701e31 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/oauth2/util/CookieUtils.java @@ -0,0 +1,56 @@ +package com.techie.backend.oauth2.util; + + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.util.SerializationUtils; + +import java.util.Base64; +import java.util.Optional; + +public class CookieUtils { + + public static Optional getCookie(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(name)) { + return Optional.of(cookie); + } + } + } + return Optional.empty(); + } + + public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) { + Cookie cookie = new Cookie(name, value); + cookie.setMaxAge(maxAge); + cookie.setPath("/"); + cookie.setHttpOnly(true); + response.addCookie(cookie); + } + + public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(name)) { + cookie.setValue(null); + cookie.setMaxAge(0); + cookie.setPath("/"); + response.addCookie(cookie); + } + } + } + } + + public static String serialize(Object object) { + return Base64.getUrlEncoder() + .encodeToString(SerializationUtils.serialize(object)); + } + + public static T deserialize(Cookie cookie, Class tClass) { + return tClass.cast(SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue()))); + } +} diff --git a/back-end/src/main/java/com/techie/backend/playlist/service/PlaylistService.java b/back-end/src/main/java/com/techie/backend/playlist/service/PlaylistService.java index b0953a41..5498c848 100644 --- a/back-end/src/main/java/com/techie/backend/playlist/service/PlaylistService.java +++ b/back-end/src/main/java/com/techie/backend/playlist/service/PlaylistService.java @@ -1,15 +1,194 @@ - package com.techie.backend.playlist.service; + +import com.techie.backend.global.exception.playlist.PlaylistNotFoundException; +import com.techie.backend.global.exception.playlist.VideoNotFoundException; import com.techie.backend.global.security.UserDetailsCustom; +import com.techie.backend.playlist.domain.Playlist; import com.techie.backend.playlist.dto.PlaylistRequest; import com.techie.backend.playlist.dto.PlaylistResponse; +import com.techie.backend.playlist.repository.PlaylistRepository; +import com.techie.backend.playlist_video.domain.PlaylistVideo; +import com.techie.backend.playlist_video.repository.PlaylistVideoRepository; +import com.techie.backend.user.domain.User; +import com.techie.backend.user.service.UserService; +import com.techie.backend.video.domain.Video; +import com.techie.backend.video.dto.VideoResponse; +import com.techie.backend.video.repository.VideoRepository; +import com.techie.backend.video.service.VideoService; +import lombok.RequiredArgsConstructor; +import org.modelmapper.ModelMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PlaylistService { + private final PlaylistRepository playlistRepository; + private final PlaylistVideoRepository playlistVideoRepository; + private final VideoRepository videoRepository; + private final UserService userService; + private final VideoService videoService; + private final ModelMapper modelMapper; + + @Transactional + public Boolean createPlaylist(UserDetailsCustom userDetails, PlaylistRequest.CreatePlaylist request) { + User user = userService.getUserFromSecurityContext(userDetails); + + Playlist playlist = Playlist.builder() + .name(request.getPlaylistName()) + .user(user) + .build(); + playlistRepository.save(playlist); + + if (request.getVideoId() == null || request.getVideoId().isEmpty()) { + return true; + } + + String videoId = request.getVideoId(); + String videoUri = videoService.buildVideoUri(videoId); + String videoResponseJson = videoService.getYoutubeResponse(videoUri).getBody(); + List videoResponses = videoService.convertJsonToVideoDTOWithoutPaging(videoResponseJson); + + if (videoResponses.isEmpty()) { + return false; + } + + VideoResponse videoResponse = videoResponses.get(0); + Video video = videoRepository.findByVideoId(videoId) + .orElseGet(() -> { + Video newVideo = Video.builder() + .videoId(videoId) + .title(videoResponse.getTitle()) + .build(); + return videoRepository.save(newVideo); + }); + + playlist.addVideo(video); + playlistRepository.save(playlist); + return true; + } + + public PlaylistResponse.Overview getPlaylists(UserDetailsCustom userDetails) { + User user = userService.getUserFromSecurityContext(userDetails); + List playlists = playlistRepository.findAllByUser(user); + + if (playlists.isEmpty()) { + return new PlaylistResponse.Overview(List.of()); + } + + List playlistSummaries = playlists.stream() + .map(playlist -> { + List