Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ecea7aa
feat: 게스트 공유링크 소유 검증 (#85)
Kohseoyoung Jul 31, 2026
1148f52
fix: 익명 목록 조회 차단 - guestId 없는 접근 거부 (#85)
Kohseoyoung Jul 31, 2026
93055a1
Merge pull request #94 from SLAT-TO/feature/85-guest-verification
Kohseoyoung Aug 2, 2026
0bc3918
feat: 알림 그룹 누적 개수 필드 추가
guingguing Aug 2, 2026
711d7f6
feat: 그룹핑 알림 누적 개수 증가 처리
guingguing Aug 2, 2026
7d1e69a
feat: 알림 목록 응답에 그룹 누적 개수 추가
guingguing Aug 2, 2026
ccd8436
feat: 영상 피드백 미읽음 개수 조회 메서드 추가
guingguing Aug 2, 2026
ac16a5b
docs: 그룹핑 알림 누적 개수 주석 추가
guingguing Aug 2, 2026
1081d4d
refactor: 알림 문구 생성 책임을 알림 서비스로 정리
guingguing Aug 2, 2026
8e95cca
feat: 알림 그룹 누적 개수 컬럼 추가
guingguing Aug 2, 2026
2bf3023
feat: 알림 제목 응답 및 생성 로직 추가
guingguing Aug 2, 2026
288b1b7
fix: 그룹핑 알림 갱신 시 제목도 갱신
guingguing Aug 2, 2026
bf68b27
fix: 그룹핑 알림 갱신 동시성 개선
guingguing Aug 2, 2026
fe8af4d
feat: 최근활동 타입 enum 추가
guingguing Aug 2, 2026
7320407
feat: 최근활동 저장 모델 추가
guingguing Aug 2, 2026
4a3e6d4
feat: 최근활동 생성 서비스 추가
guingguing Aug 3, 2026
4b471bf
Merge pull request #99 from SLAT-TO/feature/98-notification-group-count
guingguing Aug 3, 2026
9c7b0f1
feat: 최근활동 유형별 생성 메서드 추가
guingguing Aug 3, 2026
7362ae2
Merge pull request #101 from SLAT-TO/feature/100-activity-log-service
guingguing Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,12 @@ public ResponseEntity<ApiResponse<Void>> deleteFeedback(
@GetMapping("/videos/{videoId}/feedbacks")
public ResponseEntity<ApiResponse<FeedbackListResDTO>> getFeedbackList(
@PathVariable Long videoId,
@AuthenticationPrincipal Long userId,
@RequestParam(required = false) Long guestId,
@RequestParam(required = false) String cursor,
@RequestParam(required = false) Integer size
) {
FeedbackListResDTO result = feedbackService.getFeedbackList(videoId, cursor, size);
FeedbackListResDTO result = feedbackService.getFeedbackList(videoId, userId, guestId, cursor, size);

return ResponseEntity
.ok(ApiResponse.success(CommonSuccessCode.OK, result));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@Tag(name = "Reply", description = "답글 API")
@Tag(name = "Feedback Reply", description = "피드백 답글 API")
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
Expand All @@ -45,10 +45,12 @@ public ResponseEntity<ApiResponse<ReplyCreateResDTO>> createReply(
@GetMapping("/feedbacks/{feedbackId}/replies")
public ResponseEntity<ApiResponse<ReplyListResDTO>> getReplyList(
@PathVariable Long feedbackId,
@AuthenticationPrincipal Long userId,
@RequestParam(required = false) Long guestId,
@RequestParam(required = false) Long cursor,
@RequestParam(required = false) Integer size
) {
ReplyListResDTO result = feedbackDetailService.getReplyList(feedbackId, cursor, size);
ReplyListResDTO result = feedbackDetailService.getReplyList(feedbackId, userId, guestId, cursor, size);

return ResponseEntity
.ok(ApiResponse.success(CommonSuccessCode.OK, result));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import com.slatto.domain.feedback.repository.FeedbackDetailRepository;
import com.slatto.domain.feedback.repository.FeedbackRepository;
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.user.entity.Users;
import com.slatto.domain.user.repository.UserRepository;
Expand Down Expand Up @@ -59,8 +61,8 @@ public ReplyCreateResDTO createReply(Long feedbackId, Long userId, ReplyCreateRe
user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));
} else {
guest = guestRepository.findById(req.guestId())
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));
// 게스트: 원 피드백의 영상에 접근할 자격이 있는지 검증 후 Guest 확보
guest = validateGuestAccess(req.guestId(), feedback.getVideo().getId());
}

// 4. 저장
Expand All @@ -78,32 +80,62 @@ private void validateWriter(Long userId, Long guestId) {
}
}

// 게스트가 해당 영상에 접근할 자격이 있는지 검증하고, 검증된 Guest를 반환
// Guest → ShareLink → Video 체인으로 소유 여부 확인
private Guest validateGuestAccess(Long guestId, Long videoId) {
Guest guest = guestRepository.findById(guestId)
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));

ShareLink shareLink = guest.getShareLink();

// 1. 링크가 살아있는지 (활성 + 미만료)
if (!shareLink.isUsable()) {
throw new BaseException(ShareLinkErrorCode.SHARE_LINK_UNAVAILABLE);
}

// 2. 게스트의 링크 영상 == 요청 영상인지
if (!shareLink.getVideo().getId().equals(videoId)) {
throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED);
}

return guest;
}

@Transactional(readOnly = true)
public ReplyListResDTO getReplyList(Long feedbackId, Long cursor, Integer size) {
public ReplyListResDTO getReplyList(Long feedbackId, Long userId, Long guestId, Long cursor, Integer size) {

// 1. 원 피드백 존재 확인
feedbackRepository.findById(feedbackId)
Feedback feedback = feedbackRepository.findById(feedbackId)
.filter(f -> f.getDeletedAt() == null)
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));

// 2. size 기본값 + 상한 처리
// 2. 게스트가 조회하는 경우 원 피드백의 영상에 접근 자격이 있는지 검증
// 회원이 아니면 guestId 필수 — 익명(둘 다 null) 조회 차단
if (userId == null) {
if (guestId == null) {
throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED);
}
validateGuestAccess(guestId, feedback.getVideo().getId());
}

// 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

// 3. 조회
// 4. 조회
List<FeedbackDetail> replies = (cursor == null)
? feedbackDetailRepository.findFirstPage(feedbackId, pageable)
: feedbackDetailRepository.findNextPage(feedbackId, cursor, pageable);

// 4. hasNext 판단 + 초과분 제거
// 5. hasNext 판단 + 초과분 제거
boolean hasNext = replies.size() > pageSize;
if (hasNext) {
replies = replies.subList(0, pageSize);
}

// 5. nextCursor
// 6. nextCursor
Long nextCursor = (hasNext && !replies.isEmpty())
? replies.getLast().getId()
: null;
Expand All @@ -122,15 +154,20 @@ public ReplyUpdateResDTO updateReply(Long replyId, Long userId, ReplyUpdateReqDT
// 2. 작성자 검증
validateWriter(userId, req.guestId());

// 3. 본인 확인
// 3. 게스트면 이 답글의 영상에 접근 자격이 있는지 검증 (답글 → 피드백 → 영상)
if (userId == null) {
validateGuestAccess(req.guestId(), reply.getFeedback().getVideo().getId());
}

// 4. 본인 확인
if (!reply.isWriter(userId, req.guestId())) {
throw new BaseException(CommonErrorCode.FORBIDDEN);
}

// 4. 수정 (더티 체킹)
// 5. 수정 (더티 체킹)
reply.update(req.content());

// 5. updatedAt 갱신 반영
// 6. updatedAt 갱신 반영
feedbackDetailRepository.flush();

return feedbackDetailConverter.toUpdateResponse(reply);
Expand All @@ -147,12 +184,17 @@ public void deleteReply(Long replyId, Long userId, Long guestId) {
// 2. 작성자 검증
validateWriter(userId, guestId);

// 3. 본인 확인
// 3. 게스트면 이 답글의 영상에 접근 자격이 있는지 검증 (답글 → 피드백 → 영상)
if (userId == null) {
validateGuestAccess(guestId, reply.getFeedback().getVideo().getId());
}

// 4. 본인 확인
if (!reply.isWriter(userId, guestId)) {
throw new BaseException(CommonErrorCode.FORBIDDEN);
}

// 4. soft delete (더티 체킹)
// 5. soft delete (더티 체킹)
reply.softDelete();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import com.slatto.domain.feedback.entity.Feedback;
import com.slatto.domain.feedback.repository.FeedbackRepository;
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.user.entity.Users;
import com.slatto.domain.user.repository.UserRepository;
Expand Down Expand Up @@ -70,8 +72,8 @@ public FeedbackCreateResDTO createFeedback(Long videoId, Long userId, FeedbackCr
user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));
} else {
guest = guestRepository.findById(req.guestId())
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));
// 게스트: 이 영상에 접근할 자격이 있는지 검증 후 Guest 확보
guest = validateGuestAccess(req.guestId(), videoId);
}

// 4. 저장
Expand All @@ -92,15 +94,20 @@ public FeedbackUpdateResDTO updateFeedback(Long feedbackId, Long userId, Feedbac
// 2. 작성자 검증
validateWriter(userId, req.guestId());

// 3. 본인 확인
// 3. 게스트면 이 피드백의 영상에 접근 자격이 있는지 검증
if (userId == null) {
validateGuestAccess(req.guestId(), feedback.getVideo().getId());
}

// 4. 본인 확인
if (!feedback.isWriter(userId, req.guestId())) {
throw new BaseException(CommonErrorCode.FORBIDDEN);
}

// 4. 수정 (status 전달 안 함 — 해결 상태는 전용 API에서만 변경)
// 5. 수정 (status 전달 안 함 — 해결 상태는 전용 API에서만 변경)
feedback.update(req.content(), req.startTime(), req.endTime());

// 5. updatedAt 갱신을 응답에 반영하기 위해 flush
// 6. updatedAt 갱신을 응답에 반영하기 위해 flush
feedbackRepository.flush();

return feedbackConverter.toUpdateResponse(feedback);
Expand All @@ -115,6 +122,27 @@ private void validateWriter(Long userId, Long guestId) {
}
}

// 게스트가 해당 영상에 접근할 자격이 있는지 검증하고, 검증된 Guest를 반환
// Guest → ShareLink → Video 체인으로 소유 여부 확인
private Guest validateGuestAccess(Long guestId, Long videoId) {
Guest guest = guestRepository.findById(guestId)
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));

ShareLink shareLink = guest.getShareLink();

// 1. 링크가 살아있는지 (활성 + 미만료)
if (!shareLink.isUsable()) {
throw new BaseException(ShareLinkErrorCode.SHARE_LINK_UNAVAILABLE);
}

// 2. 게스트의 링크 영상 == 요청 영상인지
if (!shareLink.getVideo().getId().equals(videoId)) {
throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED);
}

return guest;
}

@Transactional
public void deleteFeedback(Long feedbackId, Long userId, Long guestId) {

Expand All @@ -126,17 +154,22 @@ public void deleteFeedback(Long feedbackId, Long userId, Long guestId) {
// 2. 작성자 검증
validateWriter(userId, guestId);

// 3. 본인 확인
// 3. 게스트면 이 피드백의 영상에 접근 자격이 있는지 검증
if (userId == null) {
validateGuestAccess(guestId, feedback.getVideo().getId());
}

// 4. 본인 확인
if (!feedback.isWriter(userId, guestId)) {
throw new BaseException(CommonErrorCode.FORBIDDEN);
}

// 4. soft delete (더티 체킹으로 자동 반영)
// 5. soft delete (더티 체킹으로 자동 반영)
feedback.softDelete();
}

@Transactional(readOnly = true)
public FeedbackListResDTO getFeedbackList(Long videoId, String cursor, Integer size) {
public FeedbackListResDTO getFeedbackList(Long videoId, Long userId, Long guestId, String cursor, Integer size) {

// 1. 영상 존재 확인
boolean videoExists = entityManagerProvider.getObject().createQuery("""
Expand All @@ -149,13 +182,22 @@ select count(v) from Video v where v.id = :videoId
throw new BaseException(CommonErrorCode.NOT_FOUND);
}

// 2. size 기본값 + 상한 처리
// 2. 게스트가 조회하는 경우 이 영상에 접근 자격이 있는지 검증
// 회원이 아니면 guestId 필수 — 익명(둘 다 null) 조회 차단
if (userId == null) {
if (guestId == null) {
throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED);
}
validateGuestAccess(guestId, videoId);
}

// 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개 더

// 3. 커서에 따라 조회
// 4. 커서에 따라 조회
List<Feedback> feedbacks;

if (cursor == null || cursor.isBlank()) {
Expand All @@ -180,21 +222,21 @@ select count(v) from Video v where v.id = :videoId
}
}

// 4. hasNext 판단 + 초과분 제거
// 5. hasNext 판단 + 초과분 제거
boolean hasNext = feedbacks.size() > pageSize;
if (hasNext) {
feedbacks = feedbacks.subList(0, pageSize);
}

// 5. nextCursor 조립
// 6. nextCursor 조립
String nextCursor = null;
if (hasNext && !feedbacks.isEmpty()) {
Feedback last = feedbacks.getLast();
String timePart = (last.getStartTime() == null) ? "n" : String.valueOf(last.getStartTime());
nextCursor = timePart + "_" + last.getId();
}

// 6. 답글 개수 한 번에 조회
// 7. 답글 개수 한 번에 조회
Map<Long, Long> replyCountMap = new HashMap<>();

if (!feedbacks.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public enum ShareLinkErrorCode implements BaseCode {
SHARE_LINK_NOT_FOUND(HttpStatus.NOT_FOUND, "SHARELINK404", "공유 링크를 찾을 수 없습니다."),
SHARE_LINK_ALREADY_EXISTS(HttpStatus.CONFLICT, "SHARELINK409", "이미 이 영상의 공유 링크가 존재합니다."),
INVALID_EXPIRED_AT(HttpStatus.BAD_REQUEST, "SHARELINK400", "만료 일시는 현재 시각보다 이후여야 합니다."),
SHARE_LINK_UNAVAILABLE(HttpStatus.GONE, "SHARELINK410", "비활성화되었거나 만료된 링크입니다.");
SHARE_LINK_UNAVAILABLE(HttpStatus.GONE, "SHARELINK410", "비활성화되었거나 만료된 링크입니다."),
GUEST_ACCESS_DENIED(HttpStatus.FORBIDDEN, "SHARELINK403", "해당 영상에 접근 권한이 없는 게스트입니다.");

private final HttpStatus httpStatus;
private final String code;
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/com/slatto/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
// 게스트 등록
.requestMatchers(HttpMethod.POST, "/api/v1/share-links/*/guests").permitAll()

// 게스트 피드백/답글 참여 — 작성/수정/삭제만 (조회는 인가 검증 이슈에서 처리)
// 게스트 피드백/답글 참여 (조회 포함 — 게스트 소유 검증은 서비스단에서 처리)
.requestMatchers(HttpMethod.POST, "/api/v1/videos/*/feedbacks").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/videos/*/feedbacks").permitAll()
.requestMatchers(HttpMethod.PATCH, "/api/v1/feedbacks/*").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/v1/feedbacks/*").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/feedbacks/*/replies").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/feedbacks/*/replies").permitAll()
.requestMatchers(HttpMethod.PATCH, "/api/v1/replies/*").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/v1/replies/*").permitAll()

Expand Down
Loading