From 100264d2055044dd68c8b844726ab0beb79eb9ea Mon Sep 17 00:00:00 2001 From: young Date: Wed, 12 Aug 2026 19:45:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9=20?= =?UTF-8?q?=EC=98=81=EC=83=81=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ShareLinkController.java | 21 ++- .../sharelink/service/ShareLinkService.java | 28 +++- .../video/dto/response/VideoResponse.java | 34 +++++ .../domain/video/service/VideoService.java | 18 ++- .../slatto/global/config/SecurityConfig.java | 1 + .../service/GuestVideoDetailServiceTest.java | 144 ++++++++++++++++++ 6 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/slatto/domain/sharelink/service/GuestVideoDetailServiceTest.java diff --git a/src/main/java/com/slatto/domain/sharelink/controller/ShareLinkController.java b/src/main/java/com/slatto/domain/sharelink/controller/ShareLinkController.java index 7ee598e..b5fbc1d 100644 --- a/src/main/java/com/slatto/domain/sharelink/controller/ShareLinkController.java +++ b/src/main/java/com/slatto/domain/sharelink/controller/ShareLinkController.java @@ -7,6 +7,7 @@ import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.GuestCreateResDTO; import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.ShareLinkInfoResDTO; import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.ShareLinkToggleResDTO; +import com.slatto.domain.video.dto.response.VideoResponse.GuestVideoDetailResDTO; import com.slatto.global.config.ApiErrorCodes; import org.springframework.security.core.annotation.AuthenticationPrincipal; import com.slatto.domain.sharelink.service.ShareLinkService; @@ -92,6 +93,24 @@ public ApiResponse registerGuest( ); } + @Operation( + summary = "게스트 영상 상세 조회", + description = "공유 링크를 통해 등록된 게스트가 영상과 프로젝트 정보를 읽기 전용으로 조회합니다." + ) + @SecurityRequirements + @ApiErrorCodes({"SHARELINK404", "SHARELINK410", "SHARELINK403", "COMMON404"}) + @GetMapping("/share-links/{shareToken}/video") + public ApiResponse getGuestVideo( + @PathVariable String shareToken, + @RequestHeader("X-Guest-Id") Long guestId, + @RequestHeader("X-Guest-Token") String guestToken + ) { + return ApiResponse.success( + CommonSuccessCode.OK, + shareLinkService.getGuestVideo(shareToken, guestId, guestToken) + ); + } + @Operation(summary = "공유 링크 조회 (소유자용)", description = "영상의 공유 링크를 조회합니다. 프로젝트 멤버만 가능합니다.") @ApiErrorCodes({"PROJECT403", "SHARELINK404"}) @GetMapping("/videos/{videoId}/share-links") @@ -119,4 +138,4 @@ public ApiResponse toggleShareLink( ); } -} \ No newline at end of file +} diff --git a/src/main/java/com/slatto/domain/sharelink/service/ShareLinkService.java b/src/main/java/com/slatto/domain/sharelink/service/ShareLinkService.java index 0826219..38eaaac 100644 --- a/src/main/java/com/slatto/domain/sharelink/service/ShareLinkService.java +++ b/src/main/java/com/slatto/domain/sharelink/service/ShareLinkService.java @@ -16,6 +16,8 @@ import com.slatto.domain.sharelink.exception.ShareLinkErrorCode; import com.slatto.domain.sharelink.repository.ShareLinkRepository; import com.slatto.domain.video.entity.Video; +import com.slatto.domain.video.dto.response.VideoResponse.GuestVideoDetailResDTO; +import com.slatto.domain.video.service.VideoService; import com.slatto.global.exception.BaseException; import com.slatto.global.response.code.CommonErrorCode; import com.slatto.global.util.TokenHasher; @@ -38,6 +40,7 @@ public class ShareLinkService { private final ObjectProvider entityManagerProvider; private final GuestRepository guestRepository; private final TokenHasher tokenHasher; + private final VideoService videoService; @Transactional public ShareLinkCreateResDTO createShareLink(Long videoId, Long userId, ShareLinkCreateReqDTO req) { @@ -116,6 +119,29 @@ public GuestCreateResDTO registerGuest(String token, GuestCreateReqDTO req) { return shareLinkConverter.toGuestCreateResponse(saved, rawSessionToken); } + @Transactional(readOnly = true) + public GuestVideoDetailResDTO getGuestVideo(String shareToken, Long guestId, String guestToken) { + ShareLink shareLink = shareLinkRepository.findByToken(shareToken) + .orElseThrow(() -> new BaseException(ShareLinkErrorCode.SHARE_LINK_NOT_FOUND)); + + if (!shareLink.isUsable()) { + throw new BaseException(ShareLinkErrorCode.SHARE_LINK_UNAVAILABLE); + } + + Guest guest = guestRepository.findById(guestId) + .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); + + if (guestToken == null || !guest.getSessionToken().equals(tokenHasher.hash(guestToken))) { + throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED); + } + + if (!guest.getShareLink().getId().equals(shareLink.getId())) { + throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED); + } + + return videoService.getGuestVideoDetail(shareLink.getVideo()); + } + @Transactional(readOnly = true) public ShareLinkInfoResDTO getShareLinkByVideo(Long videoId, Long userId) { @@ -167,4 +193,4 @@ public ShareLinkToggleResDTO toggleShareLink(Long shareLinkId, Long userId) { return shareLinkConverter.toToggleResponse(shareLink); } -} \ No newline at end of file +} diff --git a/src/main/java/com/slatto/domain/video/dto/response/VideoResponse.java b/src/main/java/com/slatto/domain/video/dto/response/VideoResponse.java index 62775f9..0a1e486 100644 --- a/src/main/java/com/slatto/domain/video/dto/response/VideoResponse.java +++ b/src/main/java/com/slatto/domain/video/dto/response/VideoResponse.java @@ -52,6 +52,40 @@ public static VideoDetailResDTO from( } } + @Schema(description = "게스트 영상 상세 조회 응답") + public record GuestVideoDetailResDTO( + @Schema(description = "영상 ID", example = "1") Long videoId, + @Schema(description = "영상 제목", example = "버전명") String title, + @Schema(description = "YouTube 영상 URL", example = "https://www.youtube.com/watch?v=abc123") + String youtubeUrl, + @Schema(description = "YouTube 영상 ID", example = "abc123") String youtubeVideoId, + @Schema(description = "영상 썸네일 URL", example = "https://img.youtube.com/vi/abc123/maxresdefault.jpg") + String thumbnailUrl, + @Schema(description = "영상 진행 상태", example = "IN_PROGRESS") String progressStatus, + @Schema(description = "프로젝트 소개", example = "프로젝트 소개글") String description, + @Schema(description = "영상 메모", example = "영상에 관련된 메모", nullable = true) String memo, + @Schema(description = "프로젝트 태그", example = "[\"뮤직비디오\", \"단편\", \"외주\", \"연출\"]") + List projectTags, + @Schema(description = "생성일", example = "2026-05-20T00:00:00") LocalDateTime createdAt, + @Schema(description = "수정일", example = "2026-05-25T00:00:00") LocalDateTime updatedAt + ) { + public static GuestVideoDetailResDTO from(VideoDetailResDTO detail) { + return new GuestVideoDetailResDTO( + detail.videoId(), + detail.title(), + detail.youtubeUrl(), + detail.youtubeVideoId(), + detail.thumbnailUrl(), + detail.progressStatus(), + detail.description(), + detail.memo(), + detail.projectTags(), + detail.createdAt(), + detail.updatedAt() + ); + } + } + @Schema(description = "영상 북마크 상태 변경 응답") public record VideoBookmarkUpdateResDTO( @Schema(description = "영상 ID", example = "1") Long videoId, diff --git a/src/main/java/com/slatto/domain/video/service/VideoService.java b/src/main/java/com/slatto/domain/video/service/VideoService.java index e16cbde..d22d27c 100644 --- a/src/main/java/com/slatto/domain/video/service/VideoService.java +++ b/src/main/java/com/slatto/domain/video/service/VideoService.java @@ -17,6 +17,7 @@ import com.slatto.domain.video.dto.response.VideoResponse.VideoCreateResDTO; import com.slatto.domain.video.dto.response.VideoResponse.VideoDeleteResDTO; import com.slatto.domain.video.dto.response.VideoResponse.VideoDetailResDTO; +import com.slatto.domain.video.dto.response.VideoResponse.GuestVideoDetailResDTO; import com.slatto.domain.video.dto.response.VideoResponse.VideoItemResDTO; import com.slatto.domain.video.dto.response.VideoResponse.VideoListResDTO; import com.slatto.domain.video.dto.response.VideoResponse.VideoUpdateResDTO; @@ -73,13 +74,22 @@ public VideoDetailResDTO getVideo(Long memberId, Long projectId, Long videoId) { Video video = videoRepository.findByIdAndProjectId(videoId, projectId) .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); boolean bookmarked = videoBookmarkRepository.findByVideoIdAndUserId(videoId, memberId).isPresent(); - List projectTags = resolveProjectTags( - video.getProject(), - projectAccessRepository.findProjectRoleNames(projectId) - ); + VideoDetailResDTO response = createVideoDetailResponse(video, bookmarked); notificationService.markVideoFeedbackNotificationsAsRead(memberId, videoId); + return response; + } + + public GuestVideoDetailResDTO getGuestVideoDetail(Video video) { + return GuestVideoDetailResDTO.from(createVideoDetailResponse(video, false)); + } + + private VideoDetailResDTO createVideoDetailResponse(Video video, boolean bookmarked) { + List projectTags = resolveProjectTags( + video.getProject(), + projectAccessRepository.findProjectRoleNames(video.getProject().getId()) + ); return VideoDetailResDTO.from(video, bookmarked, projectTags); } diff --git a/src/main/java/com/slatto/global/config/SecurityConfig.java b/src/main/java/com/slatto/global/config/SecurityConfig.java index 00d7fde..4138778 100644 --- a/src/main/java/com/slatto/global/config/SecurityConfig.java +++ b/src/main/java/com/slatto/global/config/SecurityConfig.java @@ -56,6 +56,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ).permitAll() // 게스트 등록 .requestMatchers(HttpMethod.POST, "/api/v1/share-links/*/guests").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/share-links/*/video").permitAll() // 게스트 피드백/답글 참여 (조회 포함 — 게스트 소유 검증은 서비스단에서 처리) .requestMatchers(HttpMethod.POST, "/api/v1/videos/*/feedbacks").permitAll() diff --git a/src/test/java/com/slatto/domain/sharelink/service/GuestVideoDetailServiceTest.java b/src/test/java/com/slatto/domain/sharelink/service/GuestVideoDetailServiceTest.java new file mode 100644 index 0000000..652286c --- /dev/null +++ b/src/test/java/com/slatto/domain/sharelink/service/GuestVideoDetailServiceTest.java @@ -0,0 +1,144 @@ +package com.slatto.domain.sharelink.service; + +import com.slatto.domain.project.repository.ProjectMemberRepository; +import com.slatto.domain.sharelink.converter.ShareLinkConverter; +import com.slatto.domain.sharelink.entity.Guest; +import com.slatto.domain.sharelink.entity.ShareLink; +import com.slatto.domain.sharelink.exception.ShareLinkErrorCode; +import com.slatto.domain.sharelink.repository.GuestRepository; +import com.slatto.domain.sharelink.repository.ShareLinkRepository; +import com.slatto.domain.video.dto.response.VideoResponse.GuestVideoDetailResDTO; +import com.slatto.domain.video.entity.Video; +import com.slatto.domain.video.service.VideoService; +import com.slatto.global.exception.BaseException; +import com.slatto.global.response.code.CommonErrorCode; +import com.slatto.global.util.TokenHasher; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class GuestVideoDetailServiceTest { + + private static final String SHARE_TOKEN = "share-token"; + private static final String GUEST_TOKEN = "guest-token"; + private static final Long GUEST_ID = 20L; + + @Mock private ShareLinkRepository shareLinkRepository; + @Mock private ShareLinkConverter shareLinkConverter; + @Mock private ProjectMemberRepository projectMemberRepository; + @Mock private ObjectProvider entityManagerProvider; + @Mock private GuestRepository guestRepository; + @Mock private VideoService videoService; + @Mock private ShareLink shareLink; + @Mock private ShareLink anotherShareLink; + @Mock private Guest guest; + @Mock private Video video; + + private final TokenHasher tokenHasher = new TokenHasher(); + private ShareLinkService shareLinkService; + + @BeforeEach + void setUp() { + shareLinkService = new ShareLinkService( + shareLinkRepository, + shareLinkConverter, + projectMemberRepository, + entityManagerProvider, + guestRepository, + tokenHasher, + videoService + ); + } + + @Test + void returnsGuestVideoDetailAfterValidatingLinkAndGuest() { + GuestVideoDetailResDTO expected = new GuestVideoDetailResDTO( + 1L, "1차 편집본", "https://youtube.example/video", "video-id", "thumbnail", + "IN_PROGRESS", "프로젝트 소개", "메모", List.of("광고"), null, null + ); + givenValidAccess(); + given(shareLink.getVideo()).willReturn(video); + given(videoService.getGuestVideoDetail(video)).willReturn(expected); + + GuestVideoDetailResDTO result = shareLinkService.getGuestVideo(SHARE_TOKEN, GUEST_ID, GUEST_TOKEN); + + assertThat(result).isSameAs(expected); + verify(videoService).getGuestVideoDetail(video); + } + + @Test + void rejectsUnknownShareLink() { + given(shareLinkRepository.findByToken(SHARE_TOKEN)).willReturn(Optional.empty()); + + assertError(ShareLinkErrorCode.SHARE_LINK_NOT_FOUND, SHARE_TOKEN, GUEST_ID, GUEST_TOKEN); + verify(guestRepository, never()).findById(GUEST_ID); + } + + @Test + void rejectsUnavailableShareLink() { + given(shareLinkRepository.findByToken(SHARE_TOKEN)).willReturn(Optional.of(shareLink)); + given(shareLink.isUsable()).willReturn(false); + + assertError(ShareLinkErrorCode.SHARE_LINK_UNAVAILABLE, SHARE_TOKEN, GUEST_ID, GUEST_TOKEN); + verify(guestRepository, never()).findById(GUEST_ID); + } + + @Test + void rejectsUnknownGuest() { + given(shareLinkRepository.findByToken(SHARE_TOKEN)).willReturn(Optional.of(shareLink)); + given(shareLink.isUsable()).willReturn(true); + given(guestRepository.findById(GUEST_ID)).willReturn(Optional.empty()); + + assertError(CommonErrorCode.NOT_FOUND, SHARE_TOKEN, GUEST_ID, GUEST_TOKEN); + } + + @Test + void rejectsInvalidGuestToken() { + given(shareLinkRepository.findByToken(SHARE_TOKEN)).willReturn(Optional.of(shareLink)); + given(shareLink.isUsable()).willReturn(true); + given(guestRepository.findById(GUEST_ID)).willReturn(Optional.of(guest)); + given(guest.getSessionToken()).willReturn(tokenHasher.hash(GUEST_TOKEN)); + + assertError(ShareLinkErrorCode.GUEST_ACCESS_DENIED, SHARE_TOKEN, GUEST_ID, "wrong-token"); + verify(videoService, never()).getGuestVideoDetail(video); + } + + @Test + void rejectsGuestRegisteredThroughAnotherShareLink() { + givenValidAccess(); + given(anotherShareLink.getId()).willReturn(2L); + given(guest.getShareLink()).willReturn(anotherShareLink); + + assertError(ShareLinkErrorCode.GUEST_ACCESS_DENIED, SHARE_TOKEN, GUEST_ID, GUEST_TOKEN); + verify(videoService, never()).getGuestVideoDetail(video); + } + + private void givenValidAccess() { + given(shareLinkRepository.findByToken(SHARE_TOKEN)).willReturn(Optional.of(shareLink)); + given(shareLink.isUsable()).willReturn(true); + given(shareLink.getId()).willReturn(1L); + given(guestRepository.findById(GUEST_ID)).willReturn(Optional.of(guest)); + given(guest.getSessionToken()).willReturn(tokenHasher.hash(GUEST_TOKEN)); + given(guest.getShareLink()).willReturn(shareLink); + } + + private void assertError(Object expectedCode, String shareToken, Long guestId, String guestToken) { + assertThatThrownBy(() -> shareLinkService.getGuestVideo(shareToken, guestId, guestToken)) + .isInstanceOfSatisfying(BaseException.class, + exception -> assertThat(exception.getErrorCode()).isSameAs(expectedCode)); + } +}