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) 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); } 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 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++; + } } 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/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()); + } } 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 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) 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