diff --git a/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java b/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java index e0c036a5..595f50a8 100644 --- a/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java +++ b/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java @@ -32,10 +32,10 @@ public class FeedbackController { public ResponseEntity> createFeedback( @PathVariable Long videoId, @AuthenticationPrincipal Long userId, + @RequestHeader(value = "X-Guest-Token", required = false) String guestToken, @Valid @RequestBody FeedbackCreateReqDTO request ) { - FeedbackCreateResDTO result = feedbackService.createFeedback(videoId, userId, request); - + FeedbackCreateResDTO result = feedbackService.createFeedback(videoId, userId, guestToken, request); return ResponseEntity .status(HttpStatus.CREATED) .body(ApiResponse.success(CommonSuccessCode.CREATED, result)); @@ -46,10 +46,10 @@ public ResponseEntity> createFeedback( public ResponseEntity> updateFeedback( @PathVariable Long feedbackId, @AuthenticationPrincipal Long userId, + @RequestHeader(value = "X-Guest-Token", required = false) String guestToken, @Valid @RequestBody FeedbackUpdateReqDTO request ) { - FeedbackUpdateResDTO result = feedbackService.updateFeedback(feedbackId, userId, request); - + FeedbackUpdateResDTO result = feedbackService.updateFeedback(feedbackId, userId, guestToken, request); return ResponseEntity .ok(ApiResponse.success(CommonSuccessCode.OK, result)); } @@ -59,10 +59,10 @@ public ResponseEntity> updateFeedback( public ResponseEntity> deleteFeedback( @PathVariable Long feedbackId, @AuthenticationPrincipal Long userId, - @RequestParam(required = false) Long guestId + @RequestParam(required = false) Long guestId, + @RequestHeader(value = "X-Guest-Token", required = false) String guestToken ) { - feedbackService.deleteFeedback(feedbackId, userId, guestId); - + feedbackService.deleteFeedback(feedbackId, userId, guestId, guestToken); return ResponseEntity .ok(ApiResponse.success(CommonSuccessCode.OK, null)); } @@ -73,11 +73,11 @@ public ResponseEntity> getFeedbackList( @PathVariable Long videoId, @AuthenticationPrincipal Long userId, @RequestParam(required = false) Long guestId, + @RequestHeader(value = "X-Guest-Token", required = false) String guestToken, @RequestParam(required = false) String cursor, @RequestParam(required = false) Integer size ) { - FeedbackListResDTO result = feedbackService.getFeedbackList(videoId, userId, guestId, cursor, size); - + FeedbackListResDTO result = feedbackService.getFeedbackList(videoId, userId, guestId, guestToken, cursor, size); return ResponseEntity .ok(ApiResponse.success(CommonSuccessCode.OK, result)); } diff --git a/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java b/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java index 278620ef..a8dd2df5 100644 --- a/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java +++ b/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java @@ -32,9 +32,10 @@ public class FeedbackDetailController { public ResponseEntity> createReply( @PathVariable Long feedbackId, @AuthenticationPrincipal Long userId, + @RequestHeader(value = "X-Guest-Token", required = false) String guestToken, @Valid @RequestBody ReplyCreateReqDTO request ) { - ReplyCreateResDTO result = feedbackDetailService.createReply(feedbackId, userId, request); + ReplyCreateResDTO result = feedbackDetailService.createReply(feedbackId, userId, guestToken, request); return ResponseEntity .status(HttpStatus.CREATED) @@ -47,10 +48,11 @@ public ResponseEntity> getReplyList( @PathVariable Long feedbackId, @AuthenticationPrincipal Long userId, @RequestParam(required = false) Long guestId, + @RequestHeader(value = "X-Guest-Token", required = false) String guestToken, @RequestParam(required = false) Long cursor, @RequestParam(required = false) Integer size ) { - ReplyListResDTO result = feedbackDetailService.getReplyList(feedbackId, userId, guestId, cursor, size); + ReplyListResDTO result = feedbackDetailService.getReplyList(feedbackId, userId, guestId, guestToken, cursor, size); return ResponseEntity .ok(ApiResponse.success(CommonSuccessCode.OK, result)); @@ -61,9 +63,10 @@ public ResponseEntity> getReplyList( public ResponseEntity> updateReply( @PathVariable Long replyId, @AuthenticationPrincipal Long userId, + @RequestHeader(value = "X-Guest-Token", required = false) String guestToken, @Valid @RequestBody ReplyUpdateReqDTO request ) { - ReplyUpdateResDTO result = feedbackDetailService.updateReply(replyId, userId, request); + ReplyUpdateResDTO result = feedbackDetailService.updateReply(replyId, userId, guestToken, request); return ResponseEntity .ok(ApiResponse.success(CommonSuccessCode.OK, result)); @@ -74,9 +77,10 @@ public ResponseEntity> updateReply( public ResponseEntity> deleteReply( @PathVariable Long replyId, @AuthenticationPrincipal Long userId, - @RequestParam(required = false) Long guestId + @RequestParam(required = false) Long guestId, + @RequestHeader(value = "X-Guest-Token", required = false) String guestToken ) { - feedbackDetailService.deleteReply(replyId, userId, guestId); + feedbackDetailService.deleteReply(replyId, userId, guestId, guestToken); return ResponseEntity .ok(ApiResponse.success(CommonSuccessCode.OK, null)); diff --git a/src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java b/src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java index 0aae1552..749158a6 100644 --- a/src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java +++ b/src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java @@ -30,6 +30,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.slatto.global.util.TokenHasher; @Service @RequiredArgsConstructor @@ -43,12 +44,13 @@ public class FeedbackDetailService { private final ProjectMemberRepository projectMemberRepository; private final NotificationService notificationService; private final ActivityLogService activityLogService; + private final TokenHasher tokenHasher; private static final int DEFAULT_PAGE_SIZE = 10; - private static final int MAX_PAGE_SIZE = 50; // 페이지 크기 상한 + private static final int MAX_PAGE_SIZE = 50; @Transactional - public ReplyCreateResDTO createReply(Long feedbackId, Long userId, ReplyCreateReqDTO req) { + public ReplyCreateResDTO createReply(Long feedbackId, Long userId, String guestToken, ReplyCreateReqDTO req) { // 1. 원 피드백 조회 (삭제된 건 제외) Feedback feedback = feedbackRepository.findById(feedbackId) @@ -68,8 +70,8 @@ public ReplyCreateResDTO createReply(Long feedbackId, Long userId, ReplyCreateRe // 회원이 원 피드백 영상의 프로젝트 멤버인지 검증 validateMemberAccess(userId, feedback.getVideo().getProject().getId()); } else { - // 게스트: 원 피드백의 영상에 접근할 자격이 있는지 검증 후 Guest 확보 - guest = validateGuestAccess(req.guestId(), feedback.getVideo().getId()); + // 게스트: 토큰 + 원 피드백 영상 소유 검증 후 Guest 확보 + guest = validateGuestAccess(req.guestId(), feedback.getVideo().getId(), guestToken); } // 4. 저장 @@ -77,7 +79,6 @@ public ReplyCreateResDTO createReply(Long feedbackId, Long userId, ReplyCreateRe FeedbackDetail saved = feedbackDetailRepository.save(reply); // 5. 프로젝트 멤버에게 답글 알림 발송 (작성자 본인은 actorUserId로 제외) - // 알림 문구 조합용 작성자명 — 회원이면 유저명, 게스트면 게스트명 String commenterName = (user != null) ? user.getNickname() : guest.getName(); sendReplyNotification(feedback.getVideo(), userId, commenterName); @@ -102,12 +103,9 @@ public ReplyCreateResDTO createReply(Long feedbackId, Long userId, ReplyCreateRe } // 답글 생성 시 프로젝트 멤버에게 알림 발송 - // 문구 조합/저장/그룹핑/작성자 제외는 알림 도메인이 처리하므로 재료(영상명·작성자명)만 준비해 호출한다. - // 원 피드백의 영상 기준으로 그룹핑되므로 targetId는 videoId가 사용된다. private void sendReplyNotification(Video video, Long actorUserId, String commenterName) { Long projectId = video.getProject().getId(); - // 프로젝트 활성 멤버 전체를 수신자로 (작성자 제외는 actorUserId로 알림 도메인이 처리) List recipientIds = projectMemberRepository .findAllActiveMembersByProjectId(projectId) .stream() @@ -117,10 +115,10 @@ private void sendReplyNotification(Video video, Long actorUserId, String comment notificationService.createVideoFeedbackCommentedNotifications( projectId, video.getId(), - video.getTitle(), // 영상명 → 알림 도메인이 문구 조합에 사용 - commenterName, // 작성자명 → 알림 도메인이 문구 조합에 사용 + video.getTitle(), + commenterName, recipientIds, - actorUserId // 게스트면 null → 제외 대상 없음 + actorUserId ); } @@ -133,7 +131,6 @@ private void validateWriter(Long userId, Long guestId) { } // 회원이 해당 프로젝트의 활성 멤버인지 검증 - // 게스트의 validateGuestAccess와 대칭 — 회원은 프로젝트 멤버 자격으로 접근 인가 private void validateMemberAccess(Long userId, Long projectId) { boolean isMember = projectMemberRepository .existsByProjectIdAndUserIdAndLeftAtIsNull(projectId, userId); @@ -143,11 +140,16 @@ private void validateMemberAccess(Long userId, Long projectId) { } // 게스트가 해당 영상에 접근할 자격이 있는지 검증하고, 검증된 Guest를 반환 - // Guest → ShareLink → Video 체인으로 소유 여부 확인 - private Guest validateGuestAccess(Long guestId, Long videoId) { + // 세션 토큰으로 본인 확인 + Guest → ShareLink → Video 소유 확인 + private Guest validateGuestAccess(Long guestId, Long videoId, String guestToken) { Guest guest = guestRepository.findById(guestId) .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); + // 0. 세션 토큰으로 본인 확인 — 없거나 불일치면 사칭으로 간주해 차단 + if (guestToken == null || !guest.getSessionToken().equals(tokenHasher.hash(guestToken))) { + throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED); + } + ShareLink shareLink = guest.getShareLink(); // 1. 링크가 살아있는지 (활성 + 미만료) @@ -164,14 +166,14 @@ private Guest validateGuestAccess(Long guestId, Long videoId) { } @Transactional(readOnly = true) - public ReplyListResDTO getReplyList(Long feedbackId, Long userId, Long guestId, Long cursor, Integer size) { + public ReplyListResDTO getReplyList(Long feedbackId, Long userId, Long guestId, String guestToken, Long cursor, Integer size) { // 1. 원 피드백 존재 확인 Feedback feedback = feedbackRepository.findById(feedbackId) .filter(f -> f.getDeletedAt() == null) .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); - // 2. 접근 검증 — 회원은 프로젝트 멤버, 게스트는 공유링크 소유 + // 2. 접근 검증 — 회원은 프로젝트 멤버, 게스트는 토큰 + 공유링크 소유 // 회원도 게스트도 아니면(둘 다 null) 익명 조회 차단 if (userId != null) { validateMemberAccess(userId, feedback.getVideo().getProject().getId()); @@ -179,14 +181,14 @@ public ReplyListResDTO getReplyList(Long feedbackId, Long userId, Long guestId, if (guestId == null) { throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED); } - validateGuestAccess(guestId, feedback.getVideo().getId()); + validateGuestAccess(guestId, feedback.getVideo().getId(), guestToken); } // 3. size 기본값 + 상한 처리 int pageSize = (size == null || size <= 0) ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); - Pageable pageable = PageRequest.of(0, pageSize + 1); // hasNext 판단용 +1 + Pageable pageable = PageRequest.of(0, pageSize + 1); // 4. 조회 List replies = (cursor == null) @@ -208,7 +210,7 @@ public ReplyListResDTO getReplyList(Long feedbackId, Long userId, Long guestId, } @Transactional - public ReplyUpdateResDTO updateReply(Long replyId, Long userId, ReplyUpdateReqDTO req) { + public ReplyUpdateResDTO updateReply(Long replyId, Long userId, String guestToken, ReplyUpdateReqDTO req) { // 1. 답글 조회 (삭제된 건 제외) FeedbackDetail reply = feedbackDetailRepository.findById(replyId) @@ -218,11 +220,11 @@ public ReplyUpdateResDTO updateReply(Long replyId, Long userId, ReplyUpdateReqDT // 2. 작성자 검증 validateWriter(userId, req.guestId()); - // 3. 접근 검증 — 회원은 프로젝트 멤버, 게스트는 공유링크 소유 (답글 → 피드백 → 영상) + // 3. 접근 검증 — 회원은 프로젝트 멤버, 게스트는 토큰 + 공유링크 소유 (답글 → 피드백 → 영상) if (userId != null) { validateMemberAccess(userId, reply.getFeedback().getVideo().getProject().getId()); } else { - validateGuestAccess(req.guestId(), reply.getFeedback().getVideo().getId()); + validateGuestAccess(req.guestId(), reply.getFeedback().getVideo().getId(), guestToken); } // 4. 본인 확인 @@ -230,7 +232,7 @@ public ReplyUpdateResDTO updateReply(Long replyId, Long userId, ReplyUpdateReqDT throw new BaseException(CommonErrorCode.FORBIDDEN); } - // 5. 수정 (더티 체킹) + // 5. 수정 reply.update(req.content()); // 6. updatedAt 갱신 반영 @@ -240,7 +242,7 @@ public ReplyUpdateResDTO updateReply(Long replyId, Long userId, ReplyUpdateReqDT } @Transactional - public void deleteReply(Long replyId, Long userId, Long guestId) { + public void deleteReply(Long replyId, Long userId, Long guestId, String guestToken) { // 1. 답글 조회 (이미 삭제된 건 제외) FeedbackDetail reply = feedbackDetailRepository.findById(replyId) @@ -250,11 +252,11 @@ public void deleteReply(Long replyId, Long userId, Long guestId) { // 2. 작성자 검증 validateWriter(userId, guestId); - // 3. 접근 검증 — 회원은 프로젝트 멤버, 게스트는 공유링크 소유 (답글 → 피드백 → 영상) + // 3. 접근 검증 — 회원은 프로젝트 멤버, 게스트는 토큰 + 공유링크 소유 (답글 → 피드백 → 영상) if (userId != null) { validateMemberAccess(userId, reply.getFeedback().getVideo().getProject().getId()); } else { - validateGuestAccess(guestId, reply.getFeedback().getVideo().getId()); + validateGuestAccess(guestId, reply.getFeedback().getVideo().getId(), guestToken); } // 4. 본인 확인 @@ -262,7 +264,7 @@ public void deleteReply(Long replyId, Long userId, Long guestId) { throw new BaseException(CommonErrorCode.FORBIDDEN); } - // 5. soft delete (더티 체킹) + // 5. soft delete reply.softDelete(); } @@ -284,7 +286,7 @@ public ReplyStatusResDTO changeReplyStatus(Long replyId, Long userId, ReplyStatu throw new BaseException(CommonErrorCode.FORBIDDEN); } - // 3. 상태 변경 (더티 체킹) + // 3. 상태 변경 reply.changeStatus(req.status()); // 4. updatedAt 갱신 반영 diff --git a/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java b/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java index b9bc572e..8d08ac55 100644 --- a/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java +++ b/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java @@ -14,6 +14,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import com.slatto.domain.notification.service.NotificationService; +import com.slatto.global.util.TokenHasher; import java.util.HashMap; import java.util.List; @@ -49,12 +50,13 @@ public class FeedbackService { private final FeedbackDetailRepository feedbackDetailRepository; private final NotificationService notificationService; private final ActivityLogService activityLogService; + private final TokenHasher tokenHasher; private static final int DEFAULT_PAGE_SIZE = 10; - private static final int MAX_PAGE_SIZE = 50; // 페이지 크기 상한 + private static final int MAX_PAGE_SIZE = 50; @Transactional - public FeedbackCreateResDTO createFeedback(Long videoId, Long userId, FeedbackCreateReqDTO req) { + public FeedbackCreateResDTO createFeedback(Long videoId, Long userId, String guestToken, FeedbackCreateReqDTO req) { // 1. 영상 조회 Video video = entityManagerProvider.getObject().createQuery(""" @@ -78,8 +80,8 @@ public FeedbackCreateResDTO createFeedback(Long videoId, Long userId, FeedbackCr // 회원이 이 영상의 프로젝트 멤버인지 검증 validateMemberAccess(userId, video.getProject().getId()); } else { - // 게스트: 이 영상에 접근할 자격이 있는지 검증 후 Guest 확보 - guest = validateGuestAccess(req.guestId(), videoId); + // 게스트: 토큰 + 소유 검증 후 Guest 확보 + guest = validateGuestAccess(req.guestId(), videoId, guestToken); } // 4. 저장 @@ -87,7 +89,6 @@ public FeedbackCreateResDTO createFeedback(Long videoId, Long userId, FeedbackCr Feedback saved = feedbackRepository.save(feedback); // 5. 프로젝트 멤버에게 피드백 알림 발송 (작성자 본인은 actorUserId로 제외) - // 알림 문구 조합용 작성자명 — 회원이면 유저명, 게스트면 게스트명 String commenterName = (user != null) ? user.getNickname() : guest.getName(); sendFeedbackNotification(video, userId, commenterName); @@ -112,11 +113,9 @@ public FeedbackCreateResDTO createFeedback(Long videoId, Long userId, FeedbackCr } // 피드백/답글 생성 시 프로젝트 멤버에게 알림을 보낸다. - // 문구 조합/저장/그룹핑/작성자 제외는 알림 도메인이 처리하므로 재료(영상명·작성자명)만 준비해 호출한다. private void sendFeedbackNotification(Video video, Long actorUserId, String commenterName) { Long projectId = video.getProject().getId(); - // 프로젝트 활성 멤버 전체를 수신자로 (작성자 제외는 actorUserId로 알림 도메인이 처리) List recipientIds = projectMemberRepository .findAllActiveMembersByProjectId(projectId) .stream() @@ -126,15 +125,15 @@ private void sendFeedbackNotification(Video video, Long actorUserId, String comm notificationService.createVideoFeedbackCommentedNotifications( projectId, video.getId(), - video.getTitle(), // 영상명 → 알림 도메인이 문구 조합에 사용 - commenterName, // 작성자명 → 알림 도메인이 문구 조합에 사용 + video.getTitle(), + commenterName, recipientIds, - actorUserId // 게스트면 null → 제외 대상 없음 + actorUserId ); } @Transactional - public FeedbackUpdateResDTO updateFeedback(Long feedbackId, Long userId, FeedbackUpdateReqDTO req) { + public FeedbackUpdateResDTO updateFeedback(Long feedbackId, Long userId, String guestToken, FeedbackUpdateReqDTO req) { // 1. 피드백 조회 (삭제된 건 제외) Feedback feedback = feedbackRepository.findById(feedbackId) @@ -144,11 +143,11 @@ public FeedbackUpdateResDTO updateFeedback(Long feedbackId, Long userId, Feedbac // 2. 작성자 검증 validateWriter(userId, req.guestId()); - // 3. 접근 검증 — 회원은 프로젝트 멤버, 게스트는 공유링크 소유 + // 3. 접근 검증 — 회원은 프로젝트 멤버, 게스트는 토큰 + 공유링크 소유 if (userId != null) { validateMemberAccess(userId, feedback.getVideo().getProject().getId()); } else { - validateGuestAccess(req.guestId(), feedback.getVideo().getId()); + validateGuestAccess(req.guestId(), feedback.getVideo().getId(), guestToken); } // 4. 본인 확인 @@ -156,10 +155,10 @@ public FeedbackUpdateResDTO updateFeedback(Long feedbackId, Long userId, Feedbac throw new BaseException(CommonErrorCode.FORBIDDEN); } - // 5. 수정 (status 전달 안 함 — 해결 상태는 전용 API에서만 변경) + // 5. 수정 feedback.update(req.content(), req.startTime(), req.endTime()); - // 6. updatedAt 갱신을 응답에 반영하기 위해 flush + // 6. updatedAt 갱신 반영 feedbackRepository.flush(); return feedbackConverter.toUpdateResponse(feedback); @@ -169,13 +168,12 @@ public FeedbackUpdateResDTO updateFeedback(Long feedbackId, Long userId, Feedbac private void validateWriter(Long userId, Long guestId) { boolean hasUser = (userId != null); boolean hasGuest = (guestId != null); - if (hasUser == hasGuest) { // 둘 다 있거나 둘 다 없으면 + if (hasUser == hasGuest) { throw new BaseException(CommonErrorCode.BAD_REQUEST); } } // 회원이 해당 프로젝트의 활성 멤버인지 검증 - // 게스트의 validateGuestAccess와 대칭 — 회원은 프로젝트 멤버 자격으로 접근 인가 private void validateMemberAccess(Long userId, Long projectId) { boolean isMember = projectMemberRepository .existsByProjectIdAndUserIdAndLeftAtIsNull(projectId, userId); @@ -185,11 +183,17 @@ private void validateMemberAccess(Long userId, Long projectId) { } // 게스트가 해당 영상에 접근할 자격이 있는지 검증하고, 검증된 Guest를 반환 - // Guest → ShareLink → Video 체인으로 소유 여부 확인 - private Guest validateGuestAccess(Long guestId, Long videoId) { + // 세션 토큰으로 본인 확인 + Guest → ShareLink → Video 소유 확인 + private Guest validateGuestAccess(Long guestId, Long videoId, String guestToken) { Guest guest = guestRepository.findById(guestId) .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); + // 0. 세션 토큰으로 본인 확인 — 없거나 불일치면 사칭으로 간주해 차단 + // 들어온 원문 토큰을 해시해서 저장된 해시와 비교. null이면 즉시 차단. + if (guestToken == null || !guest.getSessionToken().equals(tokenHasher.hash(guestToken))) { + throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED); + } + ShareLink shareLink = guest.getShareLink(); // 1. 링크가 살아있는지 (활성 + 미만료) @@ -206,7 +210,7 @@ private Guest validateGuestAccess(Long guestId, Long videoId) { } @Transactional - public void deleteFeedback(Long feedbackId, Long userId, Long guestId) { + public void deleteFeedback(Long feedbackId, Long userId, Long guestId, String guestToken) { // 1. 피드백 조회 (이미 삭제된 건 제외) Feedback feedback = feedbackRepository.findById(feedbackId) @@ -216,11 +220,11 @@ public void deleteFeedback(Long feedbackId, Long userId, Long guestId) { // 2. 작성자 검증 validateWriter(userId, guestId); - // 3. 접근 검증 — 회원은 프로젝트 멤버, 게스트는 공유링크 소유 + // 3. 접근 검증 — 회원은 프로젝트 멤버, 게스트는 토큰 + 공유링크 소유 if (userId != null) { validateMemberAccess(userId, feedback.getVideo().getProject().getId()); } else { - validateGuestAccess(guestId, feedback.getVideo().getId()); + validateGuestAccess(guestId, feedback.getVideo().getId(), guestToken); } // 4. 본인 확인 @@ -228,12 +232,12 @@ public void deleteFeedback(Long feedbackId, Long userId, Long guestId) { throw new BaseException(CommonErrorCode.FORBIDDEN); } - // 5. soft delete (더티 체킹으로 자동 반영) + // 5. soft delete feedback.softDelete(); } @Transactional(readOnly = true) - public FeedbackListResDTO getFeedbackList(Long videoId, Long userId, Long guestId, String cursor, Integer size) { + public FeedbackListResDTO getFeedbackList(Long videoId, Long userId, Long guestId, String guestToken, String cursor, Integer size) { // 1. 영상 존재 확인 boolean videoExists = entityManagerProvider.getObject().createQuery(""" @@ -246,7 +250,7 @@ select count(v) from Video v where v.id = :videoId throw new BaseException(CommonErrorCode.NOT_FOUND); } - // 2. 접근 검증 — 회원은 프로젝트 멤버, 게스트는 공유링크 소유 + // 2. 접근 검증 — 회원은 프로젝트 멤버, 게스트는 토큰 + 공유링크 소유 // 회원도 게스트도 아니면(둘 다 null) 익명 조회 차단 if (userId != null) { Long projectId = entityManagerProvider.getObject().createQuery(""" @@ -259,14 +263,14 @@ select count(v) from Video v where v.id = :videoId if (guestId == null) { throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED); } - validateGuestAccess(guestId, videoId); + validateGuestAccess(guestId, videoId, guestToken); } // 3. size 기본값 + 상한 처리 int pageSize = (size == null || size <= 0) ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); - Pageable pageable = PageRequest.of(0, pageSize + 1); // hasNext 판단용으로 1개 더 + Pageable pageable = PageRequest.of(0, pageSize + 1); // 4. 커서에 따라 조회 List feedbacks; @@ -331,7 +335,7 @@ public FeedbackStatusResDTO changeFeedbackStatus(Long feedbackId, Long userId, F .filter(f -> f.getDeletedAt() == null) .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); - // 2. 프로젝트 멤버인지 확인 (피드백 → 영상 → 프로젝트) + // 2. 프로젝트 멤버인지 확인 Long projectId = feedback.getVideo().getProject().getId(); boolean isMember = projectMemberRepository @@ -341,7 +345,7 @@ public FeedbackStatusResDTO changeFeedbackStatus(Long feedbackId, Long userId, F throw new BaseException(CommonErrorCode.FORBIDDEN); } - // 3. 상태 변경 (더티 체킹) + // 3. 상태 변경 feedback.changeStatus(req.status()); // 4. updatedAt 갱신 반영 diff --git a/src/main/java/com/slatto/domain/sharelink/converter/ShareLinkConverter.java b/src/main/java/com/slatto/domain/sharelink/converter/ShareLinkConverter.java index 1163b33d..d24e0601 100644 --- a/src/main/java/com/slatto/domain/sharelink/converter/ShareLinkConverter.java +++ b/src/main/java/com/slatto/domain/sharelink/converter/ShareLinkConverter.java @@ -38,15 +38,18 @@ public ShareLinkEntryResDTO toEntryResponse(ShareLink shareLink) { ); } - public Guest toGuest(ShareLink shareLink, String name) { - return Guest.create(shareLink, name); + // 해시를 받아 Guest 생성. 원문은 서비스가 보관. + public Guest toGuest(ShareLink shareLink, String name, String sessionTokenHash) { + return Guest.create(shareLink, name, sessionTokenHash); } - public GuestCreateResDTO toGuestCreateResponse(Guest guest) { + // 원문 토큰은 이 응답에서만 노출. 엔티티엔 해시만 있으므로 rawSessionToken을 따로 받음. + public GuestCreateResDTO toGuestCreateResponse(Guest guest, String rawSessionToken) { return new GuestCreateResDTO( guest.getId(), guest.getShareLink().getId(), guest.getName(), + rawSessionToken, // DB엔 없음, 응답 1회성 guest.getCreatedAt() ); } diff --git a/src/main/java/com/slatto/domain/sharelink/dto/response/ShareLinkResponse.java b/src/main/java/com/slatto/domain/sharelink/dto/response/ShareLinkResponse.java index 58bb0421..ca3b63d9 100644 --- a/src/main/java/com/slatto/domain/sharelink/dto/response/ShareLinkResponse.java +++ b/src/main/java/com/slatto/domain/sharelink/dto/response/ShareLinkResponse.java @@ -50,6 +50,10 @@ public record GuestCreateResDTO( @Schema(example = "홍길동") String name, + @Schema(example = "3f2a1c9e-4b7d-4a1e-9c2a-8e1f2b3c4d5e", + description = "게스트 본인 확인용 세션 토큰. 이후 게스트 요청 시 X-Guest-Token 헤더로 전송") + String sessionToken, + @Schema(example = "2026-07-24T14:00:00") LocalDateTime createdAt ) { } diff --git a/src/main/java/com/slatto/domain/sharelink/entity/Guest.java b/src/main/java/com/slatto/domain/sharelink/entity/Guest.java index 214344d3..6b21e001 100644 --- a/src/main/java/com/slatto/domain/sharelink/entity/Guest.java +++ b/src/main/java/com/slatto/domain/sharelink/entity/Guest.java @@ -24,12 +24,18 @@ public class Guest extends BaseEntity { @Column(name = "name", nullable = false, length = 100) private String name; - private Guest(ShareLink shareLink, String name) { + // 세션 토큰의 SHA-256 해시(64자 hex). 원문은 저장하지 않는다. + @Column(name = "session_token", nullable = false, unique = true, length = 64) + private String sessionToken; + + private Guest(ShareLink shareLink, String name, String sessionTokenHash) { this.shareLink = shareLink; this.name = name; + this.sessionToken = sessionTokenHash; } - public static Guest create(ShareLink shareLink, String name) { - return new Guest(shareLink, name); + // 원문이 아니라 '해시'를 받아 저장한다. 원문 생성은 서비스가 담당. + public static Guest create(ShareLink shareLink, String name, String sessionTokenHash) { + return new Guest(shareLink, name, sessionTokenHash); } } \ 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 881c088a..d3426b66 100644 --- a/src/main/java/com/slatto/domain/sharelink/service/ShareLinkService.java +++ b/src/main/java/com/slatto/domain/sharelink/service/ShareLinkService.java @@ -17,6 +17,8 @@ import com.slatto.domain.video.entity.Video; import com.slatto.global.exception.BaseException; import com.slatto.global.response.code.CommonErrorCode; +import com.slatto.global.util.TokenHasher; +import java.util.UUID; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.ObjectProvider; @@ -34,6 +36,7 @@ public class ShareLinkService { private final ProjectMemberRepository projectMemberRepository; private final ObjectProvider entityManagerProvider; private final GuestRepository guestRepository; + private final TokenHasher tokenHasher; @Transactional public ShareLinkCreateResDTO createShareLink(Long videoId, Long userId, ShareLinkCreateReqDTO req) { @@ -100,11 +103,16 @@ public GuestCreateResDTO registerGuest(String token, GuestCreateReqDTO req) { throw new BaseException(ShareLinkErrorCode.SHARE_LINK_UNAVAILABLE); } - // 3. 게스트 생성 - Guest guest = shareLinkConverter.toGuest(shareLink, req.name()); + // 3. 세션 토큰 발급 — 원문은 응답에만, DB에는 해시 저장 + String rawSessionToken = UUID.randomUUID().toString(); + String sessionTokenHash = tokenHasher.hash(rawSessionToken); + + // 4. 게스트 생성 (해시 저장) + Guest guest = shareLinkConverter.toGuest(shareLink, req.name(), sessionTokenHash); Guest saved = guestRepository.save(guest); - return shareLinkConverter.toGuestCreateResponse(saved); + // 5. 응답에 원문 토큰 포함 (이후 요청 시 X-Guest-Token 헤더로 재전송) + return shareLinkConverter.toGuestCreateResponse(saved, rawSessionToken); } @Transactional(readOnly = true) diff --git a/src/main/java/com/slatto/global/util/TokenHasher.java b/src/main/java/com/slatto/global/util/TokenHasher.java new file mode 100644 index 00000000..cad9d380 --- /dev/null +++ b/src/main/java/com/slatto/global/util/TokenHasher.java @@ -0,0 +1,26 @@ +package com.slatto.global.util; + +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * 게스트 세션 토큰을 SHA-256으로 해시한다. + * 원문 토큰은 등록 응답에서 한 번만 반환되고, DB에는 해시만 저장한다. + */ +@Component +public class TokenHasher { + + public String hash(String rawToken) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hashed); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 알고리즘을 찾을 수 없습니다.", e); + } + } +} \ No newline at end of file diff --git a/src/main/resources/db/migration/V016__guest_session_token.sql b/src/main/resources/db/migration/V016__guest_session_token.sql new file mode 100644 index 00000000..5b4d5f23 --- /dev/null +++ b/src/main/resources/db/migration/V016__guest_session_token.sql @@ -0,0 +1,5 @@ +-- 게스트 본인 확인용 세션 토큰 컬럼 추가 +-- 기존 게스트 데이터에도 UUID를 채운 뒤 NOT NULL + UNIQUE 제약을 건다 +ALTER TABLE guest ADD COLUMN session_token VARCHAR(36); +UPDATE guest SET session_token = UUID() WHERE session_token IS NULL; +ALTER TABLE guest MODIFY COLUMN session_token VARCHAR(36) NOT NULL UNIQUE; \ No newline at end of file diff --git a/src/main/resources/db/migration/V017__guest_session_token_hash.sql b/src/main/resources/db/migration/V017__guest_session_token_hash.sql new file mode 100644 index 00000000..37d6275a --- /dev/null +++ b/src/main/resources/db/migration/V017__guest_session_token_hash.sql @@ -0,0 +1,3 @@ +-- session_token: UUID(36자) → SHA-256 해시(64자 hex) 저장용으로 확장 +ALTER TABLE guest + MODIFY COLUMN session_token VARCHAR(64) NOT NULL; \ No newline at end of file diff --git a/src/test/java/com/slatto/domain/feedback/service/FeedbackActivityLogConnectionTest.java b/src/test/java/com/slatto/domain/feedback/service/FeedbackActivityLogConnectionTest.java index aa1035ce..5caf9bd0 100644 --- a/src/test/java/com/slatto/domain/feedback/service/FeedbackActivityLogConnectionTest.java +++ b/src/test/java/com/slatto/domain/feedback/service/FeedbackActivityLogConnectionTest.java @@ -18,6 +18,8 @@ import com.slatto.domain.user.repository.UserRepository; import com.slatto.domain.video.entity.Video; import com.slatto.domain.notification.service.NotificationService; +import com.slatto.global.exception.BaseException; +import com.slatto.global.util.TokenHasher; import jakarta.persistence.EntityManager; import jakarta.persistence.TypedQuery; import org.junit.jupiter.api.BeforeEach; @@ -25,18 +27,23 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import org.springframework.beans.factory.ObjectProvider; import java.util.Optional; import java.util.stream.Stream; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class FeedbackActivityLogConnectionTest { @Mock private FeedbackRepository feedbackRepository; @@ -52,9 +59,14 @@ class FeedbackActivityLogConnectionTest { @Mock private ActivityLogService activityLogService; @Mock private NotificationService notificationService; + // 해시 비교가 실제로 돌아야 하므로 mock이 아닌 실제 인스턴스 + private final TokenHasher tokenHasher = new TokenHasher(); + private FeedbackService feedbackService; private FeedbackDetailService feedbackDetailService; + private static final String GUEST_TOKEN = "test-guest-token"; + @BeforeEach void setUp() { feedbackService = new FeedbackService( @@ -66,7 +78,8 @@ void setUp() { projectMemberRepository, feedbackDetailRepository, notificationService, - activityLogService + activityLogService, + tokenHasher ); feedbackDetailService = new FeedbackDetailService( feedbackDetailRepository, @@ -76,7 +89,8 @@ void setUp() { feedbackDetailConverter, projectMemberRepository, notificationService, - activityLogService + activityLogService, + tokenHasher ); } @@ -94,7 +108,7 @@ void setUp() { given(feedbackConverter.toFeedback(eq(video), eq(user), eq(null), any())).willReturn(feedback); given(feedbackRepository.save(feedback)).willReturn(feedback); - feedbackService.createFeedback(11L, 1L, new FeedbackCreateReqDTO(null, "색감을 조정해주세요", 20L, 25L)); + feedbackService.createFeedback(11L, 1L, null, new FeedbackCreateReqDTO(null, "색감을 조정해주세요", 20L, 25L)); verify(activityLogService).createVideoFeedbackCommentedLog(101L, 1L, 11L, "1차 편집본"); } @@ -109,13 +123,15 @@ void setUp() { stubVideoLookup(video); given(guestRepository.findById(2L)).willReturn(Optional.of(guest)); + // 저장값은 원문이 아니라 해시 — 검증 시 tokenHasher.hash(원문)과 비교됨 + given(guest.getSessionToken()).willReturn(tokenHasher.hash(GUEST_TOKEN)); given(guest.getShareLink()).willReturn(shareLink); given(shareLink.isUsable()).willReturn(true); given(shareLink.getVideo()).willReturn(video); given(feedbackConverter.toFeedback(eq(video), eq(null), eq(guest), any())).willReturn(feedback); given(feedbackRepository.save(feedback)).willReturn(feedback); - feedbackService.createFeedback(11L, null, new FeedbackCreateReqDTO(2L, "클라이언트 피드백입니다", 20L, 25L)); + feedbackService.createFeedback(11L, null, GUEST_TOKEN, new FeedbackCreateReqDTO(2L, "클라이언트 피드백입니다", 20L, 25L)); verify(activityLogService).createGuestVideoFeedbackCommentedLog(101L, guest, 11L, "1차 편집본"); } @@ -137,7 +153,7 @@ void setUp() { given(feedbackDetailConverter.toFeedbackDetail(eq(feedback), eq(user), eq(null), any())).willReturn(reply); given(feedbackDetailRepository.save(reply)).willReturn(reply); - feedbackDetailService.createReply(31L, 1L, new ReplyCreateReqDTO(null, "반영하겠습니다")); + feedbackDetailService.createReply(31L, 1L, null, new ReplyCreateReqDTO(null, "반영하겠습니다")); verify(activityLogService).createVideoFeedbackCommentedLog(101L, 1L, 11L, "1차 편집본"); } @@ -155,17 +171,79 @@ void setUp() { given(feedback.getDeletedAt()).willReturn(null); given(feedback.getVideo()).willReturn(video); given(guestRepository.findById(2L)).willReturn(Optional.of(guest)); + // 저장값은 해시 + given(guest.getSessionToken()).willReturn(tokenHasher.hash(GUEST_TOKEN)); given(guest.getShareLink()).willReturn(shareLink); given(shareLink.isUsable()).willReturn(true); given(shareLink.getVideo()).willReturn(video); given(feedbackDetailConverter.toFeedbackDetail(eq(feedback), eq(null), eq(guest), any())).willReturn(reply); given(feedbackDetailRepository.save(reply)).willReturn(reply); - feedbackDetailService.createReply(31L, null, new ReplyCreateReqDTO(2L, "게스트 답글입니다")); + feedbackDetailService.createReply(31L, null, GUEST_TOKEN, new ReplyCreateReqDTO(2L, "게스트 답글입니다")); verify(activityLogService).createGuestVideoFeedbackCommentedLog(101L, guest, 11L, "1차 편집본"); } + @Test + void 게스트_토큰이_틀리면_피드백_등록이_차단된다() { + // 사칭 방지: guestId는 맞아도 세션 토큰이 다르면 예외로 막히고 저장이 일어나지 않아야 한다. + Video video = video(11L, "1차 편집본", 101L); + Guest guest = mock(Guest.class); + + stubVideoLookup(video); + given(guestRepository.findById(2L)).willReturn(Optional.of(guest)); + // DB엔 올바른 토큰의 해시가 저장돼 있음 + given(guest.getSessionToken()).willReturn(tokenHasher.hash(GUEST_TOKEN)); + + // 요청엔 엉뚱한 토큰 → 해시 비교 실패 → 차단 + assertThatThrownBy(() -> + feedbackService.createFeedback(11L, null, "wrong-token", + new FeedbackCreateReqDTO(2L, "사칭 시도", 20L, 25L)) + ).isInstanceOf(BaseException.class); + + verify(feedbackRepository, never()).save(any()); + verify(activityLogService, never()) + .createGuestVideoFeedbackCommentedLog(any(), any(), any(), any()); + } + + @Test + void 게스트_토큰이_없으면_피드백_등록이_차단된다() { + // 토큰 헤더 누락(null)도 사칭 간주 → 차단. + Video video = video(11L, "1차 편집본", 101L); + Guest guest = mock(Guest.class); + + stubVideoLookup(video); + given(guestRepository.findById(2L)).willReturn(Optional.of(guest)); + + assertThatThrownBy(() -> + feedbackService.createFeedback(11L, null, null, + new FeedbackCreateReqDTO(2L, "토큰 없는 시도", 20L, 25L)) + ).isInstanceOf(BaseException.class); + + verify(feedbackRepository, never()).save(any()); + } + + @Test + void 게스트_토큰이_틀리면_답글_등록이_차단된다() { + // 답글 경로도 동일하게 토큰 불일치 시 차단돼야 한다. + Video video = video(11L, "1차 편집본", 101L); + Feedback feedback = mock(Feedback.class); + Guest guest = mock(Guest.class); + + given(feedbackRepository.findById(31L)).willReturn(Optional.of(feedback)); + given(feedback.getDeletedAt()).willReturn(null); + given(feedback.getVideo()).willReturn(video); + given(guestRepository.findById(2L)).willReturn(Optional.of(guest)); + given(guest.getSessionToken()).willReturn(tokenHasher.hash(GUEST_TOKEN)); + + assertThatThrownBy(() -> + feedbackDetailService.createReply(31L, null, "wrong-token", + new ReplyCreateReqDTO(2L, "사칭 답글")) + ).isInstanceOf(BaseException.class); + + verify(feedbackDetailRepository, never()).save(any()); + } + private void stubVideoLookup(Video video) { given(entityManagerProvider.getObject()).willReturn(entityManager); given(entityManager.createQuery(any(String.class), eq(Video.class))).willReturn(videoQuery);