From 12dfa323ba403956e1dc93decaf192c2cbc12b2f Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Wed, 3 Dec 2025 14:59:10 +0900 Subject: [PATCH 1/6] =?UTF-8?q?:wrench:Settings:=20Bucket=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources b/src/main/resources index f6f4348..5471022 160000 --- a/src/main/resources +++ b/src/main/resources @@ -1 +1 @@ -Subproject commit f6f4348ff252d15641fab542ecfc0f2ea3a99e75 +Subproject commit 547102251cecc07b04c80516470cd71d4f4a2145 From 203a2b585604f295e369c24bcf82a48541cb93f1 Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Wed, 3 Dec 2025 14:59:52 +0900 Subject: [PATCH 2/6] =?UTF-8?q?:recycle:Refactor:=20S3=20=EB=B2=84?= =?UTF-8?q?=ED=82=B7=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sku/refit/global/config/S3Config.java | 7 ++-- .../page/exception/PageErrorStatus.java | 23 +++++++++++++ .../global/page/mapper/InfiniteMapper.java | 24 +++++++++++++ .../refit/global/page/mapper/PageMapper.java | 24 +++++++++++++ .../page/response/InfiniteResponse.java | 28 +++++++++++++++ .../global/page/response/PageResponse.java | 34 +++++++++++++++++++ .../sku/refit/global/s3/entity/PathName.java | 6 ++-- .../global/s3/service/S3ServiceImpl.java | 3 +- 8 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/sku/refit/global/page/exception/PageErrorStatus.java create mode 100644 src/main/java/com/sku/refit/global/page/mapper/InfiniteMapper.java create mode 100644 src/main/java/com/sku/refit/global/page/mapper/PageMapper.java create mode 100644 src/main/java/com/sku/refit/global/page/response/InfiniteResponse.java create mode 100644 src/main/java/com/sku/refit/global/page/response/PageResponse.java diff --git a/src/main/java/com/sku/refit/global/config/S3Config.java b/src/main/java/com/sku/refit/global/config/S3Config.java index dcb63cc..dd829a9 100644 --- a/src/main/java/com/sku/refit/global/config/S3Config.java +++ b/src/main/java/com/sku/refit/global/config/S3Config.java @@ -39,8 +39,11 @@ public class S3Config { @Value("${cloud.aws.s3.path.profile-image}") private String profileImagePath; - @Value("${cloud.aws.s3.path.folder2}") - private String folder2Path; + @Value("${cloud.aws.s3.path.post}") + private String postPath; + + @Value("${cloud.aws.s3.path.cloth}") + private String clothPath; @PostConstruct public void init() { diff --git a/src/main/java/com/sku/refit/global/page/exception/PageErrorStatus.java b/src/main/java/com/sku/refit/global/page/exception/PageErrorStatus.java new file mode 100644 index 0000000..48b414b --- /dev/null +++ b/src/main/java/com/sku/refit/global/page/exception/PageErrorStatus.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.global.page.exception; + +import org.springframework.http.HttpStatus; + +import com.sku.refit.global.exception.model.BaseErrorCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PageErrorStatus implements BaseErrorCode { + PAGE_NOT_FOUND("PAGE001", "페이지가 존재하지 않습니다.", HttpStatus.NOT_FOUND), + PAGE_SIZE_ERROR("PAGE002", "페이지 크기 오류가 발생했습니다.", HttpStatus.BAD_REQUEST), + PAGING_ERROR("PAGE003", "페이징 처리 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/com/sku/refit/global/page/mapper/InfiniteMapper.java b/src/main/java/com/sku/refit/global/page/mapper/InfiniteMapper.java new file mode 100644 index 0000000..961eca4 --- /dev/null +++ b/src/main/java/com/sku/refit/global/page/mapper/InfiniteMapper.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.global.page.mapper; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.sku.refit.global.page.response.InfiniteResponse; + +@Component +public class InfiniteMapper { + + public InfiniteResponse toInfiniteResponse( + List content, Long lastCursor, boolean hasNext, int size) { + return InfiniteResponse.builder() + .content(content) + .lastCursor(lastCursor) + .hasNext(hasNext) + .size(size) + .build(); + } +} diff --git a/src/main/java/com/sku/refit/global/page/mapper/PageMapper.java b/src/main/java/com/sku/refit/global/page/mapper/PageMapper.java new file mode 100644 index 0000000..ca02ad6 --- /dev/null +++ b/src/main/java/com/sku/refit/global/page/mapper/PageMapper.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.global.page.mapper; + +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +import com.sku.refit.global.page.response.PageResponse; + +@Component +public class PageMapper { + + public PageResponse toPageResponse(Page page) { + return PageResponse.builder() + .content(page.getContent()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .pageNum(page.getNumber() + 1) + .pageSize(page.getSize()) + .last(page.isLast()) + .build(); + } +} diff --git a/src/main/java/com/sku/refit/global/page/response/InfiniteResponse.java b/src/main/java/com/sku/refit/global/page/response/InfiniteResponse.java new file mode 100644 index 0000000..9bd35ad --- /dev/null +++ b/src/main/java/com/sku/refit/global/page/response/InfiniteResponse.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.global.page.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(title = "InfiniteResponse DTO", description = "응답 객체들에 대한 리스트를 무한 스크롤로 응답 반환") +public class InfiniteResponse { + + @Schema(description = "데이터 리스트") + private List content; + + @Schema(description = "마지막 데이터의 커서 값") + private Long lastCursor; + + @Schema(description = "더 가져올 데이터가 있는지 여부") + private Boolean hasNext; + + @Schema(description = "한 번에 가져온 데이터 개수", example = "20") + private Integer size; +} diff --git a/src/main/java/com/sku/refit/global/page/response/PageResponse.java b/src/main/java/com/sku/refit/global/page/response/PageResponse.java new file mode 100644 index 0000000..af82baa --- /dev/null +++ b/src/main/java/com/sku/refit/global/page/response/PageResponse.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.global.page.response; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(title = "PageResponse DTO", description = "응답 객체들에 대한 리스트를 페이지로 응답 반환") +public class PageResponse { + + @Schema(description = "데이터 리스트") + private List content; + + @Schema(description = "전체 데이터의 개수", example = "200") + private Long totalElements; + + @Schema(description = "전체 페이지 개수", example = "50") + private Integer totalPages; + + @Schema(description = "페이지 번호", example = "1") + private Integer pageNum; + + @Schema(description = "페이지 크기", example = "4") + private Integer pageSize; + + @Schema(description = "마지막 데이터 여부", example = "false") + private Boolean last; +} diff --git a/src/main/java/com/sku/refit/global/s3/entity/PathName.java b/src/main/java/com/sku/refit/global/s3/entity/PathName.java index 6721787..6f75c12 100644 --- a/src/main/java/com/sku/refit/global/s3/entity/PathName.java +++ b/src/main/java/com/sku/refit/global/s3/entity/PathName.java @@ -8,6 +8,8 @@ public enum PathName { @Schema(description = "프로필사진") PROFILE_IMAGE, - @Schema(description = "폴더") - FOLDER, + @Schema(description = "게시글") + POST, + @Schema(description = "옷") + CLOTH, } diff --git a/src/main/java/com/sku/refit/global/s3/service/S3ServiceImpl.java b/src/main/java/com/sku/refit/global/s3/service/S3ServiceImpl.java index 693f36c..d9b8864 100644 --- a/src/main/java/com/sku/refit/global/s3/service/S3ServiceImpl.java +++ b/src/main/java/com/sku/refit/global/s3/service/S3ServiceImpl.java @@ -192,7 +192,8 @@ private void validateFile(MultipartFile file) { private String getPrefix(PathName pathName) { return switch (pathName) { case PROFILE_IMAGE -> s3Config.getProfileImagePath(); - case FOLDER -> s3Config.getFolder2Path(); + case POST -> s3Config.getPostPath(); + case CLOTH -> s3Config.getClothPath(); }; } } From c2d39393a17a7338db5400ec4f6ceb27a91c8b9a Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Wed, 3 Dec 2025 15:01:19 +0900 Subject: [PATCH 3/6] =?UTF-8?q?:sparkles:Feat:=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20API=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/AuthServiceImpl.java | 20 ++ .../comment/controller/CommentController.java | 53 ++++++ .../controller/CommentControllerImpl.java | 60 ++++++ .../comment/dto/request/CommentRequest.java | 24 +++ .../dto/response/CommentDetailResponse.java | 28 +++ .../refit/domain/comment/entity/Comment.java | 52 ++++++ .../comment/exception/CommentErrorCode.java | 22 +++ .../domain/comment/mapper/CommentMapper.java | 30 +++ .../comment/repository/CommentRepository.java | 16 ++ .../comment/service/CommentService.java | 45 +++++ .../comment/service/CommentServiceImpl.java | 97 ++++++++++ .../post/controller/PostController.java | 90 +++++++++ .../post/controller/PostControllerImpl.java | 75 ++++++++ .../domain/post/dto/request/PostRequest.java | 35 ++++ .../post/dto/response/PostDetailResponse.java | 38 ++++ .../sku/refit/domain/post/entity/Post.java | 78 ++++++++ .../domain/post/exception/PostErrorCode.java | 27 +++ .../refit/domain/post/mapper/PostMapper.java | 42 +++++ .../post/repository/PostRepository.java | 20 ++ .../domain/post/service/PostService.java | 79 ++++++++ .../domain/post/service/PostServiceImpl.java | 174 ++++++++++++++++++ 21 files changed, 1105 insertions(+) create mode 100644 src/main/java/com/sku/refit/domain/comment/controller/CommentController.java create mode 100644 src/main/java/com/sku/refit/domain/comment/controller/CommentControllerImpl.java create mode 100644 src/main/java/com/sku/refit/domain/comment/dto/request/CommentRequest.java create mode 100644 src/main/java/com/sku/refit/domain/comment/dto/response/CommentDetailResponse.java create mode 100644 src/main/java/com/sku/refit/domain/comment/entity/Comment.java create mode 100644 src/main/java/com/sku/refit/domain/comment/exception/CommentErrorCode.java create mode 100644 src/main/java/com/sku/refit/domain/comment/mapper/CommentMapper.java create mode 100644 src/main/java/com/sku/refit/domain/comment/repository/CommentRepository.java create mode 100644 src/main/java/com/sku/refit/domain/comment/service/CommentService.java create mode 100644 src/main/java/com/sku/refit/domain/comment/service/CommentServiceImpl.java create mode 100644 src/main/java/com/sku/refit/domain/post/controller/PostController.java create mode 100644 src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java create mode 100644 src/main/java/com/sku/refit/domain/post/dto/request/PostRequest.java create mode 100644 src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java create mode 100644 src/main/java/com/sku/refit/domain/post/entity/Post.java create mode 100644 src/main/java/com/sku/refit/domain/post/exception/PostErrorCode.java create mode 100644 src/main/java/com/sku/refit/domain/post/mapper/PostMapper.java create mode 100644 src/main/java/com/sku/refit/domain/post/repository/PostRepository.java create mode 100644 src/main/java/com/sku/refit/domain/post/service/PostService.java create mode 100644 src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java diff --git a/src/main/java/com/sku/refit/domain/auth/service/AuthServiceImpl.java b/src/main/java/com/sku/refit/domain/auth/service/AuthServiceImpl.java index cd7800d..c456ffe 100644 --- a/src/main/java/com/sku/refit/domain/auth/service/AuthServiceImpl.java +++ b/src/main/java/com/sku/refit/domain/auth/service/AuthServiceImpl.java @@ -8,12 +8,14 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.sku.refit.domain.auth.dto.request.LoginRequest; import com.sku.refit.domain.auth.dto.response.TokenResponse; import com.sku.refit.domain.auth.exception.AuthErrorCode; +import com.sku.refit.domain.user.entity.Role; import com.sku.refit.domain.user.entity.User; import com.sku.refit.domain.user.exception.UserErrorCode; import com.sku.refit.domain.user.repository.UserRepository; @@ -37,6 +39,7 @@ public class AuthServiceImpl implements AuthService { private final AuthenticationManager authenticationManager; private final JwtProvider jwtProvider; private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; @Override @Transactional @@ -97,6 +100,23 @@ public String reissueAccessToken(String refreshToken) { @Transactional public TokenResponse testLogin() { + User user = userRepository.findByUsername(testUsername).orElse(null); + + if (user == null) { + user = + User.builder() + .profileImageUrl(testUsername + ".png") + .nickname("김다입") + .username(testUsername) + .password(passwordEncoder.encode(testPassword)) + .locationConsent(true) + .role(Role.ROLE_USER) + .build(); + + userRepository.save(user); + log.info("테스트 사용자 생성됨: {}", user.getUsername()); + } + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(testUsername, testPassword); diff --git a/src/main/java/com/sku/refit/domain/comment/controller/CommentController.java b/src/main/java/com/sku/refit/domain/comment/controller/CommentController.java new file mode 100644 index 0000000..e84360e --- /dev/null +++ b/src/main/java/com/sku/refit/domain/comment/controller/CommentController.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.comment.controller; + +import java.util.List; + +import jakarta.validation.Valid; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.sku.refit.domain.comment.dto.request.CommentRequest; +import com.sku.refit.domain.comment.dto.response.CommentDetailResponse; +import com.sku.refit.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "커뮤니티 댓글", description = "커뮤니티 댓글 관련 API") +@RequestMapping("/api/comments") +public interface CommentController { + + @PostMapping("/new") + @Operation(summary = "새 댓글 작성", description = "특정 게시글의 댓글을 작성합니다.") + ResponseEntity> createComment( + @RequestBody @Valid CommentRequest request, + @Parameter(description = "댓글을 작성할 게시글 식별자", example = "1") @RequestParam Long postId); + + @GetMapping + @Operation(summary = "특정 게시글의 댓글 조회", description = "특정 게시글의 댓글 리스트를 조회합니다.") + ResponseEntity>> getAllCommentsByPostId( + @Parameter(description = "댓글을 작성할 게시글 식별자", example = "1") @RequestParam Long postId); + + @PutMapping("{id}") + @Operation(summary = "특정 댓글 수정", description = "특정 댓글의 내용을 수정합니다.") + ResponseEntity> updateComment( + @Parameter(description = "댓글 식별자", example = "1") @PathVariable Long id, + @RequestBody @Valid CommentRequest request); + + @DeleteMapping("{id}") + @Operation(summary = "특정 댓글 삭제", description = "특정 댓글을 삭제합니다.(Hard Delete)") + ResponseEntity> deleteComment( + @Parameter(description = "댓글 식별자", example = "1") @PathVariable Long id); +} diff --git a/src/main/java/com/sku/refit/domain/comment/controller/CommentControllerImpl.java b/src/main/java/com/sku/refit/domain/comment/controller/CommentControllerImpl.java new file mode 100644 index 0000000..b1b3187 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/comment/controller/CommentControllerImpl.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.comment.controller; + +import java.util.List; + +import jakarta.validation.Valid; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.sku.refit.domain.comment.dto.request.CommentRequest; +import com.sku.refit.domain.comment.dto.response.CommentDetailResponse; +import com.sku.refit.domain.comment.service.CommentService; +import com.sku.refit.global.response.BaseResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@Slf4j +public class CommentControllerImpl implements CommentController { + + private final CommentService commentService; + + @Override + public ResponseEntity> createComment( + @Valid CommentRequest request, @RequestParam Long postId) { + + CommentDetailResponse response = commentService.createComment(request, postId); + return ResponseEntity.ok(BaseResponse.success(response)); + } + + @Override + public ResponseEntity>> getAllCommentsByPostId( + @RequestParam Long postId) { + + List response = commentService.getAllCommentsByPostId(postId); + return ResponseEntity.ok(BaseResponse.success(response)); + } + + @Override + public ResponseEntity> updateComment( + @PathVariable Long id, @Valid CommentRequest request) { + + CommentDetailResponse response = commentService.updateComment(id, request); + return ResponseEntity.ok(BaseResponse.success(response)); + } + + @Override + public ResponseEntity> deleteComment(@PathVariable Long id) { + + commentService.deleteComment(id); + return ResponseEntity.ok(BaseResponse.success(null)); + } +} diff --git a/src/main/java/com/sku/refit/domain/comment/dto/request/CommentRequest.java b/src/main/java/com/sku/refit/domain/comment/dto/request/CommentRequest.java new file mode 100644 index 0000000..0fcfe71 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/comment/dto/request/CommentRequest.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.comment.dto.request; + +import jakarta.validation.constraints.NotBlank; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(title = "CommentRequest DTO", description = "새 댓글 등록을 위한 데이터 전송") +public class CommentRequest { + + @NotBlank(message = "댓글 내용은 필수입니다.") + @Schema(description = "댓글 내용", example = "답변 감사합니다~~~") + private String content; +} diff --git a/src/main/java/com/sku/refit/domain/comment/dto/response/CommentDetailResponse.java b/src/main/java/com/sku/refit/domain/comment/dto/response/CommentDetailResponse.java new file mode 100644 index 0000000..8565d09 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/comment/dto/response/CommentDetailResponse.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.comment.dto.response; + +import java.time.LocalDateTime; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(title = "CommentDetailResponse DTO", description = "댓글 상세 정보 응답 반환") +public class CommentDetailResponse { + + @Schema(description = "댓글 식별자", example = "1") + private Long commentId; + + @Schema(description = "게시글 내용", example = "처음 가보는거라 질문 드립니다 ㅠㅠ") + private String content; + + @Schema(description = "게시글 작성자 여부", example = "true") + private Boolean isWriter; + + @Schema(description = "게시글 작성 시간", example = "20250101T120000") + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/sku/refit/domain/comment/entity/Comment.java b/src/main/java/com/sku/refit/domain/comment/entity/Comment.java new file mode 100644 index 0000000..249c58f --- /dev/null +++ b/src/main/java/com/sku/refit/domain/comment/entity/Comment.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.comment.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import com.sku.refit.domain.post.entity.Post; +import com.sku.refit.domain.user.entity.User; +import com.sku.refit.global.common.BaseTimeEntity; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "comment") +public class Comment extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + public void update(String content) { + this.content = content; + } +} diff --git a/src/main/java/com/sku/refit/domain/comment/exception/CommentErrorCode.java b/src/main/java/com/sku/refit/domain/comment/exception/CommentErrorCode.java new file mode 100644 index 0000000..cfe4e05 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/comment/exception/CommentErrorCode.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.comment.exception; + +import org.springframework.http.HttpStatus; + +import com.sku.refit.global.exception.model.BaseErrorCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum CommentErrorCode implements BaseErrorCode { + COMMENT_NOT_FOUND("COMMENT001", "댓글이 존재하지 않습니다.", HttpStatus.NOT_FOUND), + ; + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/com/sku/refit/domain/comment/mapper/CommentMapper.java b/src/main/java/com/sku/refit/domain/comment/mapper/CommentMapper.java new file mode 100644 index 0000000..978d64e --- /dev/null +++ b/src/main/java/com/sku/refit/domain/comment/mapper/CommentMapper.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.comment.mapper; + +import org.springframework.stereotype.Component; + +import com.sku.refit.domain.comment.dto.request.CommentRequest; +import com.sku.refit.domain.comment.dto.response.CommentDetailResponse; +import com.sku.refit.domain.comment.entity.Comment; +import com.sku.refit.domain.post.entity.Post; +import com.sku.refit.domain.user.entity.User; + +@Component +public class CommentMapper { + + public Comment toComment(CommentRequest request, User user, Post post) { + return Comment.builder().content(request.getContent()).user(user).post(post).build(); + } + + public CommentDetailResponse toDetailResponse(Comment comment, User user) { + + return CommentDetailResponse.builder() + .commentId(comment.getId()) + .content(comment.getContent()) + .isWriter(comment.getUser().getId().equals(user.getId())) + .createdAt(comment.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/sku/refit/domain/comment/repository/CommentRepository.java b/src/main/java/com/sku/refit/domain/comment/repository/CommentRepository.java new file mode 100644 index 0000000..4c511ea --- /dev/null +++ b/src/main/java/com/sku/refit/domain/comment/repository/CommentRepository.java @@ -0,0 +1,16 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.comment.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.sku.refit.domain.comment.entity.Comment; + +@Repository +public interface CommentRepository extends JpaRepository { + List findAllByPostIdOrderByCreatedAtAsc(Long postId); +} diff --git a/src/main/java/com/sku/refit/domain/comment/service/CommentService.java b/src/main/java/com/sku/refit/domain/comment/service/CommentService.java new file mode 100644 index 0000000..afb1728 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/comment/service/CommentService.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.comment.service; + +import java.util.List; + +import com.sku.refit.domain.comment.dto.request.CommentRequest; +import com.sku.refit.domain.comment.dto.response.CommentDetailResponse; + +public interface CommentService { + + /** + * 새로운 댓글 생성 + * + * @param request 댓글 생성 요청 데이터 + * @param postId 댓글이 달릴 게시글 ID + * @return 생성된 댓글 상세 정보 + */ + CommentDetailResponse createComment(CommentRequest request, Long postId); + + /** + * 특정 게시글의 댓글 전체 조회 + * + * @param postId 게시글 ID + * @return 댓글 상세 정보 리스트 + */ + List getAllCommentsByPostId(Long postId); + + /** + * 댓글 수정 + * + * @param id 댓글 ID + * @param request 수정 요청 데이터 + * @return 수정된 댓글 정보 + */ + CommentDetailResponse updateComment(Long id, CommentRequest request); + + /** + * 댓글 삭제 (Hard Delete) + * + * @param id 댓글 ID + */ + void deleteComment(Long id); +} diff --git a/src/main/java/com/sku/refit/domain/comment/service/CommentServiceImpl.java b/src/main/java/com/sku/refit/domain/comment/service/CommentServiceImpl.java new file mode 100644 index 0000000..0f407aa --- /dev/null +++ b/src/main/java/com/sku/refit/domain/comment/service/CommentServiceImpl.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.comment.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sku.refit.domain.comment.dto.request.CommentRequest; +import com.sku.refit.domain.comment.dto.response.CommentDetailResponse; +import com.sku.refit.domain.comment.entity.Comment; +import com.sku.refit.domain.comment.exception.CommentErrorCode; +import com.sku.refit.domain.comment.mapper.CommentMapper; +import com.sku.refit.domain.comment.repository.CommentRepository; +import com.sku.refit.domain.post.entity.Post; +import com.sku.refit.domain.post.exception.PostErrorCode; +import com.sku.refit.domain.post.repository.PostRepository; +import com.sku.refit.domain.user.entity.User; +import com.sku.refit.domain.user.service.UserService; +import com.sku.refit.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class CommentServiceImpl implements CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final CommentMapper commentMapper; + private final UserService userService; + + @Override + @Transactional + public CommentDetailResponse createComment(CommentRequest request, Long postId) { + + User user = userService.getCurrentUser(); + + Post post = + postRepository + .findById(postId) + .orElseThrow(() -> new CustomException(PostErrorCode.POST_NOT_FOUND)); + + Comment comment = commentMapper.toComment(request, user, post); + + commentRepository.save(comment); + return commentMapper.toDetailResponse(comment, user); + } + + @Override + public List getAllCommentsByPostId(Long postId) { + + User user = userService.getCurrentUser(); + if (!postRepository.existsById(postId)) { + throw new CustomException(PostErrorCode.POST_NOT_FOUND); + } + + List comments = commentRepository.findAllByPostIdOrderByCreatedAtAsc(postId); + + return comments.stream().map(comment -> commentMapper.toDetailResponse(comment, user)).toList(); + } + + @Override + @Transactional + public CommentDetailResponse updateComment(Long id, CommentRequest request) { + + User user = userService.getCurrentUser(); + Comment comment = + commentRepository + .findById(id) + .orElseThrow(() -> new CustomException(CommentErrorCode.COMMENT_NOT_FOUND)); + + comment.update(request.getContent()); + + return commentMapper.toDetailResponse(comment, user); + } + + @Override + @Transactional + public void deleteComment(Long id) { + + Comment comment = + commentRepository + .findById(id) + .orElseThrow(() -> new CustomException(CommentErrorCode.COMMENT_NOT_FOUND)); + + Post post = comment.getPost(); + post.getCommentList().remove(comment); + + commentRepository.delete(comment); + } +} diff --git a/src/main/java/com/sku/refit/domain/post/controller/PostController.java b/src/main/java/com/sku/refit/domain/post/controller/PostController.java new file mode 100644 index 0000000..3622f5a --- /dev/null +++ b/src/main/java/com/sku/refit/domain/post/controller/PostController.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.post.controller; + +import java.util.List; + +import jakarta.validation.Valid; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import com.sku.refit.domain.post.dto.request.PostRequest; +import com.sku.refit.domain.post.dto.response.PostDetailResponse; +import com.sku.refit.global.page.response.InfiniteResponse; +import com.sku.refit.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "커뮤니티 게시글", description = "커뮤니티 게시글 관련 API") +@RequestMapping("/api/posts") +public interface PostController { + + @PostMapping(value = "/new", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "새 게시글 작성", description = "새 게시글을 작성합니다.") + ResponseEntity> createPost( + @Parameter( + description = "게시글 내용", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @RequestPart(value = "request") + @Valid + PostRequest request, + @Parameter( + description = "게시글 이미지 리스트", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) + @RequestPart(value = "images", required = false) + List images); + + @GetMapping("/admin") + @Operation(summary = "[관리자] 게시글 전체 조회", description = "전체 게시글 리스트를 조회합니다.") + ResponseEntity>> getAllPosts(); + + @GetMapping + @Operation(summary = "카테코리별 게시글 전체 조회", description = "특정 카테고리의 게시글 리스트를 조회합니다.") + ResponseEntity>> getPostByCategory( + @RequestParam String category, + @Parameter(description = "마지막으로 조회한 게시글 식별자(첫 조회 시 생략)", example = "3") + @RequestParam(required = false) + Long lastBookId, + @Parameter(description = "한 번에 조회할 게시글 개수", example = "3") @RequestParam(defaultValue = "3") + Integer size); + + @GetMapping("/{id}") + @Operation(summary = "게시글 단일 조회", description = "특정 게시글의 상세 내용을 조회합니다.") + ResponseEntity> getPostById( + @Parameter(description = "게시글 식별자", example = "1") @PathVariable Long id); + + @PutMapping(value = "/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "게시글 수정", description = "특정 게시글의 상세 내용을 수정합니다.") + ResponseEntity> updatePostById( + @Parameter(description = "게시글 식별자", example = "1") @PathVariable Long id, + @Parameter( + description = "수정할 이미지 리스트", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) + @RequestPart(value = "image", required = false) + List image, + @Parameter( + description = "게시글 수정 내용", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @Valid + @RequestPart(value = "request") + PostRequest request); + + @DeleteMapping("/{id}") + @Operation(summary = "게시글 삭제", description = "특정 게시글을 삭제합니다.") + ResponseEntity> deleteExhibitionLike( + @Parameter(description = "게시글 식별자", example = "1") @PathVariable Long id); +} diff --git a/src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java b/src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java new file mode 100644 index 0000000..06642d9 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.post.controller; + +import java.util.List; + +import jakarta.validation.Valid; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.sku.refit.domain.post.dto.request.PostRequest; +import com.sku.refit.domain.post.dto.response.PostDetailResponse; +import com.sku.refit.domain.post.service.PostService; +import com.sku.refit.global.page.response.InfiniteResponse; +import com.sku.refit.global.response.BaseResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@Slf4j +public class PostControllerImpl implements PostController { + + private final PostService postService; + + @Override + public ResponseEntity> createPost( + @Valid PostRequest request, List images) { + + PostDetailResponse response = postService.createPost(request, images); + return ResponseEntity.ok(BaseResponse.success(response)); + } + + @Override + public ResponseEntity>> getAllPosts() { + + List response = postService.getAllPosts(); + return ResponseEntity.ok(BaseResponse.success(response)); + } + + @Override + public ResponseEntity>> getPostByCategory( + String category, Long lastBookId, Integer size) { + + InfiniteResponse response = + postService.getPostsByCategory(category, lastBookId, size); + return ResponseEntity.ok(BaseResponse.success(response)); + } + + @Override + public ResponseEntity> getPostById(Long id) { + + PostDetailResponse response = postService.getPostById(id); + return ResponseEntity.ok(BaseResponse.success(response)); + } + + @Override + public ResponseEntity> updatePostById( + Long id, List image, @Valid PostRequest request) { + + PostDetailResponse response = postService.updatePost(id, request, image); + return ResponseEntity.ok(BaseResponse.success(response)); + } + + @Override + public ResponseEntity> deleteExhibitionLike(Long id) { + + postService.deletePost(id); + return ResponseEntity.ok(BaseResponse.success(null)); + } +} diff --git a/src/main/java/com/sku/refit/domain/post/dto/request/PostRequest.java b/src/main/java/com/sku/refit/domain/post/dto/request/PostRequest.java new file mode 100644 index 0000000..157b82a --- /dev/null +++ b/src/main/java/com/sku/refit/domain/post/dto/request/PostRequest.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.post.dto.request; + +import java.util.List; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(title = "PostRequest DTO", description = "새 게시물 등록을 위한 데이터 전송") +public class PostRequest { + + @NotEmpty(message = "게시글 카테고리는 필수입니다.") + @Schema(description = "게시글 카테고리") + private List categoryList; + + @NotBlank(message = "게시글 제목은 필수입니다.") + @Schema(description = "게시글 제목", example = "21파티에선 정확히 어떤걸 하나요?") + private String title; + + @NotBlank(message = "게시글 내용은 필수입니다.") + @Schema(description = "게시글 내용", example = "처음 가보는거라 질문 드립니다 ㅠㅠ") + private String content; +} diff --git a/src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java b/src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java new file mode 100644 index 0000000..ff8a2ac --- /dev/null +++ b/src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.post.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(title = "PostDetailResponse DTO", description = "게시글 상세 정보 응답 반환") +public class PostDetailResponse { + + @Schema(description = "카테고리 리스트", example = "자유, 수선") + private List categoryList; + + @Schema(description = "게시글 식별자", example = "1") + private Long postId; + + @Schema(description = "게시글 제목", example = "21파티에선 정확히 어떤걸 하나요?") + private String title; + + @Schema(description = "게시글 내용", example = "처음 가보는거라 질문 드립니다 ㅠㅠ") + private String content; + + @Schema(description = "게시글 작성 시간", example = "2025-12-03T14:37:17") + private LocalDateTime createdAt; + + @Schema(description = "게시글 작성자", example = "김다입") + private String nickname; + + @Schema(description = "댓글 식별자 리스트", example = "[1, 2, 3]") + private List commentIdList; +} diff --git a/src/main/java/com/sku/refit/domain/post/entity/Post.java b/src/main/java/com/sku/refit/domain/post/entity/Post.java new file mode 100644 index 0000000..4d78f86 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/post/entity/Post.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.post.entity; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import com.sku.refit.domain.comment.entity.Comment; +import com.sku.refit.domain.user.entity.User; +import com.sku.refit.global.common.BaseTimeEntity; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "post") +public class Post extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String content; + + @ElementCollection + @CollectionTable(name = "post_category", joinColumns = @JoinColumn(name = "post_id")) + @Column(nullable = false) + private List categoryList; + + @ElementCollection + @CollectionTable(name = "post_image_url", joinColumns = @JoinColumn(name = "post_id")) + @Column(name = "image_url", nullable = false) + private List imageUrlList = new ArrayList<>(); + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @OneToMany( + mappedBy = "post", + fetch = FetchType.EAGER, + cascade = CascadeType.ALL, + orphanRemoval = true) + @Builder.Default + private List commentList = new ArrayList<>(); + + public void update(String title, String content, List imageUrlList) { + this.title = title; + this.content = content; + this.imageUrlList = imageUrlList; + } +} diff --git a/src/main/java/com/sku/refit/domain/post/exception/PostErrorCode.java b/src/main/java/com/sku/refit/domain/post/exception/PostErrorCode.java new file mode 100644 index 0000000..05eeac3 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/post/exception/PostErrorCode.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.post.exception; + +import org.springframework.http.HttpStatus; + +import com.sku.refit.global.exception.model.BaseErrorCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PostErrorCode implements BaseErrorCode { + POST_NOT_FOUND("POST001", "게시글이 존재하지 않습니다.", HttpStatus.NOT_FOUND), + INVALID_CATEGORY("POST002", "유효하지 않은 카테고리입니다.", HttpStatus.BAD_REQUEST), + IMAGE_UPLOAD_FAILED("POST003", "이미지 업로드 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + IMAGE_DELETE_FAILED("POST004", "이미지 삭제 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + POST_CREATE_FAILED("POST005", "게시글 생성 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + POST_UPDATE_FAILED("POST006", "게시글 수정 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + POST_DELETE_FAILED("POST007", "게시글 삭제 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/com/sku/refit/domain/post/mapper/PostMapper.java b/src/main/java/com/sku/refit/domain/post/mapper/PostMapper.java new file mode 100644 index 0000000..44c48a4 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/post/mapper/PostMapper.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.post.mapper; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.sku.refit.domain.comment.entity.Comment; +import com.sku.refit.domain.post.dto.request.PostRequest; +import com.sku.refit.domain.post.dto.response.PostDetailResponse; +import com.sku.refit.domain.post.entity.Post; +import com.sku.refit.domain.user.entity.User; + +@Component +public class PostMapper { + + public Post toPost(PostRequest postRequest, List imageUrlList, User user) { + + return Post.builder() + .title(postRequest.getTitle()) + .content(postRequest.getContent()) + .categoryList(postRequest.getCategoryList()) + .imageUrlList(imageUrlList) + .user(user) + .build(); + } + + public PostDetailResponse toDetailResponse(Post post, User user) { + + return PostDetailResponse.builder() + .postId(post.getId()) + .title(post.getTitle()) + .content(post.getContent()) + .createdAt(post.getCreatedAt()) + .nickname(user.getNickname()) + .categoryList(post.getCategoryList()) + .commentIdList(post.getCommentList().stream().map(Comment::getId).toList()) + .build(); + } +} diff --git a/src/main/java/com/sku/refit/domain/post/repository/PostRepository.java b/src/main/java/com/sku/refit/domain/post/repository/PostRepository.java new file mode 100644 index 0000000..a07de75 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/post/repository/PostRepository.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.post.repository; + +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 com.sku.refit.domain.post.entity.Post; + +@Repository +public interface PostRepository extends JpaRepository { + + Page findByCategoryListContaining(String category, Pageable pageable); + + Page findByCategoryListContainingAndIdLessThan( + String category, Long lastPostId, Pageable pageable); +} diff --git a/src/main/java/com/sku/refit/domain/post/service/PostService.java b/src/main/java/com/sku/refit/domain/post/service/PostService.java new file mode 100644 index 0000000..16cd69d --- /dev/null +++ b/src/main/java/com/sku/refit/domain/post/service/PostService.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.post.service; + +import java.util.List; + +import org.springframework.web.multipart.MultipartFile; + +import com.sku.refit.domain.post.dto.request.PostRequest; +import com.sku.refit.domain.post.dto.response.PostDetailResponse; +import com.sku.refit.global.page.response.InfiniteResponse; + +/** + * 게시글(Post) 관련 주요 기능을 제공하는 서비스 인터페이스입니다. + * + *

주요 기능: + * + *

    + *
  • 게시글 생성 + *
  • 게시글 전체 조회 + *
  • 카테고리별 게시글 조회 + *
  • 게시글 상세 조회 + *
  • 게시글 수정 + *
  • 게시글 삭제 + *
+ */ +public interface PostService { + + /** + * 새 게시글을 생성합니다. + * + * @param request 게시글 생성 요청 데이터 + * @param images 첨부 이미지 목록 + * @return 생성된 게시글 상세 응답 + */ + PostDetailResponse createPost(PostRequest request, List images); + + /** + * 모든 게시글 목록을 조회합니다. + * + * @return 게시글 상세 응답 리스트 + */ + List getAllPosts(); + + /** + * 특정 카테고리에 해당하는 게시글을 조회합니다. + * + * @param category 조회할 카테고리 + * @return 무한 스크롤 형태의 게시글 응답 + */ + InfiniteResponse getPostsByCategory( + String category, Long lastPostId, Integer size); + + /** + * ID로 게시글 상세 정보를 조회합니다. + * + * @param id 조회할 게시글 ID + * @return 게시글 상세 응답 + */ + PostDetailResponse getPostById(Long id); + + /** + * 게시글을 수정합니다. + * + * @param id 수정할 게시글 ID + * @param request 수정 요청 데이터 + * @param images 수정할 이미지 목록 + * @return 수정된 게시글 상세 응답 + */ + PostDetailResponse updatePost(Long id, PostRequest request, List images); + + /** + * 게시글을 삭제합니다. + * + * @param id 삭제할 게시글 ID + */ + void deletePost(Long id); +} diff --git a/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java b/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java new file mode 100644 index 0000000..4b37093 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.post.service; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.sku.refit.domain.post.dto.request.PostRequest; +import com.sku.refit.domain.post.dto.response.PostDetailResponse; +import com.sku.refit.domain.post.entity.Post; +import com.sku.refit.domain.post.exception.PostErrorCode; +import com.sku.refit.domain.post.mapper.PostMapper; +import com.sku.refit.domain.post.repository.PostRepository; +import com.sku.refit.domain.user.entity.User; +import com.sku.refit.domain.user.service.UserService; +import com.sku.refit.global.exception.CustomException; +import com.sku.refit.global.page.mapper.InfiniteMapper; +import com.sku.refit.global.page.response.InfiniteResponse; +import com.sku.refit.global.s3.entity.PathName; +import com.sku.refit.global.s3.service.S3Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class PostServiceImpl implements PostService { + + private final PostRepository postRepository; + private final S3Service s3Service; + private final UserService userService; + private final PostMapper postMapper; + private final InfiniteMapper infiniteMapper; + + @Override + @Transactional + public PostDetailResponse createPost(PostRequest request, List images) { + + User user = userService.getCurrentUser(); + List imageUrlList = new ArrayList<>(); + + if (images != null && !images.isEmpty()) { + for (MultipartFile image : images) { + String imageUrl = s3Service.uploadImage(PathName.POST, image).getImageUrl(); + imageUrlList.add(imageUrl); + } + } + + Post post = postMapper.toPost(request, imageUrlList, user); + postRepository.save(post); + + return postMapper.toDetailResponse(post, user); + } + + @Override + @Transactional(readOnly = true) + public List getAllPosts() { + + User user = userService.getCurrentUser(); + List posts = postRepository.findAll(); + + return posts.stream().map(post -> postMapper.toDetailResponse(post, user)).toList(); + } + + @Override + @Transactional(readOnly = true) + public InfiniteResponse getPostsByCategory( + String category, Long lastPostId, Integer size) { + + User user = userService.getCurrentUser(); + + Pageable pageable = PageRequest.of(0, size + 1, Sort.by(Sort.Direction.DESC, "id")); + List posts; + + if (lastPostId == null) { + posts = postRepository.findByCategoryListContaining(category, pageable).getContent(); + } else { + posts = + postRepository + .findByCategoryListContainingAndIdLessThan(category, lastPostId, pageable) + .getContent(); + } + + boolean hasNext = posts.size() > size; + if (hasNext) { + posts = posts.subList(0, size); + } + + List postResponseList = + posts.stream().map(post -> postMapper.toDetailResponse(post, user)).toList(); + + Long newLastCursor = posts.isEmpty() ? null : posts.getLast().getId(); + + return infiniteMapper.toInfiniteResponse(postResponseList, newLastCursor, hasNext, size); + } + + @Override + @Transactional(readOnly = true) + public PostDetailResponse getPostById(Long id) { + + User user = userService.getCurrentUser(); + Post post = + postRepository + .findById(id) + .orElseThrow(() -> new CustomException(PostErrorCode.POST_NOT_FOUND)); + + return postMapper.toDetailResponse(post, user); + } + + @Override + @Transactional + public PostDetailResponse updatePost(Long id, PostRequest request, List images) { + + User user = userService.getCurrentUser(); + Post post = + postRepository + .findById(id) + .orElseThrow(() -> new CustomException(PostErrorCode.POST_NOT_FOUND)); + + List oldImageUrls = post.getImageUrlList(); + List newImageUrls = new ArrayList<>(); + + if (images != null && !images.isEmpty()) { + + try { + // 기존 이미지 삭제 + for (String imageUrl : oldImageUrls) { + s3Service.deleteFile(s3Service.extractKeyNameFromUrl(imageUrl)); + } + + // 새 이미지 업로드 + for (MultipartFile image : images) { + newImageUrls.add(s3Service.uploadImage(PathName.POST, image).getImageUrl()); + } + + } catch (Exception e) { + throw new CustomException(PostErrorCode.IMAGE_UPLOAD_FAILED); + } + + } else { + newImageUrls = oldImageUrls; + } + + post.update(request.getTitle(), request.getContent(), newImageUrls); + + return postMapper.toDetailResponse(post, user); + } + + @Override + @Transactional + public void deletePost(Long id) { + + Post post = + postRepository + .findById(id) + .orElseThrow(() -> new CustomException(PostErrorCode.POST_NOT_FOUND)); + + for (String imageUrl : post.getImageUrlList()) { + s3Service.deleteFile(s3Service.extractKeyNameFromUrl(imageUrl)); + } + + postRepository.delete(post); + } +} From 29a17d5f64061b90d7a6bdaffb072252a0a28685 Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Wed, 3 Dec 2025 15:26:17 +0900 Subject: [PATCH 4/6] =?UTF-8?q?:recycle:Refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EA=B8=B0=EB=B0=98=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/CommentDetailResponse.java | 6 +++--- .../comment/service/CommentServiceImpl.java | 9 +++++++++ .../domain/post/controller/PostController.java | 2 +- .../post/controller/PostControllerImpl.java | 6 +++--- .../post/dto/response/PostDetailResponse.java | 3 +++ .../com/sku/refit/domain/post/entity/Post.java | 2 +- .../domain/post/service/PostServiceImpl.java | 18 ++++++++++++++++-- 7 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/sku/refit/domain/comment/dto/response/CommentDetailResponse.java b/src/main/java/com/sku/refit/domain/comment/dto/response/CommentDetailResponse.java index 8565d09..79bccf8 100644 --- a/src/main/java/com/sku/refit/domain/comment/dto/response/CommentDetailResponse.java +++ b/src/main/java/com/sku/refit/domain/comment/dto/response/CommentDetailResponse.java @@ -17,12 +17,12 @@ public class CommentDetailResponse { @Schema(description = "댓글 식별자", example = "1") private Long commentId; - @Schema(description = "게시글 내용", example = "처음 가보는거라 질문 드립니다 ㅠㅠ") + @Schema(description = "댓글 내용", example = "처음 가보는거라 질문 드립니다 ㅠㅠ") private String content; - @Schema(description = "게시글 작성자 여부", example = "true") + @Schema(description = "댓글 작성자 여부", example = "true") private Boolean isWriter; - @Schema(description = "게시글 작성 시간", example = "20250101T120000") + @Schema(description = "댓글 작성 시간", example = "20250101T120000") private LocalDateTime createdAt; } diff --git a/src/main/java/com/sku/refit/domain/comment/service/CommentServiceImpl.java b/src/main/java/com/sku/refit/domain/comment/service/CommentServiceImpl.java index 0f407aa..3e353f1 100644 --- a/src/main/java/com/sku/refit/domain/comment/service/CommentServiceImpl.java +++ b/src/main/java/com/sku/refit/domain/comment/service/CommentServiceImpl.java @@ -75,6 +75,10 @@ public CommentDetailResponse updateComment(Long id, CommentRequest request) { .findById(id) .orElseThrow(() -> new CustomException(CommentErrorCode.COMMENT_NOT_FOUND)); + if (!comment.getUser().getId().equals(user.getId())) { + throw new CustomException(CommentErrorCode.COMMENT_NOT_FOUND); + } + comment.update(request.getContent()); return commentMapper.toDetailResponse(comment, user); @@ -84,11 +88,16 @@ public CommentDetailResponse updateComment(Long id, CommentRequest request) { @Transactional public void deleteComment(Long id) { + User user = userService.getCurrentUser(); Comment comment = commentRepository .findById(id) .orElseThrow(() -> new CustomException(CommentErrorCode.COMMENT_NOT_FOUND)); + if (!comment.getUser().getId().equals(user.getId())) { + throw new CustomException(CommentErrorCode.COMMENT_NOT_FOUND); + } + Post post = comment.getPost(); post.getCommentList().remove(comment); diff --git a/src/main/java/com/sku/refit/domain/post/controller/PostController.java b/src/main/java/com/sku/refit/domain/post/controller/PostController.java index 3622f5a..42a2e26 100644 --- a/src/main/java/com/sku/refit/domain/post/controller/PostController.java +++ b/src/main/java/com/sku/refit/domain/post/controller/PostController.java @@ -85,6 +85,6 @@ ResponseEntity> updatePostById( @DeleteMapping("/{id}") @Operation(summary = "게시글 삭제", description = "특정 게시글을 삭제합니다.") - ResponseEntity> deleteExhibitionLike( + ResponseEntity> deletePost( @Parameter(description = "게시글 식별자", example = "1") @PathVariable Long id); } diff --git a/src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java b/src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java index 06642d9..603a1a6 100644 --- a/src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java +++ b/src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java @@ -44,10 +44,10 @@ public ResponseEntity>> getAllPosts() { @Override public ResponseEntity>> getPostByCategory( - String category, Long lastBookId, Integer size) { + String category, Long lastPostId, Integer size) { InfiniteResponse response = - postService.getPostsByCategory(category, lastBookId, size); + postService.getPostsByCategory(category, lastPostId, size); return ResponseEntity.ok(BaseResponse.success(response)); } @@ -67,7 +67,7 @@ public ResponseEntity> updatePostById( } @Override - public ResponseEntity> deleteExhibitionLike(Long id) { + public ResponseEntity> deletePost(Long id) { postService.deletePost(id); return ResponseEntity.ok(BaseResponse.success(null)); diff --git a/src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java b/src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java index ff8a2ac..da86f8f 100644 --- a/src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java +++ b/src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java @@ -33,6 +33,9 @@ public class PostDetailResponse { @Schema(description = "게시글 작성자", example = "김다입") private String nickname; + @Schema(description = "이미지 URL 리스트") + private List imageUrlList; + @Schema(description = "댓글 식별자 리스트", example = "[1, 2, 3]") private List commentIdList; } diff --git a/src/main/java/com/sku/refit/domain/post/entity/Post.java b/src/main/java/com/sku/refit/domain/post/entity/Post.java index 4d78f86..96d7ac0 100644 --- a/src/main/java/com/sku/refit/domain/post/entity/Post.java +++ b/src/main/java/com/sku/refit/domain/post/entity/Post.java @@ -64,7 +64,7 @@ public class Post extends BaseTimeEntity { @OneToMany( mappedBy = "post", - fetch = FetchType.EAGER, + fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default diff --git a/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java b/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java index 4b37093..669afac 100644 --- a/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java +++ b/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java @@ -127,23 +127,32 @@ public PostDetailResponse updatePost(Long id, PostRequest request, List new CustomException(PostErrorCode.POST_NOT_FOUND)); + if (!post.getUser().getId().equals(user.getId())) { + throw new CustomException(PostErrorCode.POST_UPDATE_FAILED); + } + List oldImageUrls = post.getImageUrlList(); List newImageUrls = new ArrayList<>(); if (images != null && !images.isEmpty()) { try { - // 기존 이미지 삭제 for (String imageUrl : oldImageUrls) { s3Service.deleteFile(s3Service.extractKeyNameFromUrl(imageUrl)); } - // 새 이미지 업로드 for (MultipartFile image : images) { newImageUrls.add(s3Service.uploadImage(PathName.POST, image).getImageUrl()); } + for (String imageUrl : oldImageUrls) { + s3Service.deleteFile(s3Service.extractKeyNameFromUrl(imageUrl)); + } + } catch (Exception e) { + for (String url : newImageUrls) { + s3Service.deleteFile(s3Service.extractKeyNameFromUrl(url)); + } throw new CustomException(PostErrorCode.IMAGE_UPLOAD_FAILED); } @@ -160,11 +169,16 @@ public PostDetailResponse updatePost(Long id, PostRequest request, List new CustomException(PostErrorCode.POST_NOT_FOUND)); + if (!post.getUser().getId().equals(user.getId())) { + throw new CustomException(PostErrorCode.POST_DELETE_FAILED); + } + for (String imageUrl : post.getImageUrlList()) { s3Service.deleteFile(s3Service.extractKeyNameFromUrl(imageUrl)); } From f443c6e4c5d5484a26c44d9b0e2a3d1c4b4aa1b1 Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Thu, 4 Dec 2025 10:18:42 +0900 Subject: [PATCH 5/6] =?UTF-8?q?:recycle:Refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EA=B8=B0=EB=B0=98=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sku/refit/domain/post/controller/PostController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/sku/refit/domain/post/controller/PostController.java b/src/main/java/com/sku/refit/domain/post/controller/PostController.java index 42a2e26..8b42f1c 100644 --- a/src/main/java/com/sku/refit/domain/post/controller/PostController.java +++ b/src/main/java/com/sku/refit/domain/post/controller/PostController.java @@ -58,7 +58,7 @@ ResponseEntity>> getPostByCate @RequestParam String category, @Parameter(description = "마지막으로 조회한 게시글 식별자(첫 조회 시 생략)", example = "3") @RequestParam(required = false) - Long lastBookId, + Long lastPostId, @Parameter(description = "한 번에 조회할 게시글 개수", example = "3") @RequestParam(defaultValue = "3") Integer size); From 16720e34068b13af60f2d251bfef271edcfc51ba Mon Sep 17 00:00:00 2001 From: uni-j-uni Date: Thu, 4 Dec 2025 10:24:24 +0900 Subject: [PATCH 6/6] =?UTF-8?q?:recycle:Refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EA=B8=B0=EB=B0=98=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../refit/domain/post/controller/PostController.java | 10 +++++----- .../domain/post/controller/PostControllerImpl.java | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/sku/refit/domain/post/controller/PostController.java b/src/main/java/com/sku/refit/domain/post/controller/PostController.java index 8b42f1c..0efa1fa 100644 --- a/src/main/java/com/sku/refit/domain/post/controller/PostController.java +++ b/src/main/java/com/sku/refit/domain/post/controller/PostController.java @@ -45,15 +45,15 @@ ResponseEntity> createPost( @Parameter( description = "게시글 이미지 리스트", content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) - @RequestPart(value = "images", required = false) - List images); + @RequestPart(value = "imageList", required = false) + List imageList); @GetMapping("/admin") @Operation(summary = "[관리자] 게시글 전체 조회", description = "전체 게시글 리스트를 조회합니다.") ResponseEntity>> getAllPosts(); @GetMapping - @Operation(summary = "카테코리별 게시글 전체 조회", description = "특정 카테고리의 게시글 리스트를 조회합니다.") + @Operation(summary = "카테고리별 게시글 전체 조회", description = "특정 카테고리의 게시글 리스트를 조회합니다.") ResponseEntity>> getPostByCategory( @RequestParam String category, @Parameter(description = "마지막으로 조회한 게시글 식별자(첫 조회 시 생략)", example = "3") @@ -74,8 +74,8 @@ ResponseEntity> updatePostById( @Parameter( description = "수정할 이미지 리스트", content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) - @RequestPart(value = "image", required = false) - List image, + @RequestPart(value = "imageList", required = false) + List imageList, @Parameter( description = "게시글 수정 내용", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) diff --git a/src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java b/src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java index 603a1a6..325d411 100644 --- a/src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java +++ b/src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java @@ -29,9 +29,9 @@ public class PostControllerImpl implements PostController { @Override public ResponseEntity> createPost( - @Valid PostRequest request, List images) { + @Valid PostRequest request, List imageList) { - PostDetailResponse response = postService.createPost(request, images); + PostDetailResponse response = postService.createPost(request, imageList); return ResponseEntity.ok(BaseResponse.success(response)); } @@ -60,9 +60,9 @@ public ResponseEntity> getPostById(Long id) { @Override public ResponseEntity> updatePostById( - Long id, List image, @Valid PostRequest request) { + Long id, List imageList, @Valid PostRequest request) { - PostDetailResponse response = postService.updatePost(id, request, image); + PostDetailResponse response = postService.updatePost(id, request, imageList); return ResponseEntity.ok(BaseResponse.success(response)); }