From e4ee87ee0c7f37548089a77aa79a10e747ac978a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sat, 19 Apr 2025 15:30:05 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20post=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/hyetaekon/post/entity/Post.java | 32 ++++++++++++++----- .../hyetaekon/post/entity/PostImage.java | 6 ++-- .../hyetaekon/post/entity/PostType.java | 15 +++++++-- .../publicservice/entity/PublicService.java | 2 +- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java index f64acdd..2418f11 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java @@ -3,14 +3,18 @@ import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; import com.hyetaekon.hyetaekon.user.entity.User; import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; +import lombok.*; + import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Entity @Getter @Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor @Table(name = "post") public class Post { @@ -26,10 +30,10 @@ public class Post { @JoinColumn(name = "public_service_id") private PublicService publicService; - @Column(length = 20, nullable = false) // ✅ 제목 20자 제한 + @Column(length = 50, nullable = false) // ✅ 제목 25자 제한 private String title; - @Column(length = 500, nullable = false) // ✅ 내용 500자 제한 + @Column(columnDefinition = "VARCHAR(500) CHARACTER SET utf8mb4", nullable = false) // ✅ 내용 500자 제한 private String content; private LocalDateTime createdAt = LocalDateTime.now(); @@ -38,15 +42,18 @@ public class Post { private int recommendCnt; // 추천수 - private int viewCount; // 조회수 + private int viewCnt; // 조회수 + + // TODO: 댓글 생성/수정 시 업데이트 + private int commentCnt; // 댓글수 - @Column(name = "post_type") + @Column(name = "post_type", nullable = false) @Enumerated(EnumType.STRING) // ✅ ENUM 타입으로 저장 (질문, 자유, 인사) private PostType postType; private String serviceUrl; - @Column(length = 12) // ✅ 관련 링크 제목 12자 제한 + @Column(length = 100) private String urlTitle; private String urlPath; @@ -57,6 +64,15 @@ public class Post { @Column(name = "category_id") private Long categoryId; + @Builder.Default @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) - private List postImages; // ✅ 게시글 이미지와 연결 + private List postImages = new ArrayList<>(); // ✅ 게시글 이미지와 연결 + + public void incrementCommentCnt() { + this.commentCnt++; + } + + public void decrementCommentCnt() { + this.commentCnt = Math.max(0, this.commentCnt - 1); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java index 177ac34..706f171 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java @@ -1,12 +1,14 @@ package com.hyetaekon.hyetaekon.post.entity; import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; +import lombok.*; @Entity @Getter @Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor public class PostImage { @Id diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostType.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostType.java index db9cb66..f777c54 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostType.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostType.java @@ -1,7 +1,16 @@ package com.hyetaekon.hyetaekon.post.entity; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor public enum PostType { - QUESTION, // 질문 게시판 - FREE, // 자유 게시판 - GREETING // 인사 게시판 + QUESTION("질문"), + FREE("자유"), + GREETING("인사"); + + @JsonValue + private final String koreanName; } diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java index b4f4a44..6bdbb99 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/entity/PublicService.java @@ -23,7 +23,7 @@ public class PublicService { // 서비스 분야 - 카테고리 + 해시태그 @Enumerated(EnumType.STRING) - @Column(nullable = false) + @Column(name = "public_category", nullable = false) private ServiceCategory serviceCategory; // 서비스 분야 @Column(name = "summary_purpose", columnDefinition = "TEXT") From bcc84f4af78dd2885b8cac7a10283b317ab72741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sat, 19 Apr 2025 16:24:20 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20post=20=EC=A0=84=EC=B2=B4=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C,=20postType=EB=B3=84=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9E=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 45 ++++++++++++------- .../post/dto/PostDetailReponseDto.java | 26 +++++++++++ .../post/dto/PostListResponseDto.java | 20 +++++++++ .../post/repository/PostRepository.java | 2 +- .../hyetaekon/post/service/PostService.java | 2 +- 5 files changed, 77 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java index bc69467..fbd30d7 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java @@ -1,13 +1,20 @@ package com.hyetaekon.hyetaekon.post.controller; +import com.hyetaekon.hyetaekon.post.dto.PostDetailReponseDto; import com.hyetaekon.hyetaekon.post.dto.PostDto; +import com.hyetaekon.hyetaekon.post.dto.PostListResponseDto; +import com.hyetaekon.hyetaekon.post.entity.PostType; import com.hyetaekon.hyetaekon.post.service.PostService; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.List; +@Slf4j @RestController @RequestMapping("/api/posts") @RequiredArgsConstructor @@ -15,28 +22,34 @@ public class PostController { private final PostService postService; - // ✅ 게시글 생성 - @PostMapping - public ResponseEntity createPost(@RequestBody PostDto postDto) { - return ResponseEntity.ok(postService.createPost(postDto)); + // 전체 게시글 목록 조회 + @GetMapping + public ResponseEntity> getAllPosts( + @PageableDefault(page = 0, size = 10) Pageable pageable) { + Page posts = postService.getAllPosts(pageable); + return ResponseEntity.ok(posts); + } + + // PostType에 해당하는 게시글 목록 조회 + @GetMapping("/type/{postType}") + public ResponseEntity> getPostsByType( + @PathVariable PostType postType, + @PageableDefault(page = 0, size = 10) Pageable pageable) { + Page posts = postService.getPostsByType(postType, pageable); + return ResponseEntity.ok(posts); } // ✅ 특정 게시글 조회 @GetMapping("/{postId}") - public ResponseEntity getPost(@PathVariable Long postId) { + public ResponseEntity getPost(@PathVariable Long postId) { return ResponseEntity.ok(postService.getPostById(postId)); } - // ✅ 특정 카테고리의 게시글 조회 - @GetMapping("/category/{categoryId}") - public ResponseEntity> getPostsByCategory(@PathVariable Long categoryId) { - return ResponseEntity.ok(postService.getPostsByCategoryId(categoryId)); - } - - // ✅ 모든 게시글 조회 - @GetMapping - public ResponseEntity> getAllPosts() { - return ResponseEntity.ok(postService.getAllPosts()); + // User, Admin에 따라 다른 접근 가능 + // ✅ 게시글 생성 + @PostMapping + public ResponseEntity createPost(@RequestBody PostDto postDto) { + return ResponseEntity.ok(postService.createPost(postDto)); } // ✅ 게시글 수정 diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java new file mode 100644 index 0000000..7007910 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java @@ -0,0 +1,26 @@ +package com.hyetaekon.hyetaekon.post.dto; + + +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostDetailReponseDto { + private Long postId; + private Long nickName; // 작성자 닉네임 + private String title; + private String content; + private LocalDateTime createdAt; + private int recommendCnt; + private int viewCnt; + private String urlTitle; + private String urlPath; + private String tags; + private List imageUrls; // ✅ 이미지 URL 리스트 추가 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java new file mode 100644 index 0000000..8484c26 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostListResponseDto.java @@ -0,0 +1,20 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.*; + +import java.time.LocalDateTime; + + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostListResponseDto { + private Long postId; + private String title; + private Long nickName; // 작성자 닉네임 + private String content; + private LocalDateTime createdAt; + private int viewCnt; + private String postType; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java index 2734c4e..0a72be9 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java @@ -8,7 +8,7 @@ @Repository public interface PostRepository extends JpaRepository { - List findByCategoryId(Long categoryId); // 추가됨 + List findByCategoryId(Long categoryId); boolean existsByUser_IdAndDeletedAtIsNull(Long userId); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java index 614f49c..0e30e9c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java @@ -66,7 +66,7 @@ public PostDto updatePost(Long id, PostDto postDto) { post.setDeletedAt(postDto.getDeletedAt()); post.setServiceUrl(postDto.getServiceUrl()); post.setRecommendCnt(postDto.getRecommendCnt()); - post.setViewCount(postDto.getViewCount()); + post.setViewCnt(postDto.getViewCnt()); post.setUrlTitle(postDto.getUrlTitle()); post.setUrlPath(postDto.getUrlPath()); post.setTags(postDto.getTags()); From 03fbcf8b131c33c77a1ace5ee3f2887a83e658c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sat, 19 Apr 2025 18:50:12 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/common/exception/ErrorCode.java | 5 ++- .../hyetaekon/hyetaekon/post/entity/Post.java | 37 +++++++++++++--- .../controller/RecommendController.java | 28 +++++++++++- .../recommend/service/RecommendService.java | 43 +++++++++++++++++++ .../user/controller/UserController.java | 18 ++------ 5 files changed, 108 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java b/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java index 215ae9a..52b2349 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/exception/ErrorCode.java @@ -40,7 +40,10 @@ public enum ErrorCode { // 좋아요 RECOMMEND_ALREADY_EXISTS(HttpStatus.CONFLICT, "RECOMMEND-001", "이미 좋아요를 누른 게시글입니다."), RECOMMEND_NOT_FOUND(HttpStatus.NOT_FOUND, "RECOMMEND-002", "좋아요 정보를 찾을 수 없습니다."), - POST_NOT_FOUND(HttpStatus.NOT_FOUND, "POST-001", "해당 게시글을 찾을 수 없습니다."), + RECOMMEND_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "RECOMMEND-003", "추천한 유저를 찾을 수 없습니다."), + + // 게시글 + POST_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND,"POST-001", "해당 아이디의 게시글을 찾을 수 없습니다"), // 관심사 선택 제한 INTEREST_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "INTEREST-001", "관심사는 최대 6개까지만 등록 가능합니다."), diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java index 2418f11..6ad232d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java @@ -1,6 +1,8 @@ package com.hyetaekon.hyetaekon.post.entity; +import com.hyetaekon.hyetaekon.bookmark.entity.Bookmark; import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import com.hyetaekon.hyetaekon.recommend.entity.Recommend; import com.hyetaekon.hyetaekon.user.entity.User; import jakarta.persistence.*; import lombok.*; @@ -30,7 +32,7 @@ public class Post { @JoinColumn(name = "public_service_id") private PublicService publicService; - @Column(length = 50, nullable = false) // ✅ 제목 25자 제한 + @Column(columnDefinition = "VARCHAR(20) CHARACTER SET utf8mb4", nullable = false) // ✅ 제목 20자 제한 private String title; @Column(columnDefinition = "VARCHAR(500) CHARACTER SET utf8mb4", nullable = false) // ✅ 내용 500자 제한 @@ -40,12 +42,18 @@ public class Post { private LocalDateTime deletedAt; - private int recommendCnt; // 추천수 + @Builder.Default + @Column(name = "recommend_cnt") + private int recommendCnt = 0; // 추천수 - private int viewCnt; // 조회수 + @Builder.Default + @Column(name = "view_cnt") + private int viewCnt = 0; // 조회수 // TODO: 댓글 생성/수정 시 업데이트 - private int commentCnt; // 댓글수 + @Builder.Default + @Column(name = "comment_cnt") + private int commentCnt = 0; // 댓글수 @Column(name = "post_type", nullable = false) @Enumerated(EnumType.STRING) // ✅ ENUM 타입으로 저장 (질문, 자유, 인사) @@ -53,7 +61,7 @@ public class Post { private String serviceUrl; - @Column(length = 100) + @Column(columnDefinition = "VARCHAR(12) CHARACTER SET utf8mb4") // ✅ url제목 12자 제한 private String urlTitle; private String urlPath; @@ -68,6 +76,25 @@ public class Post { @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) private List postImages = new ArrayList<>(); // ✅ 게시글 이미지와 연결 + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List recommends = new ArrayList<>(); + + // 조회수 증가 + public void incrementViewCnt() { + this.viewCnt++; + } + + // 추천수 증가 + public void incrementRecommendCnt() { + this.recommendCnt++; + } + + // 추천수 감소 + public void decrementRecommendCnt() { + this.recommendCnt = Math.max(0, this.recommendCnt - 1); + } + public void incrementCommentCnt() { this.commentCnt++; } diff --git a/src/main/java/com/hyetaekon/hyetaekon/recommend/controller/RecommendController.java b/src/main/java/com/hyetaekon/hyetaekon/recommend/controller/RecommendController.java index fb913c0..ad1c3d3 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/recommend/controller/RecommendController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/recommend/controller/RecommendController.java @@ -1,9 +1,13 @@ package com.hyetaekon.hyetaekon.recommend.controller; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.recommend.service.RecommendService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; @Slf4j @RestController @@ -11,5 +15,25 @@ @RequiredArgsConstructor public class RecommendController { + private final RecommendService recommendService; + // 북마크 추가 + @PostMapping + public ResponseEntity addBookmark( + @PathVariable("postId") Long postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + recommendService.addRecommend(postId, customUserDetails.getId()); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + // 북마크 제거 + @DeleteMapping + public ResponseEntity removeBookmark( + @PathVariable("postId") Long postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + recommendService.removeRecommend(postId, customUserDetails.getId()); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java b/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java index bbd312d..be49b5d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java @@ -1,12 +1,21 @@ package com.hyetaekon.hyetaekon.recommend.service; +import com.hyetaekon.hyetaekon.bookmark.entity.Bookmark; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.post.entity.Post; import com.hyetaekon.hyetaekon.post.repository.PostRepository; +import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import com.hyetaekon.hyetaekon.recommend.entity.Recommend; import com.hyetaekon.hyetaekon.recommend.repository.RecommendRepository; +import com.hyetaekon.hyetaekon.user.entity.User; import com.hyetaekon.hyetaekon.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import static com.hyetaekon.hyetaekon.common.exception.ErrorCode.*; +import static com.hyetaekon.hyetaekon.common.exception.ErrorCode.BOOKMARK_NOT_FOUND; + @Service @Transactional @RequiredArgsConstructor @@ -15,4 +24,38 @@ public class RecommendService { private final UserRepository userRepository; private final PostRepository postRepository; + public void addRecommend(Long postId, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(RECOMMEND_USER_NOT_FOUND)); + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new GlobalException(POST_NOT_FOUND_BY_ID)); + + // 이미 북마크가 있는지 확인 + if (recommendRepository.existsByUserIdAndPostId(userId, postId)) { + throw new GlobalException(BOOKMARK_ALREADY_EXISTS); + } + + Recommend recommend = Recommend.builder() + .user(user) + .post(post) + .build(); + + recommendRepository.save(recommend); + + // 북마크 수 증가 + post.incrementRecommendCnt(); + } + + @jakarta.transaction.Transactional + public void removeRecommend(Long postId, Long userId) { + Recommend recommend = recommendRepository.findByUserIdAndPostId(userId, postId) + .orElseThrow(() -> new GlobalException(RECOMMEND_NOT_FOUND)); + + recommendRepository.delete(recommend); + + // 추천수 감소 + Post post = recommend.getPost(); + post.decrementRecommendCnt(); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java index 6546a5d..0bd9662 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/user/controller/UserController.java @@ -105,24 +105,12 @@ public ResponseEntity> getBookmarkedServices( // @GetMapping("/me/posts") // @PreAuthorize("hasRole('USER')") // public ResponseEntity>> getMyPosts( -// @RequestParam(required = false) String postType, -// @RequestParam(defaultValue = "0") int page, -// @RequestParam(defaultValue = "10") int size) { +// @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + // @RequestParam(name = "size", defaultValue = "5") @Positive @Max(30) int size, + // @AuthenticationPrincipal CustomUserDetails userDetails) { // Page posts = userService.getMyPosts(postType, PageRequest.of(page, size)); // return ResponseEntity.ok(ApiResponseDto.success(posts)); // } -// - /** - * 작성한 댓글 목록 조회 - */ -// @GetMapping("/me/comments") -// @PreAuthorize("hasRole('USER')") -// public ResponseEntity>> getMyComments( -// @RequestParam(defaultValue = "0") int page, -// @RequestParam(defaultValue = "10") int size) { -// Page comments = userService.getMyComments(PageRequest.of(page, size)); -// return ResponseEntity.ok(ApiResponseDto.success(comments)); -// } } From 5506338ced204bb17992117a0859aeff8dc70e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sat, 19 Apr 2025 18:51:09 +0900 Subject: [PATCH 4/6] =?UTF-8?q?style:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hyetaekon/hyetaekon/post/dto/PostDto.java | 27 ------------------- .../recommend/service/RecommendService.java | 3 --- 2 files changed, 30 deletions(-) delete mode 100644 src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java deleted file mode 100644 index 34b975b..0000000 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.hyetaekon.hyetaekon.post.dto; - -import lombok.Getter; -import lombok.Setter; -import java.time.LocalDateTime; -import java.util.List; - -@Getter -@Setter -public class PostDto { - private Long id; - private Long userId; - private String publicServiceId; - private String title; - private String content; - private LocalDateTime createdAt; - private LocalDateTime deletedAt; // 삭제일 추가 - private String postType; - private String serviceUrl; - private int recommendCnt; - private int viewCount; - private String urlTitle; - private String urlPath; - private String tags; - private Long categoryId; // 추가됨 - private List imageUrls; // ✅ 이미지 URL 리스트 추가 -} diff --git a/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java b/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java index be49b5d..a37bf4d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/recommend/service/RecommendService.java @@ -1,10 +1,8 @@ package com.hyetaekon.hyetaekon.recommend.service; -import com.hyetaekon.hyetaekon.bookmark.entity.Bookmark; import com.hyetaekon.hyetaekon.common.exception.GlobalException; import com.hyetaekon.hyetaekon.post.entity.Post; import com.hyetaekon.hyetaekon.post.repository.PostRepository; -import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; import com.hyetaekon.hyetaekon.recommend.entity.Recommend; import com.hyetaekon.hyetaekon.recommend.repository.RecommendRepository; import com.hyetaekon.hyetaekon.user.entity.User; @@ -14,7 +12,6 @@ import org.springframework.transaction.annotation.Transactional; import static com.hyetaekon.hyetaekon.common.exception.ErrorCode.*; -import static com.hyetaekon.hyetaekon.common.exception.ErrorCode.BOOKMARK_NOT_FOUND; @Service @Transactional From 07c495babefdcae3419f52832f8f5810550079f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sat, 19 Apr 2025 18:59:13 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4,=20type=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=ED=8E=98=EC=9D=B4=EC=A7=95=20+=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=EC=97=AC=EB=B6=80=20=ED=99=95=EC=9D=B8,=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EA=B8=80=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20Dto=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 38 ++-- .../post/dto/PostCreateRequestDto.java | 23 ++ .../post/dto/PostDetailReponseDto.java | 2 + .../post/dto/PostUpdateRequestDto.java | 23 ++ .../hyetaekon/post/mapper/PostMapper.java | 71 +++---- .../post/repository/PostRepository.java | 13 +- .../hyetaekon/post/service/PostService.java | 199 ++++++++++++++---- 7 files changed, 270 insertions(+), 99 deletions(-) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java index fbd30d7..0dbcc90 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java @@ -1,8 +1,7 @@ package com.hyetaekon.hyetaekon.post.controller; -import com.hyetaekon.hyetaekon.post.dto.PostDetailReponseDto; -import com.hyetaekon.hyetaekon.post.dto.PostDto; -import com.hyetaekon.hyetaekon.post.dto.PostListResponseDto; +import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; +import com.hyetaekon.hyetaekon.post.dto.*; import com.hyetaekon.hyetaekon.post.entity.PostType; import com.hyetaekon.hyetaekon.post.service.PostService; import lombok.RequiredArgsConstructor; @@ -11,6 +10,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -39,29 +39,37 @@ public ResponseEntity> getPostsByType( return ResponseEntity.ok(posts); } - // ✅ 특정 게시글 조회 + // User, Admin에 따라 다른 접근 가능 + // ✅ 특정 게시글 상세 조회 @GetMapping("/{postId}") - public ResponseEntity getPost(@PathVariable Long postId) { - return ResponseEntity.ok(postService.getPostById(postId)); + public ResponseEntity getPost( + @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(postService.getPostById(postId, userDetails.getId())); } - // User, Admin에 따라 다른 접근 가능 // ✅ 게시글 생성 @PostMapping - public ResponseEntity createPost(@RequestBody PostDto postDto) { - return ResponseEntity.ok(postService.createPost(postDto)); + public ResponseEntity createPost( + @RequestBody PostCreateRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(postService.createPost(requestDto, userDetails.getId())); } - // ✅ 게시글 수정 + // ✅ 게시글 수정 - 본인 @PutMapping("/{postId}") - public ResponseEntity updatePost(@PathVariable Long postId, @RequestBody PostDto postDto) { - return ResponseEntity.ok(postService.updatePost(postId, postDto)); + public ResponseEntity updatePost( + @PathVariable Long postId, + @RequestBody PostUpdateRequestDto updateDto, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(postService.updatePost(postId, updateDto, userDetails.getId())); } - // ✅ 게시글 삭제 (soft delete 방식 사용 가능) + // ✅ 게시글 삭제 (soft delete 방식 사용 가능) - 본인 혹은 관리자 @DeleteMapping("/{postId}") - public ResponseEntity deletePost(@PathVariable Long postId) { - postService.deletePost(postId); + public ResponseEntity deletePost( + @PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails userDetails) { + postService.deletePost(postId, userDetails.getId(), userDetails.getRole()); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java new file mode 100644 index 0000000..2e7f5b4 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java @@ -0,0 +1,23 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostCreateRequestDto { + private Long nickName; + private String title; + private String content; + private LocalDateTime createdAt; + private String postType; + private String urlTitle; + private String urlPath; + private String tags; + private List imageUrls; // ✅ 이미지 URL 리스트 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java index 7007910..7c812e5 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java @@ -17,10 +17,12 @@ public class PostDetailReponseDto { private String title; private String content; private LocalDateTime createdAt; + private String postType; private int recommendCnt; private int viewCnt; private String urlTitle; private String urlPath; private String tags; private List imageUrls; // ✅ 이미지 URL 리스트 추가 + private boolean recommended; // 현재 로그인한 사용자의 추천 여부 } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java new file mode 100644 index 0000000..97a1eb2 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java @@ -0,0 +1,23 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostUpdateRequestDto { + private Long nickName; + private String title; + private String content; + // private LocalDateTime createdAt; + private String postType; + private String urlTitle; + private String urlPath; + private String tags; + private List imageUrls; // ✅ 이미지 URL 리스트 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java index 908c493..936000f 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java @@ -1,58 +1,51 @@ package com.hyetaekon.hyetaekon.post.mapper; -import com.hyetaekon.hyetaekon.post.dto.PostDto; +import com.hyetaekon.hyetaekon.post.dto.*; import com.hyetaekon.hyetaekon.post.entity.Post; import com.hyetaekon.hyetaekon.post.entity.PostImage; import com.hyetaekon.hyetaekon.post.entity.PostType; import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; import com.hyetaekon.hyetaekon.user.entity.User; -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; +import org.mapstruct.*; import java.util.List; import java.util.stream.Collectors; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface PostMapper { - @Mapping(target = "imageUrls", expression = "java(mapPostImages(post))") - @Mapping(target = "postType", expression = "java(post.getPostType() != null ? post.getPostType().name() : null)") - PostDto toDto(Post post); - - default Post toEntity(PostDto dto) { - Post post = new Post(); - // 👇 User 객체 세팅 - User user = new User(); - user.setId(dto.getUserId()); - post.setUser(user); - - // 👇 PublicService 객체 세팅 - PublicService publicService = new PublicService(); - publicService.setId(dto.getPublicServiceId()); - post.setPublicService(publicService); - post.setTitle(dto.getTitle()); - post.setContent(dto.getContent()); - post.setPostType(dto.getPostType() != null ? PostType.valueOf(dto.getPostType()) : null); - post.setServiceUrl(dto.getServiceUrl()); - post.setUrlTitle(dto.getUrlTitle()); - post.setUrlPath(dto.getUrlPath()); - post.setTags(dto.getTags()); - post.setCategoryId(dto.getCategoryId()); - - if (dto.getImageUrls() != null) { - List images = dto.getImageUrls().stream() - .map(url -> { - PostImage img = new PostImage(); - img.setImageUrl(url); - img.setPost(post); - return img; - }).collect(Collectors.toList()); - post.setPostImages(images); + // Post -> PostListResponseDto 변환 + @Mapping(source = "id", target = "postId") + @Mapping(source = "user.nickname", target = "nickName") + @Mapping(source = "postType.koreanName", target = "postType") + PostListResponseDto toPostListDto(Post post); + + // Post -> PostDetailResponseDto 변환 + @Mapping(source = "id", target = "postId") + @Mapping(source = "user.nickname", target = "nickName") + @Mapping(source = "postType.koreanName", target = "postType") + @Mapping(target = "imageUrls", expression = "java(mapImageUrls(post.getPostImages()))") + PostDetailReponseDto toPostDetailDto(Post post); + + // PostCreateRequestDto -> Post 변환 (새 게시글 생성) + @Mapping(target = "createdAt", expression = "java(java.time.LocalDateTime.now())") + Post toEntity(PostCreateRequestDto createDto); + + // 이미지 URL 목록 매핑을 위한 기본 메서드 + default List mapImageUrls(List postImages) { + if (postImages == null) { + return java.util.Collections.emptyList(); } - - return post; + return postImages.stream() + .map(com.hyetaekon.hyetaekon.post.entity.PostImage::getImageUrl) + .filter(java.util.Objects::nonNull) + .collect(java.util.stream.Collectors.toList()); } + // PostUpdateRequestDto로 Post 업데이트 + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + void updatePostFromDto(PostUpdateRequestDto updateDto, @MappingTarget Post post); + default List mapPostImages(Post post) { if (post.getPostImages() == null) return null; return post.getPostImages().stream() diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java index 0a72be9..e8cc50d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java @@ -1,14 +1,25 @@ package com.hyetaekon.hyetaekon.post.repository; import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.post.entity.PostType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface PostRepository extends JpaRepository { - List findByCategoryId(Long categoryId); + // 삭제되지 않은 모든 게시글 조회 (페이징) + Page findByDeletedAtIsNull(Pageable pageable); + + // 특정 타입의 삭제되지 않은 게시글 조회 (페이징) + Page findByPostTypeAndDeletedAtIsNull(PostType postType, Pageable pageable); + + // ID로 삭제되지 않은 게시글 조회 + Optional findByIdAndDeletedAtIsNull(Long id); boolean existsByUser_IdAndDeletedAtIsNull(Long userId); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java index 0e30e9c..bc01780 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java @@ -1,81 +1,192 @@ package com.hyetaekon.hyetaekon.post.service; -import com.hyetaekon.hyetaekon.post.dto.PostDto; +import com.hyetaekon.hyetaekon.post.dto.*; import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.post.entity.PostImage; import com.hyetaekon.hyetaekon.post.entity.PostType; import com.hyetaekon.hyetaekon.post.mapper.PostMapper; import com.hyetaekon.hyetaekon.post.repository.PostRepository; -import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; +import com.hyetaekon.hyetaekon.recommend.repository.RecommendRepository; import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; 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 jakarta.persistence.EntityNotFoundException; +import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class PostService { private final PostRepository postRepository; + private final UserRepository userRepository; + private final RecommendRepository recommendRepository; private final PostMapper postMapper; - public List getAllPosts() { - return postRepository.findAll() - .stream() - .map(postMapper::toDto) - .collect(Collectors.toList()); + /** + * 전체 게시글 목록 조회 (페이징) + */ + public Page getAllPosts(Pageable pageable) { + return postRepository.findByDeletedAtIsNull(pageable) + .map(postMapper::toPostListDto); } - public PostDto getPostById(Long id) { - Post post = postRepository.findById(id).orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다.")); - return postMapper.toDto(post); + /** + * 특정 타입의 게시글 목록 조회 (페이징) + */ + public Page getPostsByType(PostType postType, Pageable pageable) { + return postRepository.findByPostTypeAndDeletedAtIsNull(postType, pageable) + .map(postMapper::toPostListDto); } - public List getPostsByCategoryId(Long categoryId) { // 추가됨 - return postRepository.findByCategoryId(categoryId) - .stream() - .map(postMapper::toDto) - .collect(Collectors.toList()); + /** + * 특정 게시글 상세 조회(로그인 시) + */ + @Transactional + public PostDetailReponseDto getPostById(Long postId, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다: " + userId)); + + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다: " + postId)); + + // 조회수 증가 + post.incrementViewCnt(); + + // 사용자의 추천 여부 확인 + boolean recommended = recommendRepository.existsByUserIdAndPostId(userId, postId); + + // DTO 변환 및 추천 여부 설정 + PostDetailReponseDto responseDto = postMapper.toPostDetailDto(post); + responseDto.setRecommended(recommended); // DTO에 isRecommended 필드 추가 필요 + + return postMapper.toPostDetailDto(post); } - public PostDto createPost(PostDto postDto) { - Post post = postMapper.toEntity(postDto); + /** + * 게시글 생성(로그인 시) + */ + @Transactional + public PostDetailReponseDto createPost(PostCreateRequestDto requestDto, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다: " + userId)); + + // PostType Enum 변환 + PostType postType = findPostTypeByName(requestDto.getPostType()); + + // DTO -> Entity 변환 + Post post = postMapper.toEntity(requestDto); + post.setUser(user); + post.setPostType(postType); + + // 이미지 URL 처리 + if (requestDto.getImageUrls() != null && !requestDto.getImageUrls().isEmpty()) { + List postImages = new ArrayList<>(); + + for (String imageUrl : requestDto.getImageUrls()) { + PostImage postImage = PostImage.builder() + .post(post) + .imageUrl(imageUrl) + .build(); + postImages.add(postImage); + } + + post.setPostImages(postImages); + } + Post savedPost = postRepository.save(post); - return postMapper.toDto(savedPost); + return postMapper.toPostDetailDto(savedPost); } - public PostDto updatePost(Long id, PostDto postDto) { - Post post = postRepository.findById(id).orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다.")); + /** + * 게시글 수정 (본인만 가능) + */ + @Transactional + public PostDetailReponseDto updatePost(Long postId, PostUpdateRequestDto updateDto, Long userId) { + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다: " + postId)); - User user = new User(); - user.setId(postDto.getUserId()); - post.setUser(user); + // 작성자 확인 + if (!post.getUser().getId().equals(userId)) { + throw new AccessDeniedException("게시글 수정 권한이 없습니다"); + } - if (postDto.getPublicServiceId() != null) { - PublicService publicService = new PublicService(); - publicService.setId(postDto.getPublicServiceId()); // String 타입으로 변환됨 - post.setPublicService(publicService); - } else { - post.setPublicService(null); + // PostType 변환 + if (updateDto.getPostType() != null) { + PostType postType = findPostTypeByName(updateDto.getPostType()); + post.setPostType(postType); } - post.setTitle(postDto.getTitle()); - post.setContent(postDto.getContent()); - post.setPostType(PostType.valueOf(postDto.getPostType())); - post.setDeletedAt(postDto.getDeletedAt()); - post.setServiceUrl(postDto.getServiceUrl()); - post.setRecommendCnt(postDto.getRecommendCnt()); - post.setViewCnt(postDto.getViewCnt()); - post.setUrlTitle(postDto.getUrlTitle()); - post.setUrlPath(postDto.getUrlPath()); - post.setTags(postDto.getTags()); - post.setCategoryId(postDto.getCategoryId()); // 추가됨 - Post updatedPost = postRepository.save(post); - return postMapper.toDto(updatedPost); + // 기본 정보 업데이트 + postMapper.updatePostFromDto(updateDto, post); + + // 이미지 업데이트 처리 + if (updateDto.getImageUrls() != null) { + // 기존 이미지 제거 + post.getPostImages().clear(); + + // 새 이미지 추가 + List postImages = updateDto.getImageUrls().stream() + .map(imageUrl -> PostImage.builder() + .post(post) + .imageUrl(imageUrl) + .build()) + .collect(Collectors.toList()); + + post.getPostImages().addAll(postImages); + } + + return postMapper.toPostDetailDto(post); + } + + /** + * 게시글 삭제 (본인 또는 관리자만 가능) + */ + @Transactional + public void deletePost(Long postId, Long userId, String role) { + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다: " + postId)); + + // 작성자 또는 관리자 확인 + boolean isOwner = post.getUser().getId().equals(userId); + boolean isAdmin = "ROLE_ADMIN".equals(role); + + if (!isOwner && !isAdmin) { + throw new AccessDeniedException("게시글 삭제 권한이 없습니다"); + } + + // Soft Delete 처리 + post.setDeletedAt(LocalDateTime.now()); } - public void deletePost(Long id) { - postRepository.deleteById(id); + /** + * 게시글 타입명으로 Enum 조회 + */ + private PostType findPostTypeByName(String postTypeName) { + try { + // 한글명으로 찾기 + for (PostType type : PostType.values()) { + if (type.getKoreanName().equals(postTypeName)) { + return type; + } + } + + // Enum 상수명으로 찾기 + return PostType.valueOf(postTypeName); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 게시글 타입입니다: " + postTypeName); + } } + + } From cb6d65382c035ac481d00961630dd686bae1071a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5BCLOUD4=5D=20=EA=B3=A0=EB=B2=94=EC=84=9D?= Date: Sat, 26 Apr 2025 17:37:51 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8,=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B0=A9=EC=8B=9D=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C,=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80,=20soft=20Delete=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EB=8F=84=EC=9E=85,=20request=20=EC=8B=9C=EC=97=90=20url?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EC=95=84=EB=8B=8C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=97=85=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 6 +- .../post/dto/PostCreateRequestDto.java | 3 +- ...nseDto.java => PostDetailResponseDto.java} | 2 +- .../post/dto/PostUpdateRequestDto.java | 3 +- .../hyetaekon/post/entity/PostImage.java | 14 ++ .../post/mapper/PostImageMapper.java | 38 +++++ .../hyetaekon/post/mapper/PostMapper.java | 42 +++--- .../post/repository/PostImageRepository.java | 13 ++ .../hyetaekon/post/service/PostService.java | 139 +++++++++++++----- 9 files changed, 193 insertions(+), 67 deletions(-) rename src/main/java/com/hyetaekon/hyetaekon/post/dto/{PostDetailReponseDto.java => PostDetailResponseDto.java} (94%) create mode 100644 src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostImageMapper.java create mode 100644 src/main/java/com/hyetaekon/hyetaekon/post/repository/PostImageRepository.java diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java index 0dbcc90..c842b0b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java @@ -42,7 +42,7 @@ public ResponseEntity> getPostsByType( // User, Admin에 따라 다른 접근 가능 // ✅ 특정 게시글 상세 조회 @GetMapping("/{postId}") - public ResponseEntity getPost( + public ResponseEntity getPost( @PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails userDetails) { return ResponseEntity.ok(postService.getPostById(postId, userDetails.getId())); @@ -50,7 +50,7 @@ public ResponseEntity getPost( // ✅ 게시글 생성 @PostMapping - public ResponseEntity createPost( + public ResponseEntity createPost( @RequestBody PostCreateRequestDto requestDto, @AuthenticationPrincipal CustomUserDetails userDetails) { return ResponseEntity.ok(postService.createPost(requestDto, userDetails.getId())); @@ -58,7 +58,7 @@ public ResponseEntity createPost( // ✅ 게시글 수정 - 본인 @PutMapping("/{postId}") - public ResponseEntity updatePost( + public ResponseEntity updatePost( @PathVariable Long postId, @RequestBody PostUpdateRequestDto updateDto, @AuthenticationPrincipal CustomUserDetails userDetails) { diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java index 2e7f5b4..e542bd5 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java @@ -1,6 +1,7 @@ package com.hyetaekon.hyetaekon.post.dto; import lombok.*; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; import java.util.List; @@ -19,5 +20,5 @@ public class PostCreateRequestDto { private String urlTitle; private String urlPath; private String tags; - private List imageUrls; // ✅ 이미지 URL 리스트 + private List images; // 이미지 파일 } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java similarity index 94% rename from src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java rename to src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java index 7c812e5..a4ccc5a 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailReponseDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java @@ -11,7 +11,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor -public class PostDetailReponseDto { +public class PostDetailResponseDto { private Long postId; private Long nickName; // 작성자 닉네임 private String title; diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java index 97a1eb2..8d6419c 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java @@ -1,6 +1,7 @@ package com.hyetaekon.hyetaekon.post.dto; import lombok.*; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; import java.util.List; @@ -19,5 +20,5 @@ public class PostUpdateRequestDto { private String urlTitle; private String urlPath; private String tags; - private List imageUrls; // ✅ 이미지 URL 리스트 + private List images; // 이미지 파일 } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java index 706f171..7325e94 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java @@ -3,6 +3,8 @@ import jakarta.persistence.*; import lombok.*; +import java.time.LocalDateTime; + @Entity @Getter @Setter @@ -21,4 +23,16 @@ public class PostImage { @Column(name = "image_url", length = 255) private String imageUrl; + + private LocalDateTime deletedAt; + + // 이미지가 삭제되었는지 확인하는 메소드 + public boolean isDeleted() { + return deletedAt != null; + } + + // Soft delete 처리 메소드 + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostImageMapper.java b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostImageMapper.java new file mode 100644 index 0000000..e2c366c --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostImageMapper.java @@ -0,0 +1,38 @@ +package com.hyetaekon.hyetaekon.post.mapper; + +import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.post.entity.PostImage; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface PostImageMapper { + + // 게시글 이미지 변환 + default PostImage toPostImage(String url, Post post) { + if (url == null || post == null) { + throw new IllegalArgumentException("url 또는 post가 null일 수 없습니다."); + } + + return PostImage.builder() + .imageUrl(url) + .post(post) + .build(); + } + + // URL 리스트로 PostImage 리스트 생성 + default List toEntityList(List uploadedUrls, Post post) { + if (uploadedUrls == null || uploadedUrls.isEmpty()) { + return Collections.emptyList(); + } + return uploadedUrls.stream() + .map(url -> toPostImage(url, post)) + .collect(Collectors.toList()); + } + +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java index 936000f..6a82fd1 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java @@ -3,15 +3,16 @@ import com.hyetaekon.hyetaekon.post.dto.*; import com.hyetaekon.hyetaekon.post.entity.Post; import com.hyetaekon.hyetaekon.post.entity.PostImage; -import com.hyetaekon.hyetaekon.post.entity.PostType; -import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; -import com.hyetaekon.hyetaekon.user.entity.User; import org.mapstruct.*; +import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; -@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +@Mapper(componentModel = "spring", + unmappedTargetPolicy = ReportingPolicy.IGNORE, + uses = {PostImageMapper.class}) public interface PostMapper { // Post -> PostListResponseDto 변환 @@ -24,32 +25,27 @@ public interface PostMapper { @Mapping(source = "id", target = "postId") @Mapping(source = "user.nickname", target = "nickName") @Mapping(source = "postType.koreanName", target = "postType") - @Mapping(target = "imageUrls", expression = "java(mapImageUrls(post.getPostImages()))") - PostDetailReponseDto toPostDetailDto(Post post); + @Mapping(target = "imageUrls", source = "postImages", qualifiedByName = "mapPostImagesToUrls") + PostDetailResponseDto toPostDetailDto(Post post); // PostCreateRequestDto -> Post 변환 (새 게시글 생성) @Mapping(target = "createdAt", expression = "java(java.time.LocalDateTime.now())") Post toEntity(PostCreateRequestDto createDto); - // 이미지 URL 목록 매핑을 위한 기본 메서드 - default List mapImageUrls(List postImages) { - if (postImages == null) { - return java.util.Collections.emptyList(); - } - return postImages.stream() - .map(com.hyetaekon.hyetaekon.post.entity.PostImage::getImageUrl) - .filter(java.util.Objects::nonNull) - .collect(java.util.stream.Collectors.toList()); - } - - // PostUpdateRequestDto로 Post 업데이트 + // null 아닌 값만 업데이트 @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) void updatePostFromDto(PostUpdateRequestDto updateDto, @MappingTarget Post post); - default List mapPostImages(Post post) { - if (post.getPostImages() == null) return null; - return post.getPostImages().stream() - .map(PostImage::getImageUrl) - .collect(Collectors.toList()); + // 이미지만 URL 리스트로 변환 (soft delete 처리된 것 제외) + @Named("mapPostImagesToUrls") + default List mapPostImagesToUrls(List postImages) { + if (postImages == null) { + return Collections.emptyList(); + } + return postImages.stream() + .filter(img -> img.getDeletedAt() == null) + .map(PostImage::getImageUrl) + .filter(Objects::nonNull) + .collect(Collectors.toList()); } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostImageRepository.java b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostImageRepository.java new file mode 100644 index 0000000..7994337 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostImageRepository.java @@ -0,0 +1,13 @@ +package com.hyetaekon.hyetaekon.post.repository; + +import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.post.entity.PostImage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PostImageRepository extends JpaRepository { + List findByPostAndDeletedAtIsNull(Post post); +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java index bc01780..143e44e 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java @@ -1,10 +1,14 @@ package com.hyetaekon.hyetaekon.post.service; +import com.hyetaekon.hyetaekon.common.exception.GlobalException; +import com.hyetaekon.hyetaekon.common.s3bucket.service.S3BucketService; import com.hyetaekon.hyetaekon.post.dto.*; import com.hyetaekon.hyetaekon.post.entity.Post; import com.hyetaekon.hyetaekon.post.entity.PostImage; import com.hyetaekon.hyetaekon.post.entity.PostType; +import com.hyetaekon.hyetaekon.post.mapper.PostImageMapper; import com.hyetaekon.hyetaekon.post.mapper.PostMapper; +import com.hyetaekon.hyetaekon.post.repository.PostImageRepository; import com.hyetaekon.hyetaekon.post.repository.PostRepository; import com.hyetaekon.hyetaekon.recommend.repository.RecommendRepository; import com.hyetaekon.hyetaekon.user.entity.User; @@ -18,10 +22,14 @@ import org.springframework.transaction.annotation.Transactional; import jakarta.persistence.EntityNotFoundException; +import org.springframework.web.multipart.MultipartFile; + import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; +import java.util.Set; + +import static com.hyetaekon.hyetaekon.common.exception.ErrorCode.*; @Slf4j @Service @@ -29,9 +37,21 @@ public class PostService { private final PostRepository postRepository; + private final PostImageRepository postImageRepository; private final UserRepository userRepository; private final RecommendRepository recommendRepository; private final PostMapper postMapper; + private final PostImageMapper postImageMapper; + private final S3BucketService s3BucketService; + + // 이미지 업로드 제한 설정 + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + private static final int MAX_FILES_COUNT = 5; // 최대 5개 이미지 + private static final Set ALLOWED_TYPES = Set.of( + "image/jpeg", + "image/png", + "image/gif" + ); /** * 전체 게시글 목록 조회 (페이징) @@ -53,7 +73,7 @@ public Page getPostsByType(PostType postType, Pageable page * 특정 게시글 상세 조회(로그인 시) */ @Transactional - public PostDetailReponseDto getPostById(Long postId, Long userId) { + public PostDetailResponseDto getPostById(Long postId, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다: " + userId)); @@ -67,17 +87,17 @@ public PostDetailReponseDto getPostById(Long postId, Long userId) { boolean recommended = recommendRepository.existsByUserIdAndPostId(userId, postId); // DTO 변환 및 추천 여부 설정 - PostDetailReponseDto responseDto = postMapper.toPostDetailDto(post); - responseDto.setRecommended(recommended); // DTO에 isRecommended 필드 추가 필요 + PostDetailResponseDto responseDto = postMapper.toPostDetailDto(post); + responseDto.setRecommended(recommended); - return postMapper.toPostDetailDto(post); + return responseDto; } /** * 게시글 생성(로그인 시) */ @Transactional - public PostDetailReponseDto createPost(PostCreateRequestDto requestDto, Long userId) { + public PostDetailResponseDto createPost(PostCreateRequestDto requestDto, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다: " + userId)); @@ -89,22 +109,17 @@ public PostDetailReponseDto createPost(PostCreateRequestDto requestDto, Long use post.setUser(user); post.setPostType(postType); - // 이미지 URL 처리 - if (requestDto.getImageUrls() != null && !requestDto.getImageUrls().isEmpty()) { - List postImages = new ArrayList<>(); + // 게시글 저장 + Post savedPost = postRepository.save(post); - for (String imageUrl : requestDto.getImageUrls()) { - PostImage postImage = PostImage.builder() - .post(post) - .imageUrl(imageUrl) - .build(); - postImages.add(postImage); + // 이미지 처리 + if (requestDto.getImages() != null && !requestDto.getImages().isEmpty()) { + List postImages = processPostImages(requestDto.getImages(), savedPost); + if (!postImages.isEmpty()) { + postImageRepository.saveAll(postImages); } - - post.setPostImages(postImages); } - Post savedPost = postRepository.save(post); return postMapper.toPostDetailDto(savedPost); } @@ -112,7 +127,7 @@ public PostDetailReponseDto createPost(PostCreateRequestDto requestDto, Long use * 게시글 수정 (본인만 가능) */ @Transactional - public PostDetailReponseDto updatePost(Long postId, PostUpdateRequestDto updateDto, Long userId) { + public PostDetailResponseDto updatePost(Long postId, PostUpdateRequestDto updateDto, Long userId) { Post post = postRepository.findByIdAndDeletedAtIsNull(postId) .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다: " + postId)); @@ -131,19 +146,18 @@ public PostDetailReponseDto updatePost(Long postId, PostUpdateRequestDto updateD postMapper.updatePostFromDto(updateDto, post); // 이미지 업데이트 처리 - if (updateDto.getImageUrls() != null) { - // 기존 이미지 제거 - post.getPostImages().clear(); + if (updateDto.getImages() != null && !updateDto.getImages().isEmpty()) { + // 기존 이미지 soft delete 처리 + List existingImages = postImageRepository.findByPostAndDeletedAtIsNull(post); + for (PostImage image : existingImages) { + image.softDelete(); + } // 새 이미지 추가 - List postImages = updateDto.getImageUrls().stream() - .map(imageUrl -> PostImage.builder() - .post(post) - .imageUrl(imageUrl) - .build()) - .collect(Collectors.toList()); - - post.getPostImages().addAll(postImages); + List newImages = processPostImages(updateDto.getImages(), post); + if (!newImages.isEmpty()) { + postImageRepository.saveAll(newImages); + } } return postMapper.toPostDetailDto(post); @@ -167,26 +181,75 @@ public void deletePost(Long postId, Long userId, String role) { // Soft Delete 처리 post.setDeletedAt(LocalDateTime.now()); + + // 모든 이미지 soft delete 처리 + List images = postImageRepository.findByPostAndDeletedAtIsNull(post); + for (PostImage image : images) { + image.softDelete(); + } + } + + /** + * 이미지 처리를 위한 private 메서드 + */ + private List processPostImages(List images, Post post) { + if (images == null || images.isEmpty()) { + return new ArrayList<>(); + } + + // 이미지 유효성 검증 + validateImages(images); + + // 이미지 업로드 및 엔티티 변환 + try { + List uploadedUrls = s3BucketService.upload(images, "posts/" + post.getId()); + return postImageMapper.toEntityList(uploadedUrls, post); + } catch (Exception e) { + log.error("이미지 업로드 실패: ", e); + throw new GlobalException(FILE_UPLOAD_FAILED); + } + } + + /** + * 이미지 유효성 검증 + */ + private void validateImages(List images) { + // 이미지 파일 개수 제한 + if (images.size() > MAX_FILES_COUNT) { + throw new GlobalException(FILE_COUNT_EXCEEDED); + } + + for (MultipartFile image : images) { + // 파일 크기 검증 + if (image.getSize() > MAX_FILE_SIZE) { + throw new GlobalException(FILE_SIZE_EXCEEDED); + } + + // 파일 타입 검증 + String contentType = image.getContentType(); + if (contentType == null || !ALLOWED_TYPES.contains(contentType)) { + throw new GlobalException(INVALID_FILE_TYPE); + } + } } /** * 게시글 타입명으로 Enum 조회 */ private PostType findPostTypeByName(String postTypeName) { - try { - // 한글명으로 찾기 - for (PostType type : PostType.values()) { - if (type.getKoreanName().equals(postTypeName)) { - return type; - } + // 한글명으로 찾기 + for (PostType type : PostType.values()) { + if (type.getKoreanName().equals(postTypeName)) { + return type; } + } - // Enum 상수명으로 찾기 + try { + // 한글명으로 찾지 못한 경우 Enum 상수명으로 시도 return PostType.valueOf(postTypeName); } catch (IllegalArgumentException e) { throw new IllegalArgumentException("유효하지 않은 게시글 타입입니다: " + postTypeName); } } - }