From e27938114a7acc82bb1121fd3925d46b32ef650b Mon Sep 17 00:00:00 2001 From: kimgyurin Date: Thu, 27 Aug 2026 18:39:39 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20StoryView=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EB=B0=8F=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monday/domain/story/entity/StoryView.java | 56 +++++++++++++++++++ .../story/repository/StoryViewRepository.java | 11 ++++ 2 files changed, 67 insertions(+) create mode 100644 src/main/java/com/likelion/monday/domain/story/entity/StoryView.java create mode 100644 src/main/java/com/likelion/monday/domain/story/repository/StoryViewRepository.java diff --git a/src/main/java/com/likelion/monday/domain/story/entity/StoryView.java b/src/main/java/com/likelion/monday/domain/story/entity/StoryView.java new file mode 100644 index 0000000..2e23ec6 --- /dev/null +++ b/src/main/java/com/likelion/monday/domain/story/entity/StoryView.java @@ -0,0 +1,56 @@ +package com.likelion.monday.domain.story.entity; + +import com.likelion.monday.global.entity.BaseEntity; +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 jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 비로그인 사용자의 중복 조회 방지를 위해 accountId(로그인 시)와 guestKey(비로그인 시) 중 하나로 식별한다. + * 같은 사연을 같은 계정/게스트가 다시 조회해도 조회수가 중복으로 올라가지 않도록 유니크 제약을 건다. + * accountId/guestKey 중 정확히 하나만 채워지도록 하는 검증은 상세조회 API를 만들 때 서비스 레이어에서 처리한다. + */ +@Entity +@Table( + name = "story_view", + uniqueConstraints = { + @UniqueConstraint(name = "uk_story_view_account", columnNames = {"story_id", "account_id"}), + @UniqueConstraint(name = "uk_story_view_guest", columnNames = {"story_id", "guest_key"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class StoryView extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "story_id", nullable = false) + private Story story; + + @Column(name = "account_id") + private Long accountId; + + @Column(name = "guest_key", length = 100) + private String guestKey; + + @Builder + private StoryView(Story story, Long accountId, String guestKey) { + this.story = story; + this.accountId = accountId; + this.guestKey = guestKey; + } +} \ No newline at end of file diff --git a/src/main/java/com/likelion/monday/domain/story/repository/StoryViewRepository.java b/src/main/java/com/likelion/monday/domain/story/repository/StoryViewRepository.java new file mode 100644 index 0000000..0e6a24a --- /dev/null +++ b/src/main/java/com/likelion/monday/domain/story/repository/StoryViewRepository.java @@ -0,0 +1,11 @@ +package com.likelion.monday.domain.story.repository; + +import com.likelion.monday.domain.story.entity.StoryView; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StoryViewRepository extends JpaRepository { + + boolean existsByStory_IdAndAccountId(Long storyId, Long accountId); + + boolean existsByStory_IdAndGuestKey(Long storyId, String guestKey); +} \ No newline at end of file From aced3e756fa0055b1d660462111d6b35cb8b1b45 Mon Sep 17 00:00:00 2001 From: kimgyurin Date: Thu, 27 Aug 2026 18:43:01 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EB=B9=84=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=8B=9D=EB=B3=84?= =?UTF-8?q?=EC=9A=A9=20guest=5Fkey=20=EC=BF=A0=ED=82=A4=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/cookie/GuestKeyProvider.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/main/java/com/likelion/monday/global/cookie/GuestKeyProvider.java diff --git a/src/main/java/com/likelion/monday/global/cookie/GuestKeyProvider.java b/src/main/java/com/likelion/monday/global/cookie/GuestKeyProvider.java new file mode 100644 index 0000000..f56cba3 --- /dev/null +++ b/src/main/java/com/likelion/monday/global/cookie/GuestKeyProvider.java @@ -0,0 +1,55 @@ +package com.likelion.monday.global.cookie; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.UUID; +import org.springframework.stereotype.Component; + +/** + * 비로그인 사용자를 식별하기 위한 guest_key를 쿠키로 관리한다. + * 요청에 쿠키가 있으면 그 값을 그대로 쓰고, 없으면 새로 발급해서 응답에 심어준다. + * 조회/공감 등 중복 방지가 필요한 곳에서 공통으로 사용한다. + * + * TODO: 배포 도메인이 FE와 분리되는 경우 SameSite=None; Secure 설정이 필요할 수 있음 (CORS/withCredentials 논의와 함께 확인) + */ +@Component +public class GuestKeyProvider { + + private static final String COOKIE_NAME = "guest_key"; + private static final int COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; + + public String resolve(HttpServletRequest request, HttpServletResponse response) { + String existing = findExisting(request); + if (existing != null) { + return existing; + } + + String newGuestKey = UUID.randomUUID().toString(); + response.addCookie(createCookie(newGuestKey)); + return newGuestKey; + } + + private String findExisting(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + + for (Cookie cookie : cookies) { + if (COOKIE_NAME.equals(cookie.getName())) { + return cookie.getValue(); + } + } + + return null; + } + + private Cookie createCookie(String guestKey) { + Cookie cookie = new Cookie(COOKIE_NAME, guestKey); + cookie.setHttpOnly(true); + cookie.setPath("/"); + cookie.setMaxAge(COOKIE_MAX_AGE_SECONDS); + return cookie; + } +} \ No newline at end of file From fdfff91d6b0f73e9d5ce4e0d1ccaa8341d8b3e24 Mon Sep 17 00:00:00 2001 From: kimgyurin Date: Thu, 27 Aug 2026 18:45:47 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=82=AC=EC=97=B0=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5=20DTO=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/story/dto/StoryDetailResDto.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/main/java/com/likelion/monday/domain/story/dto/StoryDetailResDto.java diff --git a/src/main/java/com/likelion/monday/domain/story/dto/StoryDetailResDto.java b/src/main/java/com/likelion/monday/domain/story/dto/StoryDetailResDto.java new file mode 100644 index 0000000..2460b90 --- /dev/null +++ b/src/main/java/com/likelion/monday/domain/story/dto/StoryDetailResDto.java @@ -0,0 +1,43 @@ +package com.likelion.monday.domain.story.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "사연 상세 조회 응답") +public record StoryDetailResDto( + + @Schema(description = "사연 ID", example = "1") + Long storyId, + + @Schema(description = "반려동물 이름", example = "머고") + String petName, + + @Schema(description = "반려동물 종류", example = "고양이") + String petType, + + @Schema(description = "반려동물 나이", example = "3") + Integer petAge, + + @Schema(description = "사연 제목", example = "우리 집 귀염둥이에게") + String title, + + @Schema(description = "사연 본문") + String content, + + @Schema(description = "작성자 닉네임", example = "매기") + String nickname, + + @Schema(description = "노출 순서대로 정렬된 사진 URL 목록") + List imageUrls, + + @Schema(description = "작성 일시") + LocalDateTime createdAt, + + @Schema(description = "조회수", example = "128") + int viewCount, + + @Schema(description = "공감수", example = "12") + int likeCount +) { +} \ No newline at end of file From ceb89171c03cd5d8e0c556558a57583d9c199d3e Mon Sep 17 00:00:00 2001 From: kimgyurin Date: Thu, 27 Aug 2026 18:48:10 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20StoryMapper=EC=97=90=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=A1=B0=ED=9A=8C=20=EB=A7=A4=ED=95=91=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monday/domain/story/mapper/StoryMapper.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/java/com/likelion/monday/domain/story/mapper/StoryMapper.java b/src/main/java/com/likelion/monday/domain/story/mapper/StoryMapper.java index 68d9887..e0c6e08 100644 --- a/src/main/java/com/likelion/monday/domain/story/mapper/StoryMapper.java +++ b/src/main/java/com/likelion/monday/domain/story/mapper/StoryMapper.java @@ -2,6 +2,7 @@ import com.likelion.monday.domain.story.dto.StoryCardResDto; import com.likelion.monday.domain.story.dto.StoryCreateReqDto; +import com.likelion.monday.domain.story.dto.StoryDetailResDto; import com.likelion.monday.domain.story.dto.StoryWriteResDto; import com.likelion.monday.domain.story.entity.Story; import java.util.List; @@ -41,4 +42,19 @@ public StoryCardResDto toCardResDto(Story story, String nickname, String thumbna story.getViewCount(), story.getLikeCount()); } + + public StoryDetailResDto toDetailResDto(Story story, String nickname, List imageUrls) { + return new StoryDetailResDto( + story.getId(), + story.getPetName(), + story.getPetType(), + story.getPetAge(), + story.getTitle(), + story.getContent(), + nickname, + imageUrls, + story.getCreatedAt(), + story.getViewCount(), + story.getLikeCount()); + } } From 1cfd92feca0c9c7e74af1c9d46b8863aaaed5855 Mon Sep 17 00:00:00 2001 From: kimgyurin Date: Thu, 27 Aug 2026 18:52:43 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20Story=20=EC=A1=B0=ED=9A=8C=EC=88=98?= =?UTF-8?q?=20=EC=A6=9D=EA=B0=80=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/likelion/monday/domain/story/entity/Story.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/likelion/monday/domain/story/entity/Story.java b/src/main/java/com/likelion/monday/domain/story/entity/Story.java index abb8f79..27babd0 100644 --- a/src/main/java/com/likelion/monday/domain/story/entity/Story.java +++ b/src/main/java/com/likelion/monday/domain/story/entity/Story.java @@ -120,4 +120,8 @@ public boolean isEditable() { public boolean isOwnedBy(Long accountId) { return this.accountId.equals(accountId); } + + public void increaseViewCount() { + this.viewCount++; + } } From dda1a6164dd1a6971e683d087b4d4c4587c18b25 Mon Sep 17 00:00:00 2001 From: kimgyurin Date: Sat, 29 Aug 2026 15:36:33 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=EC=82=AC=EC=97=B0=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/story/service/StoryService.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/main/java/com/likelion/monday/domain/story/service/StoryService.java b/src/main/java/com/likelion/monday/domain/story/service/StoryService.java index 9ecdf06..fd86bf6 100644 --- a/src/main/java/com/likelion/monday/domain/story/service/StoryService.java +++ b/src/main/java/com/likelion/monday/domain/story/service/StoryService.java @@ -6,16 +6,19 @@ import com.likelion.monday.domain.story.constant.StorySort; import com.likelion.monday.domain.story.dto.StoryCardResDto; import com.likelion.monday.domain.story.dto.StoryCreateReqDto; +import com.likelion.monday.domain.story.dto.StoryDetailResDto; import com.likelion.monday.domain.story.dto.StoryPageResDto; import com.likelion.monday.domain.story.dto.StoryUpdateReqDto; import com.likelion.monday.domain.story.dto.StoryWriteResDto; import com.likelion.monday.domain.story.entity.Story; import com.likelion.monday.domain.story.entity.StoryImage; import com.likelion.monday.domain.story.entity.StoryStatus; +import com.likelion.monday.domain.story.entity.StoryView; import com.likelion.monday.domain.story.exception.StoryErrorCode; import com.likelion.monday.domain.story.mapper.StoryMapper; import com.likelion.monday.domain.story.repository.StoryImageRepository; import com.likelion.monday.domain.story.repository.StoryRepository; +import com.likelion.monday.domain.story.repository.StoryViewRepository; import com.likelion.monday.global.exception.CustomException; import com.likelion.monday.global.storage.ImageStorage; import java.util.ArrayList; @@ -23,6 +26,7 @@ import java.util.Map; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -43,6 +47,7 @@ public class StoryService { private final StoryRepository storyRepository; private final StoryImageRepository storyImageRepository; + private final StoryViewRepository storyViewRepository; private final AccountRepository accountRepository; private final StoryMapper storyMapper; private final ImageStorage imageStorage; @@ -74,6 +79,26 @@ public StoryPageResDto getStories(StorySort sort, int page, int size) { stories.getTotalPages()); } + /** + * 공개(PUBLIC)된 사연 하나를 상세로 보여준다. + * 같은 게스트가 같은 사연을 다시 봐도 조회수가 중복으로 올라가지 않도록, 조회 기록이 없을 때만 기록하고 조회수를 올린다. + */ + public StoryDetailResDto getStory(Long storyId, String guestKey) { + Story story = storyRepository.findById(storyId) + .filter(s -> s.getStatus() == StoryStatus.PUBLIC) + .orElseThrow(() -> new CustomException(StoryErrorCode.STORY_NOT_FOUND)); + + recordView(story, guestKey); + + Account account = accountRepository.findById(story.getAccountId()) + .orElseThrow(() -> new CustomException(AccountErrorCode.ACCOUNT_NOT_FOUND)); + List imageUrls = storyImageRepository.findByStory_IdOrderBySortOrderAsc(storyId).stream() + .map(StoryImage::getImageUrl) + .toList(); + + return storyMapper.toDetailResDto(story, account.getNickname(), imageUrls); + } + /** * 사연을 등록한다. * 이메일은 계정 식별자이므로 처음 보는 이메일이면 계정을 만들고, 이미 있으면 그 계정에 사연을 하나 더 추가한다. @@ -222,6 +247,26 @@ private void deleteImages(List images) { images.forEach(image -> imageStorage.delete(image.getImageUrl())); } + /** + * 조회 기록이 없을 때만 저장하고 조회수를 올린다. + * exists 확인과 insert 사이에 동시 요청이 들어올 수 있으므로, 유니크 제약 위반도 함께 잡아서 무시한다. + */ + private void recordView(Story story, String guestKey) { + if (storyViewRepository.existsByStory_IdAndGuestKey(story.getId(), guestKey)) { + return; + } + + try { + storyViewRepository.save(StoryView.builder() + .story(story) + .guestKey(guestKey) + .build()); + story.increaseViewCount(); + } catch (DataIntegrityViolationException e) { + // 동시 요청으로 이미 기록된 경우, 조회수는 올리지 않고 그대로 둔다. + } + } + private Map findThumbnails(List stories) { List storyIds = stories.stream() .map(Story::getId) From 67bbd4a4d8e880d79881f038f117c66d6ac4be23 Mon Sep 17 00:00:00 2001 From: kimgyurin Date: Sat, 29 Aug 2026 16:40:48 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20StoryController=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=88=9C=EC=84=9C=EB=A5=BC=20StoryControl?= =?UTF-8?q?lerDocs=EC=99=80=20=EC=9D=BC=EC=B9=98=EC=8B=9C=ED=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/story/controller/StoryController.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/java/com/likelion/monday/domain/story/controller/StoryController.java b/src/main/java/com/likelion/monday/domain/story/controller/StoryController.java index 712ddd2..3924ea0 100644 --- a/src/main/java/com/likelion/monday/domain/story/controller/StoryController.java +++ b/src/main/java/com/likelion/monday/domain/story/controller/StoryController.java @@ -2,11 +2,15 @@ import com.likelion.monday.domain.story.constant.StorySort; import com.likelion.monday.domain.story.dto.StoryCreateReqDto; +import com.likelion.monday.domain.story.dto.StoryDetailResDto; import com.likelion.monday.domain.story.dto.StoryPageResDto; import com.likelion.monday.domain.story.dto.StoryUpdateReqDto; import com.likelion.monday.domain.story.dto.StoryWriteResDto; import com.likelion.monday.domain.story.service.StoryService; +import com.likelion.monday.global.cookie.GuestKeyProvider; import com.likelion.monday.global.response.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; @@ -27,6 +31,7 @@ public class StoryController implements StoryControllerDocs { private final StoryService storyService; + private final GuestKeyProvider guestKeyProvider; @Override @GetMapping @@ -37,6 +42,16 @@ public ApiResponse getStories( return ApiResponse.success(storyService.getStories(sort, page, size)); } + @Override + @GetMapping("/{storyId}") + public ApiResponse getStory( + @PathVariable Long storyId, + HttpServletRequest request, + HttpServletResponse response) { + String guestKey = guestKeyProvider.resolve(request, response); + return ApiResponse.success(storyService.getStory(storyId, guestKey)); + } + @Override @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseStatus(HttpStatus.CREATED) From 904cd66ab9cee39c9c3ff0681a18edb456739581 Mon Sep 17 00:00:00 2001 From: kimgyurin Date: Sat, 29 Aug 2026 16:41:43 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20=EC=82=AC=EC=97=B0=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../story/controller/StoryControllerDocs.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/com/likelion/monday/domain/story/controller/StoryControllerDocs.java b/src/main/java/com/likelion/monday/domain/story/controller/StoryControllerDocs.java index 0d6dc38..12c70da 100644 --- a/src/main/java/com/likelion/monday/domain/story/controller/StoryControllerDocs.java +++ b/src/main/java/com/likelion/monday/domain/story/controller/StoryControllerDocs.java @@ -2,12 +2,15 @@ import com.likelion.monday.domain.story.constant.StorySort; import com.likelion.monday.domain.story.dto.StoryCreateReqDto; +import com.likelion.monday.domain.story.dto.StoryDetailResDto; import com.likelion.monday.domain.story.dto.StoryPageResDto; import com.likelion.monday.domain.story.dto.StoryUpdateReqDto; import com.likelion.monday.domain.story.dto.StoryWriteResDto; import com.likelion.monday.global.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.util.List; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.multipart.MultipartFile; @@ -48,4 +51,14 @@ public interface StoryControllerDocs { """ ) ApiResponse getStories(StorySort sort, int page, int size); + + @Operation( + summary = "사연 상세 조회", + description = """ + 공개(PUBLIC)된 사연 하나를 상세로 조회한다. 본문과 전체 사진 목록을 포함한다. + 응답과 함께 게스트 식별용 쿠키(guest_key)가 발급되며, 이후 같은 브라우저로 다시 조회해도 조회수에 반영되지 않는다. + 검토중이거나 비공개인 사연은 404로 처리한다. + """ + ) + ApiResponse getStory(Long storyId, HttpServletRequest request, HttpServletResponse response); }