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/controller/PostController.java b/src/main/java/com/hyetaekon/hyetaekon/post/controller/PostController.java index bc69467..c842b0b 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.PostDto; +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; +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.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; +@Slf4j @RestController @RequestMapping("/api/posts") @RequiredArgsConstructor @@ -15,40 +22,54 @@ 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); } - // ✅ 특정 게시글 조회 - @GetMapping("/{postId}") - public ResponseEntity getPost(@PathVariable Long postId) { - return ResponseEntity.ok(postService.getPostById(postId)); + // 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("/category/{categoryId}") - public ResponseEntity> getPostsByCategory(@PathVariable Long categoryId) { - return ResponseEntity.ok(postService.getPostsByCategoryId(categoryId)); + // User, Admin에 따라 다른 접근 가능 + // ✅ 특정 게시글 상세 조회 + @GetMapping("/{postId}") + public ResponseEntity getPost( + @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(postService.getPostById(postId, userDetails.getId())); } - // ✅ 모든 게시글 조회 - @GetMapping - public ResponseEntity> getAllPosts() { - return ResponseEntity.ok(postService.getAllPosts()); + // ✅ 게시글 생성 + @PostMapping + 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..e542bd5 --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java @@ -0,0 +1,24 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.*; +import org.springframework.web.multipart.MultipartFile; + +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 images; // 이미지 파일 +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java similarity index 58% rename from src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java rename to src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java index 34b975b..a4ccc5a 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostDetailResponseDto.java @@ -1,27 +1,28 @@ package com.hyetaekon.hyetaekon.post.dto; -import lombok.Getter; -import lombok.Setter; + +import lombok.*; + import java.time.LocalDateTime; import java.util.List; @Getter @Setter -public class PostDto { - private Long id; - private Long userId; - private String publicServiceId; +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostDetailResponseDto { + private Long postId; + private Long nickName; // 작성자 닉네임 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 int viewCnt; private String urlTitle; private String urlPath; private String tags; - private Long categoryId; // 추가됨 private List imageUrls; // ✅ 이미지 URL 리스트 추가 + private boolean recommended; // 현재 로그인한 사용자의 추천 여부 } 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/dto/PostUpdateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java new file mode 100644 index 0000000..8d6419c --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java @@ -0,0 +1,24 @@ +package com.hyetaekon.hyetaekon.post.dto; + +import lombok.*; +import org.springframework.web.multipart.MultipartFile; + +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 images; // 이미지 파일 +} 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..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,16 +1,22 @@ 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.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,27 +32,36 @@ public class Post { @JoinColumn(name = "public_service_id") private PublicService publicService; - @Column(length = 20, nullable = false) // ✅ 제목 20자 제한 + @Column(columnDefinition = "VARCHAR(20) CHARACTER SET utf8mb4", nullable = false) // ✅ 제목 20자 제한 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(); private LocalDateTime deletedAt; - private int recommendCnt; // 추천수 + @Builder.Default + @Column(name = "recommend_cnt") + private int recommendCnt = 0; // 추천수 + + @Builder.Default + @Column(name = "view_cnt") + private int viewCnt = 0; // 조회수 - private int viewCount; // 조회수 + // TODO: 댓글 생성/수정 시 업데이트 + @Builder.Default + @Column(name = "comment_cnt") + private int commentCnt = 0; // 댓글수 - @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(columnDefinition = "VARCHAR(12) CHARACTER SET utf8mb4") // ✅ url제목 12자 제한 private String urlTitle; private String urlPath; @@ -57,6 +72,34 @@ public class Post { @Column(name = "category_id") private Long categoryId; + @Builder.Default + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List postImages = new ArrayList<>(); // ✅ 게시글 이미지와 연결 + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) - private List postImages; // ✅ 게시글 이미지와 연결 + @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++; + } + + 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..7325e94 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,16 @@ package com.hyetaekon.hyetaekon.post.entity; import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; +import lombok.*; + +import java.time.LocalDateTime; @Entity @Getter @Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor public class PostImage { @Id @@ -19,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/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/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 908c493..6a82fd1 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java @@ -1,62 +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.Collections; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", + unmappedTargetPolicy = ReportingPolicy.IGNORE, + uses = {PostImageMapper.class}) 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", source = "postImages", qualifiedByName = "mapPostImagesToUrls") + PostDetailResponseDto toPostDetailDto(Post post); + + // PostCreateRequestDto -> Post 변환 (새 게시글 생성) + @Mapping(target = "createdAt", expression = "java(java.time.LocalDateTime.now())") + Post toEntity(PostCreateRequestDto createDto); + + // null 아닌 값만 업데이트 + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + void updatePostFromDto(PostUpdateRequestDto updateDto, @MappingTarget Post post); + + // 이미지만 URL 리스트로 변환 (soft delete 처리된 것 제외) + @Named("mapPostImagesToUrls") + default List mapPostImagesToUrls(List postImages) { + if (postImages == null) { + return Collections.emptyList(); } - - return post; - } - - default List mapPostImages(Post post) { - if (post.getPostImages() == null) return null; - return post.getPostImages().stream() - .map(PostImage::getImageUrl) - .collect(Collectors.toList()); + 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/repository/PostRepository.java b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java index 2734c4e..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 614f49c..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,81 +1,255 @@ package com.hyetaekon.hyetaekon.post.service; -import com.hyetaekon.hyetaekon.post.dto.PostDto; +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.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 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 @RequiredArgsConstructor 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" + ); - 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 PostDetailResponseDto 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 변환 및 추천 여부 설정 + PostDetailResponseDto responseDto = postMapper.toPostDetailDto(post); + responseDto.setRecommended(recommended); + + return responseDto; } - public PostDto createPost(PostDto postDto) { - Post post = postMapper.toEntity(postDto); + /** + * 게시글 생성(로그인 시) + */ + @Transactional + public PostDetailResponseDto 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); + + // 게시글 저장 Post savedPost = postRepository.save(post); - return postMapper.toDto(savedPost); + + // 이미지 처리 + if (requestDto.getImages() != null && !requestDto.getImages().isEmpty()) { + List postImages = processPostImages(requestDto.getImages(), savedPost); + if (!postImages.isEmpty()) { + postImageRepository.saveAll(postImages); + } + } + + return postMapper.toPostDetailDto(savedPost); } - public PostDto updatePost(Long id, PostDto postDto) { - Post post = postRepository.findById(id).orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다.")); + /** + * 게시글 수정 (본인만 가능) + */ + @Transactional + public PostDetailResponseDto 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.setViewCount(postDto.getViewCount()); - 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.getImages() != null && !updateDto.getImages().isEmpty()) { + // 기존 이미지 soft delete 처리 + List existingImages = postImageRepository.findByPostAndDeletedAtIsNull(post); + for (PostImage image : existingImages) { + image.softDelete(); + } + + // 새 이미지 추가 + List newImages = processPostImages(updateDto.getImages(), post); + if (!newImages.isEmpty()) { + postImageRepository.saveAll(newImages); + } + } + + return postMapper.toPostDetailDto(post); } - public void deletePost(Long id) { - postRepository.deleteById(id); + /** + * 게시글 삭제 (본인 또는 관리자만 가능) + */ + @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()); + + // 모든 이미지 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) { + // 한글명으로 찾기 + for (PostType type : PostType.values()) { + if (type.getKoreanName().equals(postTypeName)) { + return type; + } + } + + try { + // 한글명으로 찾지 못한 경우 Enum 상수명으로 시도 + return PostType.valueOf(postTypeName); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 게시글 타입입니다: " + postTypeName); + } + } + } 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") 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..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,12 +1,18 @@ package com.hyetaekon.hyetaekon.recommend.service; +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.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.*; + @Service @Transactional @RequiredArgsConstructor @@ -15,4 +21,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)); -// } }