From 16b828fecaa74c14f70fcffbea624392d58a547f Mon Sep 17 00:00:00 2001 From: Juhyeon Lee Date: Tue, 3 Mar 2026 16:06:53 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[FEAT]=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20API?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../setting/service/NoticeSettingService.java | 1 - .../api/story/controller/StoryController.java | 103 ++++++++++ .../api/story/dto/StoryAllResponseDTO.java | 22 +++ .../backend/api/story/dto/StoryDTO.java | 36 ++++ .../api/story/dto/StoryIdResponseDTO.java | 15 ++ .../backend/api/story/entity/Story.java | 33 ++++ .../api/story/repository/StoryRepository.java | 44 +++++ .../api/story/service/StoryService.java | 186 ++++++++++++++++++ .../backend/common/response/ErrorStatus.java | 2 + .../common/response/SuccessStatus.java | 5 + 10 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/moongeul/backend/api/story/controller/StoryController.java create mode 100644 src/main/java/com/moongeul/backend/api/story/dto/StoryAllResponseDTO.java create mode 100644 src/main/java/com/moongeul/backend/api/story/dto/StoryDTO.java create mode 100644 src/main/java/com/moongeul/backend/api/story/dto/StoryIdResponseDTO.java create mode 100644 src/main/java/com/moongeul/backend/api/story/entity/Story.java create mode 100644 src/main/java/com/moongeul/backend/api/story/repository/StoryRepository.java create mode 100644 src/main/java/com/moongeul/backend/api/story/service/StoryService.java diff --git a/src/main/java/com/moongeul/backend/api/setting/service/NoticeSettingService.java b/src/main/java/com/moongeul/backend/api/setting/service/NoticeSettingService.java index 7d2f7ad..60eb5b6 100644 --- a/src/main/java/com/moongeul/backend/api/setting/service/NoticeSettingService.java +++ b/src/main/java/com/moongeul/backend/api/setting/service/NoticeSettingService.java @@ -3,7 +3,6 @@ import com.moongeul.backend.api.member.entity.Member; import com.moongeul.backend.api.member.entity.Role; import com.moongeul.backend.api.member.repository.MemberRepository; -import com.moongeul.backend.api.setting.dto.NoticeAllRequestDTO; import com.moongeul.backend.api.setting.dto.NoticeAllResponseDTO; import com.moongeul.backend.api.setting.dto.NoticeDTO; import com.moongeul.backend.api.setting.dto.NoticeRequestDTO; diff --git a/src/main/java/com/moongeul/backend/api/story/controller/StoryController.java b/src/main/java/com/moongeul/backend/api/story/controller/StoryController.java new file mode 100644 index 0000000..c159c8b --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/story/controller/StoryController.java @@ -0,0 +1,103 @@ +package com.moongeul.backend.api.story.controller; + +import com.moongeul.backend.api.post.entity.PostVisibility; +import com.moongeul.backend.api.story.dto.StoryAllResponseDTO; +import com.moongeul.backend.api.story.dto.StoryIdResponseDTO; +import com.moongeul.backend.api.story.dto.StoryDTO; +import com.moongeul.backend.api.story.service.StoryService; +import com.moongeul.backend.common.response.ApiResponse; +import com.moongeul.backend.common.response.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "Story", description = "Story(스토리) 관련 API 입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v2/story") +public class StoryController { + + private final StoryService storyService; + + @Operation( + summary = "스토리 제작 API", + description = "스토리에서 제작한 '이미지' 파일을 저장합니다." + + "
- 해당 스토리와 연관된 게시글 ID(postId)값을 넘겨주세요." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "스토리 제작 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "업로드할 이미지가 없거나 이미지 형식이 아닙니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 사용자를 찾을 수 없습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "파일 업로드/삭제에 실패했습니다.") + }) + @PostMapping(value = "/create/{postId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> createStory( + @AuthenticationPrincipal UserDetails userDetails, + @RequestPart("StoryImage") MultipartFile storyImage, + @PathVariable Long postId) { + + StoryIdResponseDTO storyIdResponseDTO = storyService.createStory(userDetails.getUsername(), storyImage, postId); + return ApiResponse.success(SuccessStatus.CREATE_STORY_SUCCESS, storyIdResponseDTO); + } + + @Operation( + summary = "스토리 전체 조회 API", + description = "활성화된 스토리를 전체 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "스토리 전체 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 사용자를 찾을 수 없습니다."), + }) + @GetMapping + public ResponseEntity> getStory( + @AuthenticationPrincipal UserDetails userDetails, + @RequestParam(required = false, defaultValue = "PUBLIC") PostVisibility postVisibility, + @RequestParam(required = false, defaultValue = "1") @Min(value = 1, message = "페이지는 1 이상이어야 합니다.(1부터 시작)") Integer page, + @RequestParam(required = false, defaultValue = "10") @Min(value = 1, message = "한 페이지당 개수는 1 이상이어야 합니다.") Integer size) { + + // 비회원인 경우 "anonymousUser", 회원인 경우 email + String username = (userDetails != null) ? userDetails.getUsername() : "anonymousUser"; + + StoryAllResponseDTO storyAllResponseDTO = storyService.getALLStory(username, postVisibility, page, size); + return ApiResponse.success(SuccessStatus.GET_ALL_STORY_SUCCESS, storyAllResponseDTO); + } + + @Operation( + summary = "스토리 상세 조회 API", + description = "스토리를 상세 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "스토리 상세 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 스토리를 찾을 수 없습니다."), + }) + @GetMapping("/{id}") + public ResponseEntity> getStory(@PathVariable Long id) { + + StoryDTO storyDTO = storyService.getStory(id); + return ApiResponse.success(SuccessStatus.GET_STORY_SUCCESS, storyDTO); + } + + @Operation( + summary = "스토리 삭제 API", + description = "스토리를 삭제합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "스토리 삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "회원의 스토리가 아닙니다."), + }) + @DeleteMapping("/{id}") + public ResponseEntity> deleteStory(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable Long id) { + + storyService.deleteStory(userDetails.getUsername(), id); + return ApiResponse.success_only(SuccessStatus.DELETE_STORY_SUCCESS); + } +} diff --git a/src/main/java/com/moongeul/backend/api/story/dto/StoryAllResponseDTO.java b/src/main/java/com/moongeul/backend/api/story/dto/StoryAllResponseDTO.java new file mode 100644 index 0000000..f776d8b --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/story/dto/StoryAllResponseDTO.java @@ -0,0 +1,22 @@ +package com.moongeul.backend.api.story.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StoryAllResponseDTO { + + private Long total; // 전체 검색 결과 수 + private Integer page; // 현재 페이지 + private Integer size; // 페이지당 개수 + private Integer totalPages; // 전체 페이지 수 + private Boolean isLast; // 마지막 페이지 여부 + private List data; // 기록 리스트 +} diff --git a/src/main/java/com/moongeul/backend/api/story/dto/StoryDTO.java b/src/main/java/com/moongeul/backend/api/story/dto/StoryDTO.java new file mode 100644 index 0000000..d53cc9b --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/story/dto/StoryDTO.java @@ -0,0 +1,36 @@ +package com.moongeul.backend.api.story.dto; + +import com.moongeul.backend.api.readingTaste.entity.ReadingTasteType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StoryDTO { + + private MemberInfo memberInfo; + private StoryInfo storyInfo; + + @Getter + @Builder + public static class MemberInfo { + private Long memberId; + private String nickname; // 닉네임 + private String profileImage; // 회원 이미지 + private ReadingTasteType readingTasteType; // 독서 취향 유형 + } + + @Getter + @Builder + public static class StoryInfo { + private Long storyId; + private String storyImage; + private LocalDateTime created; // 작성 시간 + } +} diff --git a/src/main/java/com/moongeul/backend/api/story/dto/StoryIdResponseDTO.java b/src/main/java/com/moongeul/backend/api/story/dto/StoryIdResponseDTO.java new file mode 100644 index 0000000..9765369 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/story/dto/StoryIdResponseDTO.java @@ -0,0 +1,15 @@ +package com.moongeul.backend.api.story.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StoryIdResponseDTO { + + private Long storyId; +} diff --git a/src/main/java/com/moongeul/backend/api/story/entity/Story.java b/src/main/java/com/moongeul/backend/api/story/entity/Story.java new file mode 100644 index 0000000..545152f --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/story/entity/Story.java @@ -0,0 +1,33 @@ +package com.moongeul.backend.api.story.entity; + +import com.moongeul.backend.api.member.entity.Member; +import com.moongeul.backend.api.post.entity.Post; +import com.moongeul.backend.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder // 빌더 패턴 사용을 위한 롬복 애너테이션 +@NoArgsConstructor // 기본 생성자 +@AllArgsConstructor // 모든 필드를 포함한 생성자 +@Table(name = "STORY") // 데이터베이스 테이블 이름 지정 +public class Story extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String storyImage; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; +} diff --git a/src/main/java/com/moongeul/backend/api/story/repository/StoryRepository.java b/src/main/java/com/moongeul/backend/api/story/repository/StoryRepository.java new file mode 100644 index 0000000..bf19ce1 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/story/repository/StoryRepository.java @@ -0,0 +1,44 @@ +package com.moongeul.backend.api.story.repository; + +import com.moongeul.backend.api.story.entity.Story; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; + +public interface StoryRepository extends JpaRepository { + + // 1. 전체 공개 스토리 조회 (비로그인/로그인 공용) (24시간 이내 + 팔로잉 스토리 + 내 스토리 포함) + @Query("SELECT DISTINCT s FROM Story s " + + "JOIN FETCH s.member m " + + "JOIN FETCH s.post p " + + "LEFT JOIN Follow f ON f.following = m AND f.follower.email = :email " + + "WHERE s.createdAt >= :timeLimit " + + "AND (" + + " p.postVisibility = 'PUBLIC' " + // 1. 전체 공개 스토리 + " OR m.email = :email " + // 2. 내 스토리 + " OR (p.postVisibility = 'FOLLOWERS' AND f.followStatus = 'ACCEPTED')" + // 3. 팔로잉 중인 스토리 + ")") + Page findAllPublicStories( + @Param("email") String email, + @Param("timeLimit") LocalDateTime timeLimit, + Pageable pageable + ); + + // 2. 팔로워 공개 스토리 조회 (24시간 이내 + 팔로잉 스토리 + 내 스토리) + @Query("SELECT s FROM Story s " + + "JOIN FETCH s.member " + + "JOIN FETCH s.post p " + + "LEFT JOIN Follow f ON f.following = s.member AND f.follower.email = :email " + + "WHERE ((s.member.email = :email) " + + "OR (p.postVisibility = 'FOLLOWERS' AND f.followStatus = 'ACCEPTED')) " + + "AND s.createdAt >= :timeLimit") + Page findAllFollowerStories( + @Param("email") String email, + @Param("timeLimit") LocalDateTime timeLimit, + Pageable pageable + ); +} diff --git a/src/main/java/com/moongeul/backend/api/story/service/StoryService.java b/src/main/java/com/moongeul/backend/api/story/service/StoryService.java new file mode 100644 index 0000000..b139bfe --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/story/service/StoryService.java @@ -0,0 +1,186 @@ +package com.moongeul.backend.api.story.service; + +import com.moongeul.backend.api.member.entity.Member; +import com.moongeul.backend.api.member.repository.MemberRepository; +import com.moongeul.backend.api.post.entity.Post; +import com.moongeul.backend.api.post.entity.PostVisibility; +import com.moongeul.backend.api.post.repository.PostRepository; +import com.moongeul.backend.api.story.dto.StoryAllResponseDTO; +import com.moongeul.backend.api.story.dto.StoryIdResponseDTO; +import com.moongeul.backend.api.story.dto.StoryDTO; +import com.moongeul.backend.api.story.entity.Story; +import com.moongeul.backend.api.story.repository.StoryRepository; +import com.moongeul.backend.common.exception.BadRequestException; +import com.moongeul.backend.common.exception.NotFoundException; +import com.moongeul.backend.common.exception.UnauthorizedException; +import com.moongeul.backend.common.response.ErrorStatus; +import com.moongeul.backend.common.service.FileUploadService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class StoryService { + + private final MemberRepository memberRepository; + private final StoryRepository storyRepository; + private final PostRepository postRepository; + private final FileUploadService fileUploadService; + + /* 스토리 제작 API */ + @Transactional + public StoryIdResponseDTO createStory(String email, MultipartFile storyImage, Long postId) { + validateStoryImage(storyImage); + + Member member = getMemberByEmail(email); + Post post = getPost(postId); + + // 예외처리: 회원의 게시글이 아닌 경우 + if(post.getMember() != member){ + throw new UnauthorizedException(ErrorStatus.POST_UNAUTHORIZED.getMessage()); + } + + String uploadedStoryImageUrl = fileUploadService.uploadFile(storyImage, "profile/" + member.getId()); + + Story newStory = Story.builder() + .storyImage(uploadedStoryImageUrl) + .member(member) + .post(post) + .build(); + + Story savedStory = storyRepository.save(newStory); + + return StoryIdResponseDTO.builder() + .storyId(savedStory.getId()) + .build(); + } + + private void validateStoryImage(MultipartFile storyImage) { + if (storyImage == null || storyImage.isEmpty()) { + throw new BadRequestException(ErrorStatus.PROFILE_IMAGE_EMPTY_EXCEPTION.getMessage()); + } + + String contentType = storyImage.getContentType(); + if (!StringUtils.hasText(contentType) || !contentType.startsWith("image/")) { + throw new BadRequestException(ErrorStatus.PROFILE_IMAGE_INVALID_TYPE_EXCEPTION.getMessage()); + } + } + + /* 스토리 전체 조회 API */ + @Transactional(readOnly = true) + public StoryAllResponseDTO getALLStory(String email, PostVisibility postVisibility, Integer page, Integer size) { + + Pageable pageable = PageRequest.of(page - 1, size); + Page storyPage; + + // 현재 시간 기준 24시간 전 계산 + LocalDateTime timeLimit = LocalDateTime.now().minusHours(24); + + boolean isAnonymous = (email == null || "anonymousUser".equals(email)); + + if (isAnonymous) { + // 비로그인: 전체 공개 스토리만 조회 + storyPage = storyRepository.findAllPublicStories(null, timeLimit, pageable); + } else { + // 로그인: 전체 공개 스토리 + 내 스토리 + if (PostVisibility.PUBLIC.equals(postVisibility)) { + storyPage = storyRepository.findAllPublicStories(email, timeLimit, pageable); + } else { + // FOLLOWERS 공개 범위 선택 시: 팔로잉 스토리 + 내 스토리 + storyPage = storyRepository.findAllFollowerStories(email, timeLimit, pageable); + } + } + + List storyDTOList = storyPage.getContent().stream() + .map(this::convertToDTO) + .toList(); + + return StoryAllResponseDTO.builder() + .total(storyPage.getTotalElements()) + .page(storyPage.getNumber() + 1) + .size(storyPage.getSize()) + .totalPages(storyPage.getTotalPages()) + .isLast(storyPage.isLast()) + .data(storyDTOList) + .build(); + } + + /* 스토리 상세 조회 API */ + @Transactional(readOnly = true) + public StoryDTO getStory(Long storyId) { + + Story story = getStoryById(storyId); + + return convertToDTO(story); + } + + // StoryDTO build 메서드 + private StoryDTO convertToDTO(Story story) { + + StoryDTO.MemberInfo memberInfo = StoryDTO.MemberInfo.builder() + .memberId(story.getMember().getId()) + .nickname(story.getMember().getNickname()) + .profileImage(story.getMember().getProfileImage()) + .readingTasteType(story.getMember().getReadingTasteType()) + .build(); + + StoryDTO.StoryInfo storyInfo = StoryDTO.StoryInfo.builder() + .storyId(story.getId()) + .storyImage(story.getStoryImage()) + .created(story.getCreatedAt()) + .build(); + + return StoryDTO.builder() + .memberInfo(memberInfo) + .storyInfo(storyInfo) + .build(); + } + + /* 스토리 삭제 API */ + public void deleteStory(String email, Long storyId){ + + Story story = getStoryById(storyId); + + if(!story.getMember().getEmail().equals(email)){ + throw new UnauthorizedException(ErrorStatus.STORY_UNAUTHORIZED.getMessage()); + } + + fileUploadService.deleteFileByUrl(story.getStoryImage()); + storyRepository.delete(story); + } + + /* + * + * 코드 깔끔하게 하기용 메서드 + * + * */ + + // 회원 조회 메서드 + private Member getMemberByEmail(String email) { + return memberRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundException(ErrorStatus.USER_NOTFOUND_EXCEPTION.getMessage())); + } + + // 스토리 조회 메서드 + private Story getStoryById(Long id) { + return storyRepository.findById(id) + .orElseThrow(() -> new NotFoundException(ErrorStatus.STORY_NOTFOUND_EXCEPTION.getMessage())); + } + + // 기록(게시글) 조회 메서드 + private Post getPost(Long postId) { + return postRepository.findById(postId) + .orElseThrow(() -> new NotFoundException(ErrorStatus.POST_NOTFOUND_EXCEPTION.getMessage())); + } +} diff --git a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java index a1c6b29..d7aae8e 100644 --- a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java @@ -44,6 +44,7 @@ public enum ErrorStatus { ANSWER_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "답변 수정 권한이 없습니다."), CREATE_NOTICE_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "공지사항 작성 권한이 없습니다."), DELETE_NOTICE_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "공지사항 삭제 권한이 없습니다."), + STORY_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "회원의 스토리가 아닙니다."), /** * 404 NOT_FOUND @@ -63,6 +64,7 @@ public enum ErrorStatus { NOTIFICATION_NOTFOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 알림을 찾을 수 없습니다."), READING_TASTE_TEST_RESULT_NOTFOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 UUID에 해당하는 독서 취향 테스트 결과가 없습니다."), NOTICE_NOTFOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 공지사항을 찾을 수 없습니다."), + STORY_NOTFOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 스토리를 찾을 수 없습니다."), /** * 400 BAD_REQUEST diff --git a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java index 9d0a55a..613a28f 100644 --- a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java @@ -90,6 +90,11 @@ public enum SuccessStatus { MODIFY_QUESTION_SUCCESS(HttpStatus.OK, "질문 수정 성공"), DELETE_QUESTION_SUCCESS(HttpStatus.OK, "질문 삭제 성공"), + /* STORY */ + CREATE_STORY_SUCCESS(HttpStatus.OK, "스토리 제작 성공"), + GET_STORY_SUCCESS(HttpStatus.OK, "스토리 상세 조회 성공"), + GET_ALL_STORY_SUCCESS(HttpStatus.OK, "스토리 전체 조회 성공"), + DELETE_STORY_SUCCESS(HttpStatus.OK, "스토리 삭제 성공"), /** * 201 From 5f96659d673c240ffe08f17979df43b8bd103571 Mon Sep 17 00:00:00 2001 From: Juhyeon Lee Date: Tue, 3 Mar 2026 16:50:27 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[FEAT]=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EB=B3=B4?= =?UTF-8?q?=EA=B4=80=ED=95=A8=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.java | 18 +++++++++++ .../api/member/dto/MyStoryResponseDTO.java | 31 +++++++++++++++++++ .../api/member/service/MemberService.java | 30 ++++++++++++++++++ .../api/story/repository/StoryRepository.java | 10 ++++-- .../common/response/SuccessStatus.java | 1 + 5 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/moongeul/backend/api/member/dto/MyStoryResponseDTO.java diff --git a/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java b/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java index ef2f0f2..54ba05b 100644 --- a/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java +++ b/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java @@ -13,6 +13,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -379,5 +380,22 @@ public ResponseEntity> updateProfileImage( memberService.updateProfileImage(userDetails.getUsername(), profileImage); return ApiResponse.success_only(SuccessStatus.UPDATE_PROFILE_IMAGE_SUCCESS); } + + @Operation( + summary = "스토리 보관함 조회 API", + description = "마이페이지 스토리 보관함을 내역을 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "스토리 보관함 조회 성공"), + }) + @GetMapping("/my-story") + public ResponseEntity> getMyStories( + @AuthenticationPrincipal UserDetails userDetails, + @RequestParam(required = false, defaultValue = "1") @Min(value = 1, message = "페이지는 1 이상이어야 합니다.(1부터 시작)") Integer page, + @RequestParam(required = false, defaultValue = "10") @Min(value = 1, message = "한 페이지당 개수는 1 이상이어야 합니다.") Integer size) { + + MyStoryResponseDTO myStoryResponseDTO = memberService.getMyStories(userDetails.getUsername(), page, size); + return ApiResponse.success(SuccessStatus.GET_MY_STORY_SUCCESS, myStoryResponseDTO); + } } diff --git a/src/main/java/com/moongeul/backend/api/member/dto/MyStoryResponseDTO.java b/src/main/java/com/moongeul/backend/api/member/dto/MyStoryResponseDTO.java new file mode 100644 index 0000000..d64a69b --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/dto/MyStoryResponseDTO.java @@ -0,0 +1,31 @@ +package com.moongeul.backend.api.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class MyStoryResponseDTO { + + private Long total; // 전체 검색 결과 수 + private Integer page; // 현재 페이지 + private Integer size; // 페이지당 개수 + private Integer totalPages; // 전체 페이지 수 + private Boolean isLast; // 마지막 페이지 여부 + private List data; // 기록 리스트 + + @Getter + @Builder + public static class StoryInfo { + private Long storyId; + private String storyImage; + private LocalDateTime created; // 작성 시간 + } +} diff --git a/src/main/java/com/moongeul/backend/api/member/service/MemberService.java b/src/main/java/com/moongeul/backend/api/member/service/MemberService.java index cc6188c..8f9599e 100644 --- a/src/main/java/com/moongeul/backend/api/member/service/MemberService.java +++ b/src/main/java/com/moongeul/backend/api/member/service/MemberService.java @@ -15,6 +15,8 @@ import com.moongeul.backend.api.post.repository.PostRepository; import com.moongeul.backend.api.post.repository.QuoteRepository; import com.moongeul.backend.api.book.entity.Book; +import com.moongeul.backend.api.story.entity.Story; +import com.moongeul.backend.api.story.repository.StoryRepository; import com.moongeul.backend.common.config.jwt.JwtTokenProvider; import com.moongeul.backend.common.exception.BadRequestException; import com.moongeul.backend.common.exception.ForbiddenException; @@ -45,6 +47,8 @@ public class MemberService { private final CategoryRepository categoryRepository; private final PostRepository postRepository; private final QuoteRepository quoteRepository; + private final StoryRepository storyRepository; + private final JwtTokenProvider jwtTokenProvider; private final GoogleOAuthService googleOAuthService; private final KakaoOAuthService kakaoOAuthService; @@ -482,6 +486,32 @@ private void validatePrivacyAccess(Member currentMember, Member targetMember) { } } + /* 스토리 보관함 조회 API */ + @Transactional(readOnly = true) + public MyStoryResponseDTO getMyStories(String email, Integer page, Integer size) { + + Pageable pageable = PageRequest.of(page - 1, size); + + Page myStoryPage = storyRepository.findAllMyStories(email, pageable); + + List storyInfoList = myStoryPage.getContent().stream() + .map(story -> MyStoryResponseDTO.StoryInfo.builder() + .storyId(story.getId()) + .storyImage(story.getStoryImage()) + .created(story.getCreatedAt()) + .build()) + .toList(); + + return MyStoryResponseDTO.builder() + .total(myStoryPage.getTotalElements()) + .page(myStoryPage.getNumber() + 1) + .size(myStoryPage.getSize()) + .totalPages(myStoryPage.getTotalPages()) + .isLast(myStoryPage.isLast()) + .data(storyInfoList) + .build(); + } + // 회원 조회 메서드 private Member getMemberByEmail(String email) { return memberRepository.findByEmail(email) diff --git a/src/main/java/com/moongeul/backend/api/story/repository/StoryRepository.java b/src/main/java/com/moongeul/backend/api/story/repository/StoryRepository.java index bf19ce1..82aa078 100644 --- a/src/main/java/com/moongeul/backend/api/story/repository/StoryRepository.java +++ b/src/main/java/com/moongeul/backend/api/story/repository/StoryRepository.java @@ -11,7 +11,7 @@ public interface StoryRepository extends JpaRepository { - // 1. 전체 공개 스토리 조회 (비로그인/로그인 공용) (24시간 이내 + 팔로잉 스토리 + 내 스토리 포함) + // 전체 공개 스토리 조회 (비로그인/로그인 공용) (24시간 이내 + 팔로잉 스토리 + 내 스토리 포함) @Query("SELECT DISTINCT s FROM Story s " + "JOIN FETCH s.member m " + "JOIN FETCH s.post p " + @@ -28,7 +28,7 @@ Page findAllPublicStories( Pageable pageable ); - // 2. 팔로워 공개 스토리 조회 (24시간 이내 + 팔로잉 스토리 + 내 스토리) + // 팔로워 공개 스토리 조회 (24시간 이내 + 팔로잉 스토리 + 내 스토리) @Query("SELECT s FROM Story s " + "JOIN FETCH s.member " + "JOIN FETCH s.post p " + @@ -41,4 +41,10 @@ Page findAllFollowerStories( @Param("timeLimit") LocalDateTime timeLimit, Pageable pageable ); + + // 내 스토리 조회 + @Query("SELECT s FROM Story s " + + "WHERE s.member.email = :email " + + "ORDER BY s.createdAt DESC") + Page findAllMyStories(@Param("email") String email, Pageable pageable); } diff --git a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java index 613a28f..2595333 100644 --- a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java @@ -95,6 +95,7 @@ public enum SuccessStatus { GET_STORY_SUCCESS(HttpStatus.OK, "스토리 상세 조회 성공"), GET_ALL_STORY_SUCCESS(HttpStatus.OK, "스토리 전체 조회 성공"), DELETE_STORY_SUCCESS(HttpStatus.OK, "스토리 삭제 성공"), + GET_MY_STORY_SUCCESS(HttpStatus.OK, "스토리 보관함 조회 성공"), /** * 201