From 436440fe20be779b704a27555835e28a569347d1 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 12 Aug 2026 03:26:16 +0900 Subject: [PATCH 01/14] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=A4=EB=B2=84=20=EC=A0=91=EA=B7=BC=20=EA=B1=B0?= =?UTF-8?q?=EB=B6=80=EB=A5=BC=20PROJECT403=20=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 영상, 피드백, 공유 링크, 일정 서비스가 "프로젝트의 활성 멤버인가" 를 직접 검사하고 CommonErrorCode.FORBIDDEN 을 던지고 있었다. 같은 조건을 ProjectAccessValidator 는 PROJECT403 으로 던진다. 검사식까지 existsByProjectIdAndUserIdAndLeftAtIsNull 로 동일한데 응답 코드만 갈라져서, 호출하는 쪽에서는 같은 실패가 두 가지 코드로 온다. 도메인 접두사 체계를 따라 PROJECT403 으로 맞춘다. HTTP 상태는 403 그대로이고 code 문자열과 메시지만 바뀐다. --- .../feedback/service/FeedbackDetailService.java | 5 +++-- .../domain/feedback/service/FeedbackService.java | 5 +++-- .../domain/schedule/service/ScheduleService.java | 3 ++- .../sharelink/service/ShareLinkService.java | 7 ++++--- .../slatto/domain/video/service/VideoService.java | 15 ++++++++------- 5 files changed, 20 insertions(+), 15 deletions(-) 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 749158a..a57d890 100644 --- a/src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java +++ b/src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java @@ -6,6 +6,7 @@ import com.slatto.domain.feedback.dto.response.FeedbackDetailResponse.ReplyListResDTO; import com.slatto.domain.feedback.dto.request.FeedbackDetailRequest.ReplyUpdateReqDTO; import com.slatto.domain.feedback.dto.response.FeedbackDetailResponse.ReplyUpdateResDTO; +import com.slatto.domain.project.exception.ProjectErrorCode; import com.slatto.domain.project.repository.ProjectMemberRepository; import com.slatto.domain.notification.service.ActivityLogService; import com.slatto.domain.feedback.dto.request.FeedbackDetailRequest.ReplyStatusReqDTO; @@ -135,7 +136,7 @@ private void validateMemberAccess(Long userId, Long projectId) { boolean isMember = projectMemberRepository .existsByProjectIdAndUserIdAndLeftAtIsNull(projectId, userId); if (!isMember) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } } @@ -283,7 +284,7 @@ public ReplyStatusResDTO changeReplyStatus(Long replyId, Long userId, ReplyStatu .existsByProjectIdAndUserIdAndLeftAtIsNull(projectId, userId); if (!isMember) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } // 3. 상태 변경 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 8d08ac5..15eb4a8 100644 --- a/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java +++ b/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java @@ -10,6 +10,7 @@ import com.slatto.domain.feedback.dto.response.FeedbackResponse.FeedbackStatusResDTO; import com.slatto.domain.feedback.repository.FeedbackDetailRepository; import com.slatto.domain.notification.service.ActivityLogService; +import com.slatto.domain.project.exception.ProjectErrorCode; import com.slatto.domain.project.repository.ProjectMemberRepository; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -178,7 +179,7 @@ private void validateMemberAccess(Long userId, Long projectId) { boolean isMember = projectMemberRepository .existsByProjectIdAndUserIdAndLeftAtIsNull(projectId, userId); if (!isMember) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } } @@ -342,7 +343,7 @@ public FeedbackStatusResDTO changeFeedbackStatus(Long feedbackId, Long userId, F .existsByProjectIdAndUserIdAndLeftAtIsNull(projectId, userId); if (!isMember) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } // 3. 상태 변경 diff --git a/src/main/java/com/slatto/domain/schedule/service/ScheduleService.java b/src/main/java/com/slatto/domain/schedule/service/ScheduleService.java index bddba54..1d84a6d 100644 --- a/src/main/java/com/slatto/domain/schedule/service/ScheduleService.java +++ b/src/main/java/com/slatto/domain/schedule/service/ScheduleService.java @@ -4,6 +4,7 @@ import com.slatto.domain.notification.service.NotificationService; import com.slatto.domain.project.entity.Project; import com.slatto.domain.project.entity.ProjectMember; +import com.slatto.domain.project.exception.ProjectErrorCode; import com.slatto.domain.project.repository.ProjectMemberRepository; import com.slatto.domain.project.service.ProjectAccessValidator; import com.slatto.domain.schedule.converter.ScheduleConverter; @@ -422,7 +423,7 @@ private void validateScheduleAccess(Schedule schedule, Long currentUserId) { project.getId(), currentUserId )) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } } 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 d3426b6..0826219 100644 --- a/src/main/java/com/slatto/domain/sharelink/service/ShareLinkService.java +++ b/src/main/java/com/slatto/domain/sharelink/service/ShareLinkService.java @@ -1,5 +1,6 @@ package com.slatto.domain.sharelink.service; +import com.slatto.domain.project.exception.ProjectErrorCode; import com.slatto.domain.project.repository.ProjectMemberRepository; import com.slatto.domain.sharelink.converter.ShareLinkConverter; import com.slatto.domain.sharelink.dto.request.ShareLinkRequest.ShareLinkCreateReqDTO; @@ -56,7 +57,7 @@ public ShareLinkCreateResDTO createShareLink(Long videoId, Long userId, ShareLin .existsByProjectIdAndUserIdAndLeftAtIsNull(projectId, userId); if (!isMember) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } // 3. 만료 일시가 과거면 거부 @@ -134,7 +135,7 @@ public ShareLinkInfoResDTO getShareLinkByVideo(Long videoId, Long userId) { .existsByProjectIdAndUserIdAndLeftAtIsNull(projectId, userId); if (!isMember) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } // 3. 링크 조회 (없으면 404) @@ -158,7 +159,7 @@ public ShareLinkToggleResDTO toggleShareLink(Long shareLinkId, Long userId) { .existsByProjectIdAndUserIdAndLeftAtIsNull(projectId, userId); if (!isMember) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } // 3. 상태 토글 (더티 체킹) 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 a35139b..b571a59 100644 --- a/src/main/java/com/slatto/domain/video/service/VideoService.java +++ b/src/main/java/com/slatto/domain/video/service/VideoService.java @@ -3,6 +3,7 @@ import com.slatto.domain.project.entity.Project; import com.slatto.domain.notification.service.NotificationService; import com.slatto.domain.project.enums.LengthType; +import com.slatto.domain.project.exception.ProjectErrorCode; import com.slatto.domain.user.enums.CategoryName; import com.slatto.domain.user.enums.Kind; import com.slatto.domain.user.enums.RoleName; @@ -65,7 +66,7 @@ public VideoDetailResDTO getVideo(Long memberId, Long projectId, Long videoId) { throw new BaseException(CommonErrorCode.NOT_FOUND); } if (!projectAccessRepository.existsByMemberIdAndProjectId(memberId, projectId)) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } Video video = videoRepository.findByIdAndProjectId(videoId, projectId) @@ -92,7 +93,7 @@ public VideoBookmarkUpdateResDTO updateBookmark( throw new BaseException(CommonErrorCode.NOT_FOUND); } if (!projectAccessRepository.existsByMemberIdAndProjectId(memberId, projectId)) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } Video video = videoRepository.findByIdAndProjectId(videoId, projectId) @@ -111,7 +112,7 @@ public YoutubeValidateResDTO validateYoutubeUrl(Long memberId, YoutubeValidateRe throw new BaseException(CommonErrorCode.NOT_FOUND); } if (!projectAccessRepository.existsByMemberIdAndProjectId(memberId, request.projectId())) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } String youtubeVideoId = youtubeUrlParser.extractVideoId(request.youtubeUrl()); @@ -139,7 +140,7 @@ public VideoCreateResDTO createVideo(Long memberId, Long projectId, VideoCreateR Project project = projectAccessRepository.findProjectById(projectId) .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); if (!projectAccessRepository.existsByMemberIdAndProjectId(memberId, projectId)) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } String youtubeVideoId = youtubeUrlParser.extractVideoId(request.youtubeUrl()); @@ -180,7 +181,7 @@ public VideoListResDTO getVideos(Long memberId, Long projectId, Long cursor, Int throw new BaseException(CommonErrorCode.NOT_FOUND); } if (!projectAccessRepository.existsByMemberIdAndProjectId(memberId, projectId)) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } int size = requestedSize == null ? DEFAULT_SIZE : Math.min(requestedSize, MAX_SIZE); @@ -220,7 +221,7 @@ public VideoDeleteResDTO deleteVideo(Long memberId, Long projectId, Long videoId throw new BaseException(CommonErrorCode.NOT_FOUND); } if (!projectAccessRepository.existsByMemberIdAndProjectId(memberId, projectId)) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } Video video = videoRepository.findByIdAndProjectId(videoId, projectId) @@ -242,7 +243,7 @@ public VideoUpdateResDTO updateVideo( throw new BaseException(CommonErrorCode.NOT_FOUND); } if (!projectAccessRepository.existsByMemberIdAndProjectId(memberId, projectId)) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } Video video = videoRepository.findByIdAndProjectId(videoId, projectId) From 74072e829619369084061c690d8a97ef1ab3c89e Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 12 Aug 2026 03:27:11 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20Video/Feedback/Schedule=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 영상, 피드백, 일정 도메인에는 에러 코드 enum 자체가 없어서 도메인 고유의 실패까지 CommonErrorCode 로 나가고 있었다. 다음 커밋에서 교체할 코드를 먼저 정의한다. 채용은 enum 이 있으나 403 이 없어 두 개를 덧붙인다. 공고 작성자 검증과 지원서 열람 권한은 조건이 달라 코드를 나눈다. --- .../feedback/exception/FeedbackErrorCode.java | 23 ++++++++++++++++ .../exception/RecruitmentErrorCode.java | 2 ++ .../schedule/exception/ScheduleErrorCode.java | 22 +++++++++++++++ .../video/exception/VideoErrorCode.java | 27 +++++++++++++++++++ 4 files changed, 74 insertions(+) create mode 100644 src/main/java/com/slatto/domain/feedback/exception/FeedbackErrorCode.java create mode 100644 src/main/java/com/slatto/domain/schedule/exception/ScheduleErrorCode.java create mode 100644 src/main/java/com/slatto/domain/video/exception/VideoErrorCode.java diff --git a/src/main/java/com/slatto/domain/feedback/exception/FeedbackErrorCode.java b/src/main/java/com/slatto/domain/feedback/exception/FeedbackErrorCode.java new file mode 100644 index 0000000..b7c64a2 --- /dev/null +++ b/src/main/java/com/slatto/domain/feedback/exception/FeedbackErrorCode.java @@ -0,0 +1,23 @@ +package com.slatto.domain.feedback.exception; + +import com.slatto.global.response.code.BaseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum FeedbackErrorCode implements BaseCode { + + FEEDBACK_WRITER_ONLY(HttpStatus.FORBIDDEN, "FEEDBACK403", "본인이 작성한 피드백만 수정하거나 삭제할 수 있습니다."), + FEEDBACK_REPLY_WRITER_ONLY(HttpStatus.FORBIDDEN, "FEEDBACK_REPLY403", "본인이 작성한 답글만 수정하거나 삭제할 수 있습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public boolean isSuccess() { + return false; + } +} diff --git a/src/main/java/com/slatto/domain/recruitment/exception/RecruitmentErrorCode.java b/src/main/java/com/slatto/domain/recruitment/exception/RecruitmentErrorCode.java index 834ba40..0494118 100644 --- a/src/main/java/com/slatto/domain/recruitment/exception/RecruitmentErrorCode.java +++ b/src/main/java/com/slatto/domain/recruitment/exception/RecruitmentErrorCode.java @@ -9,6 +9,8 @@ @RequiredArgsConstructor public enum RecruitmentErrorCode implements BaseCode { + RECRUITMENT_WRITER_ONLY(HttpStatus.FORBIDDEN, "RECRUITMENT403", "본인이 작성한 공고만 수정하거나 관리할 수 있습니다."), + APPLICATION_ACCESS_DENIED(HttpStatus.FORBIDDEN, "APPLICATION403", "공고 작성자와 지원 본인만 열람할 수 있습니다."), RECRUITMENT_CLOSED(HttpStatus.BAD_REQUEST, "RECRUITMENT_CLOSED400", "마감된 공고에는 지원할 수 없습니다."), RECRUITMENT_SELF_APPLICATION(HttpStatus.BAD_REQUEST, "RECRUITMENT_SELF400", "본인이 작성한 공고에는 지원할 수 없습니다."), APPLICATION_ALREADY_APPLIED(HttpStatus.CONFLICT, "APPLICATION409", "이미 지원한 공고입니다."), diff --git a/src/main/java/com/slatto/domain/schedule/exception/ScheduleErrorCode.java b/src/main/java/com/slatto/domain/schedule/exception/ScheduleErrorCode.java new file mode 100644 index 0000000..ba05402 --- /dev/null +++ b/src/main/java/com/slatto/domain/schedule/exception/ScheduleErrorCode.java @@ -0,0 +1,22 @@ +package com.slatto.domain.schedule.exception; + +import com.slatto.global.response.code.BaseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ScheduleErrorCode implements BaseCode { + + SCHEDULE_WRITER_ONLY(HttpStatus.FORBIDDEN, "SCHEDULE403", "본인이 등록한 일정만 조회하거나 변경할 수 있습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public boolean isSuccess() { + return false; + } +} diff --git a/src/main/java/com/slatto/domain/video/exception/VideoErrorCode.java b/src/main/java/com/slatto/domain/video/exception/VideoErrorCode.java new file mode 100644 index 0000000..e864a72 --- /dev/null +++ b/src/main/java/com/slatto/domain/video/exception/VideoErrorCode.java @@ -0,0 +1,27 @@ +package com.slatto.domain.video.exception; + +import com.slatto.global.response.code.BaseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum VideoErrorCode implements BaseCode { + + VIDEO_ALREADY_REGISTERED(HttpStatus.CONFLICT, "VIDEO409", "이미 등록된 영상입니다."), + VIDEO_REFERENCE_FILE_ALREADY_LINKED( + HttpStatus.CONFLICT, + "VIDEO_REFERENCE_FILE409", + "이미 연결된 참고 자료입니다." + ); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public boolean isSuccess() { + return false; + } +} From 475c3c0f510d7370994ea538aa44a7b08fb1acf6 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 12 Aug 2026 03:30:42 +0900 Subject: [PATCH 03/14] =?UTF-8?q?refactor:=20=EC=9E=91=EC=84=B1=EC=9E=90?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=EA=B3=BC=20=EC=A4=91=EB=B3=B5=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=9D=91=EB=8B=B5=EC=9D=84=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=BD=94=EB=93=9C=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommonErrorCode.FORBIDDEN / CONFLICT 로 나가던 14곳을 도메인 코드로 바꾼다. - 피드백/답글 작성자: FEEDBACK403, FEEDBACK_REPLY403 - 일정 작성자: SCHEDULE403 - 공고 작성자: RECRUITMENT403 - 지원서 열람 권한: APPLICATION403 - 영상 중복 등록, 참고 자료 중복 연결: VIDEO409, VIDEO_REFERENCE_FILE409 CSRF 필터의 403 은 도메인이 아닌 전역 보안 응답이라 COMMON403 으로 남긴다. 이로써 서비스 계층에서 CommonErrorCode.FORBIDDEN / CONFLICT 를 던지는 곳은 없다. 기존 응답은 전부 "권한이 없습니다" 한 문장이었는데, 이제 어떤 권한이 왜 없는지가 메시지에 드러난다. --- .../domain/feedback/service/FeedbackDetailService.java | 5 +++-- .../slatto/domain/feedback/service/FeedbackService.java | 5 +++-- .../service/RecruitmentApplicationFileService.java | 2 +- .../recruitment/service/RecruitmentApplicationService.java | 4 ++-- .../domain/recruitment/service/RecruitmentService.java | 2 +- .../slatto/domain/schedule/service/ScheduleService.java | 3 ++- .../domain/video/service/VideoReferenceFileService.java | 5 +++-- .../java/com/slatto/domain/video/service/VideoService.java | 7 ++++--- .../RecruitmentApplicationDetailIntegrationTest.java | 4 ++-- .../service/RecruitmentApplicationFileServiceTest.java | 3 +-- 10 files changed, 22 insertions(+), 18 deletions(-) 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 a57d890..4d48a3c 100644 --- a/src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java +++ b/src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java @@ -15,6 +15,7 @@ import org.springframework.data.domain.Pageable; import java.util.List; import com.slatto.domain.feedback.entity.Feedback; +import com.slatto.domain.feedback.exception.FeedbackErrorCode; import com.slatto.domain.feedback.entity.FeedbackDetail; import com.slatto.domain.feedback.repository.FeedbackDetailRepository; import com.slatto.domain.feedback.repository.FeedbackRepository; @@ -230,7 +231,7 @@ public ReplyUpdateResDTO updateReply(Long replyId, Long userId, String guestToke // 4. 본인 확인 if (!reply.isWriter(userId, req.guestId())) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(FeedbackErrorCode.FEEDBACK_REPLY_WRITER_ONLY); } // 5. 수정 @@ -262,7 +263,7 @@ public void deleteReply(Long replyId, Long userId, Long guestId, String guestTok // 4. 본인 확인 if (!reply.isWriter(userId, guestId)) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(FeedbackErrorCode.FEEDBACK_REPLY_WRITER_ONLY); } // 5. soft delete 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 15eb4a8..0db60cf 100644 --- a/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java +++ b/src/main/java/com/slatto/domain/feedback/service/FeedbackService.java @@ -22,6 +22,7 @@ import java.util.Map; import com.slatto.domain.feedback.entity.Feedback; +import com.slatto.domain.feedback.exception.FeedbackErrorCode; import com.slatto.domain.feedback.repository.FeedbackRepository; import com.slatto.domain.sharelink.entity.Guest; import com.slatto.domain.sharelink.entity.ShareLink; @@ -153,7 +154,7 @@ public FeedbackUpdateResDTO updateFeedback(Long feedbackId, Long userId, String // 4. 본인 확인 if (!feedback.isWriter(userId, req.guestId())) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(FeedbackErrorCode.FEEDBACK_WRITER_ONLY); } // 5. 수정 @@ -230,7 +231,7 @@ public void deleteFeedback(Long feedbackId, Long userId, Long guestId, String gu // 4. 본인 확인 if (!feedback.isWriter(userId, guestId)) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(FeedbackErrorCode.FEEDBACK_WRITER_ONLY); } // 5. soft delete diff --git a/src/main/java/com/slatto/domain/recruitment/service/RecruitmentApplicationFileService.java b/src/main/java/com/slatto/domain/recruitment/service/RecruitmentApplicationFileService.java index 2bd462d..580be3b 100644 --- a/src/main/java/com/slatto/domain/recruitment/service/RecruitmentApplicationFileService.java +++ b/src/main/java/com/slatto/domain/recruitment/service/RecruitmentApplicationFileService.java @@ -183,7 +183,7 @@ private void validateFileAccess( return; } - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(RecruitmentErrorCode.APPLICATION_ACCESS_DENIED); } private List normalizeFileIds(List fileIds) { diff --git a/src/main/java/com/slatto/domain/recruitment/service/RecruitmentApplicationService.java b/src/main/java/com/slatto/domain/recruitment/service/RecruitmentApplicationService.java index d5aeb9b..6f33853 100644 --- a/src/main/java/com/slatto/domain/recruitment/service/RecruitmentApplicationService.java +++ b/src/main/java/com/slatto/domain/recruitment/service/RecruitmentApplicationService.java @@ -316,7 +316,7 @@ private void dispatchAppliedNotification(Recruitment recruitment, Users applican private void validateWriter(Recruitment recruitment, Long currentUserId) { if (!recruitment.isWriter(currentUserId)) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(RecruitmentErrorCode.RECRUITMENT_WRITER_ONLY); } } @@ -333,7 +333,7 @@ private void validateApplicationAccess( return; } - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(RecruitmentErrorCode.APPLICATION_ACCESS_DENIED); } // order by id asc + putIfAbsent 가 단건 조회의 "id ASC 첫 행" 규칙과 같은 값을 만든다. diff --git a/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java b/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java index 4702e75..6db7f39 100644 --- a/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java +++ b/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java @@ -418,7 +418,7 @@ private RecruitmentApplicationStatus getMyApplicationStatus(Long recruitmentId, private void validateWriter(Recruitment recruitment, Long currentUserId) { if (!recruitment.isWriter(currentUserId)) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(RecruitmentErrorCode.RECRUITMENT_WRITER_ONLY); } } diff --git a/src/main/java/com/slatto/domain/schedule/service/ScheduleService.java b/src/main/java/com/slatto/domain/schedule/service/ScheduleService.java index 1d84a6d..0b9d5e7 100644 --- a/src/main/java/com/slatto/domain/schedule/service/ScheduleService.java +++ b/src/main/java/com/slatto/domain/schedule/service/ScheduleService.java @@ -20,6 +20,7 @@ import com.slatto.domain.schedule.entity.SchedulePrivateMemo; import com.slatto.domain.schedule.enums.ScheduleQueryScope; import com.slatto.domain.schedule.enums.ScheduleScope; +import com.slatto.domain.schedule.exception.ScheduleErrorCode; import com.slatto.domain.schedule.repository.ScheduleParticipantRepository; import com.slatto.domain.schedule.repository.SchedulePrivateMemoRepository; import com.slatto.domain.schedule.repository.ScheduleRepository; @@ -407,7 +408,7 @@ private Schedule getScheduleOrThrow(Long scheduleId) { private void validateWriter(Schedule schedule, Long currentUserId) { if (!schedule.isWriter(currentUserId)) { - throw new BaseException(CommonErrorCode.FORBIDDEN); + throw new BaseException(ScheduleErrorCode.SCHEDULE_WRITER_ONLY); } } diff --git a/src/main/java/com/slatto/domain/video/service/VideoReferenceFileService.java b/src/main/java/com/slatto/domain/video/service/VideoReferenceFileService.java index 9259d5c..3c34a14 100644 --- a/src/main/java/com/slatto/domain/video/service/VideoReferenceFileService.java +++ b/src/main/java/com/slatto/domain/video/service/VideoReferenceFileService.java @@ -10,6 +10,7 @@ import com.slatto.domain.video.dto.response.VideoResponse.VideoReferenceFileListResDTO; import com.slatto.domain.video.entity.Video; import com.slatto.domain.video.entity.VideoReferenceFile; +import com.slatto.domain.video.exception.VideoErrorCode; import com.slatto.domain.video.repository.VideoReferenceFileRepository; import com.slatto.domain.video.repository.VideoRepository; import com.slatto.global.exception.BaseException; @@ -84,7 +85,7 @@ public VideoReferenceFileCreateResDTO createReferenceFile( .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); if (isAlreadyLinked(projectId, videoId, projectFile.getId())) { - throw new BaseException(CommonErrorCode.CONFLICT); + throw new BaseException(VideoErrorCode.VIDEO_REFERENCE_FILE_ALREADY_LINKED); } VideoReferenceFile referenceFile = VideoReferenceFile.create(video, projectFile, currentMember.getUser()); @@ -93,7 +94,7 @@ public VideoReferenceFileCreateResDTO createReferenceFile( VideoReferenceFile savedReferenceFile = videoReferenceFileRepository.save(referenceFile); return VideoReferenceFileCreateResDTO.from(savedReferenceFile); } catch (DataIntegrityViolationException exception) { - throw new BaseException(CommonErrorCode.CONFLICT); + throw new BaseException(VideoErrorCode.VIDEO_REFERENCE_FILE_ALREADY_LINKED); } } 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 b571a59..e16cbde 100644 --- a/src/main/java/com/slatto/domain/video/service/VideoService.java +++ b/src/main/java/com/slatto/domain/video/service/VideoService.java @@ -22,6 +22,7 @@ import com.slatto.domain.video.dto.response.VideoResponse.VideoUpdateResDTO; import com.slatto.domain.video.dto.response.VideoResponse.YoutubeValidateResDTO; import com.slatto.domain.video.entity.Video; +import com.slatto.domain.video.exception.VideoErrorCode; import com.slatto.domain.video.repository.VideoBookmarkRepository; import com.slatto.domain.video.repository.VideoProjectAccessRepository; import com.slatto.domain.video.repository.VideoRepository; @@ -117,7 +118,7 @@ public YoutubeValidateResDTO validateYoutubeUrl(Long memberId, YoutubeValidateRe String youtubeVideoId = youtubeUrlParser.extractVideoId(request.youtubeUrl()); if (videoRepository.existsByProjectIdAndYoutubeVideoId(request.projectId(), youtubeVideoId)) { - throw new BaseException(CommonErrorCode.CONFLICT); + throw new BaseException(VideoErrorCode.VIDEO_ALREADY_REGISTERED); } YoutubeVideoInfo videoInfo = youtubeApiClient.getVideo(youtubeVideoId) @@ -145,7 +146,7 @@ public VideoCreateResDTO createVideo(Long memberId, Long projectId, VideoCreateR String youtubeVideoId = youtubeUrlParser.extractVideoId(request.youtubeUrl()); if (videoRepository.existsByProjectIdAndYoutubeVideoId(projectId, youtubeVideoId)) { - throw new BaseException(CommonErrorCode.CONFLICT); + throw new BaseException(VideoErrorCode.VIDEO_ALREADY_REGISTERED); } YoutubeVideoInfo videoInfo = youtubeApiClient.getVideo(youtubeVideoId) @@ -172,7 +173,7 @@ public VideoCreateResDTO createVideo(Long memberId, Long projectId, VideoCreateR videoRepository.flush(); return VideoCreateResDTO.from(savedVideo); } catch (DataIntegrityViolationException exception) { - throw new BaseException(CommonErrorCode.CONFLICT); + throw new BaseException(VideoErrorCode.VIDEO_ALREADY_REGISTERED); } } diff --git a/src/test/java/com/slatto/domain/recruitment/service/RecruitmentApplicationDetailIntegrationTest.java b/src/test/java/com/slatto/domain/recruitment/service/RecruitmentApplicationDetailIntegrationTest.java index 32d2189..e72eb51 100644 --- a/src/test/java/com/slatto/domain/recruitment/service/RecruitmentApplicationDetailIntegrationTest.java +++ b/src/test/java/com/slatto/domain/recruitment/service/RecruitmentApplicationDetailIntegrationTest.java @@ -3,6 +3,7 @@ import com.slatto.domain.recruitment.dto.RecruitmentApplicationDetailResponse; import com.slatto.domain.recruitment.entity.Recruitment; import com.slatto.domain.recruitment.entity.RecruitmentApplication; +import com.slatto.domain.recruitment.exception.RecruitmentErrorCode; import com.slatto.domain.recruitment.repository.RecruitmentApplicationRepository; import com.slatto.domain.recruitment.repository.RecruitmentRepository; import com.slatto.domain.user.entity.UserPortfolio; @@ -16,7 +17,6 @@ import com.slatto.domain.user.repository.UserPortfolioRepository; import com.slatto.domain.user.repository.UserRepository; import com.slatto.global.exception.BaseException; -import com.slatto.global.response.code.CommonErrorCode; import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -128,7 +128,7 @@ void deniesDetailToStranger() { )) .isInstanceOf(BaseException.class) .extracting(exception -> ((BaseException) exception).getErrorCode()) - .isEqualTo(CommonErrorCode.FORBIDDEN); + .isEqualTo(RecruitmentErrorCode.APPLICATION_ACCESS_DENIED); } private Users saveUser(String email, String nickname, String socialId) { diff --git a/src/test/java/com/slatto/domain/recruitment/service/RecruitmentApplicationFileServiceTest.java b/src/test/java/com/slatto/domain/recruitment/service/RecruitmentApplicationFileServiceTest.java index 20270e2..cee8633 100644 --- a/src/test/java/com/slatto/domain/recruitment/service/RecruitmentApplicationFileServiceTest.java +++ b/src/test/java/com/slatto/domain/recruitment/service/RecruitmentApplicationFileServiceTest.java @@ -12,7 +12,6 @@ import com.slatto.domain.user.enums.SocialType; import com.slatto.domain.user.repository.UserRepository; import com.slatto.global.exception.BaseException; -import com.slatto.global.response.code.CommonErrorCode; import com.slatto.global.storage.StorageService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -187,7 +186,7 @@ void deniesDownloadToStranger() { )) .isInstanceOf(BaseException.class) .extracting(exception -> ((BaseException) exception).getErrorCode()) - .isEqualTo(CommonErrorCode.FORBIDDEN); + .isEqualTo(RecruitmentErrorCode.APPLICATION_ACCESS_DENIED); } private void givenApplicantCanUpload() { From 780dd5ad11a9a36b707db3a9f46b850bd4650cc4 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 12 Aug 2026 03:45:48 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20@ApiErrorCodes=20=EB=A1=9C=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=97=90=EB=9F=AC=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20=EB=AC=B8=EC=84=9C=EC=97=90=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 403, 409, 410, 429 는 도메인 규칙에서만 나오기 때문에 요청 모양으로 추론할 수 없다. 공통 에러처럼 자동으로 붙이면 실제로 나지 않는 상태 코드까지 문서에 실리므로, 엔드포인트가 직접 밝힌 코드만 싣는다. 애노테이션은 enum 이 아니라 코드 문자열을 받는다. 자바 애노테이션 배열에는 서로 다른 enum 을 섞을 수 없어 ProjectErrorCode 와 CommonErrorCode 를 함께 적을 수 없기 때문이다. 대신 ErrorCodeRegistry 가 BaseCode 구현을 훑어 코드 문자열로 되찾을 수 있게 하고, 오타는 컴파일러 대신 문서 검증 테스트가 잡는다. 커스터마이저는 하나로 둔다. 공통 응답이 먼저 깔린 뒤에 도메인 예시가 얹혀야 같은 상태 코드에서 공통 예시가 밀려나지 않는데, OperationCustomizer 를 둘로 나눠 등록하면 springdoc 이 부르는 순서를 @Order 로 정할 수 없었다. 실제로 도메인 쪽이 먼저 돌아 404 의 공통 예시가 통째로 빠졌다. --- .../slatto/global/config/ApiErrorCodes.java | 31 ++++ .../global/config/DomainErrorResponses.java | 125 ++++++++++++++++ .../global/config/ErrorResponseExamples.java | 34 +++++ .../SwaggerErrorResponseCustomizer.java | 21 +-- .../response/code/ErrorCodeRegistry.java | 94 +++++++++++++ .../config/OpenApiDocumentationTest.java | 133 +++++++++++++++++- .../SwaggerErrorResponseCustomizerTest.java | 65 +++++++++ .../response/code/ErrorCodeRegistryTest.java | 56 ++++++++ 8 files changed, 549 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/slatto/global/config/ApiErrorCodes.java create mode 100644 src/main/java/com/slatto/global/config/DomainErrorResponses.java create mode 100644 src/main/java/com/slatto/global/config/ErrorResponseExamples.java create mode 100644 src/main/java/com/slatto/global/response/code/ErrorCodeRegistry.java create mode 100644 src/test/java/com/slatto/global/config/SwaggerErrorResponseCustomizerTest.java create mode 100644 src/test/java/com/slatto/global/response/code/ErrorCodeRegistryTest.java diff --git a/src/main/java/com/slatto/global/config/ApiErrorCodes.java b/src/main/java/com/slatto/global/config/ApiErrorCodes.java new file mode 100644 index 0000000..c0b1bb3 --- /dev/null +++ b/src/main/java/com/slatto/global/config/ApiErrorCodes.java @@ -0,0 +1,31 @@ +package com.slatto.global.config; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 이 엔드포인트에서 발생할 수 있는 도메인 에러 코드를 적는다. + * + *

공통 에러(400, 401, 404, 413, 500)는 {@code SwaggerErrorResponseCustomizer} 가 + * 조건을 보고 알아서 붙이므로 여기 적지 않는다. + * 403, 409, 410, 429 처럼 도메인 규칙에서만 나오는 응답을 적는 자리다. + * + *

값은 enum 상수가 아니라 코드 문자열이다. + * 애노테이션 배열은 한 가지 타입만 담을 수 있어서 + * 서로 다른 도메인의 enum 을 한 배열에 섞을 수 없기 때문이다. + * + *

+ * @ApiErrorCodes({"PROJECT403", "PROJECT_ADMIN403"})
+ * 
+ */ +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiErrorCodes { + + String[] value(); + +} diff --git a/src/main/java/com/slatto/global/config/DomainErrorResponses.java b/src/main/java/com/slatto/global/config/DomainErrorResponses.java new file mode 100644 index 0000000..d2e4b36 --- /dev/null +++ b/src/main/java/com/slatto/global/config/DomainErrorResponses.java @@ -0,0 +1,125 @@ +package com.slatto.global.config; + +import com.slatto.global.response.code.BaseCode; +import com.slatto.global.response.code.ErrorCodeRegistry; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * {@link ApiErrorCodes} 에 적힌 도메인 에러 응답을 문서에 붙인다. + * + *

403, 409, 410, 429 는 도메인 규칙에서만 나오기 때문에 조건으로 추론할 수 없다. + * 공통 에러처럼 자동으로 판단하지 않고, 엔드포인트가 직접 밝힌 것만 싣는다. + * + *

{@code OperationCustomizer} 로 따로 등록하지 않고 {@link SwaggerErrorResponseCustomizer} 가 마지막에 부른다. + * 공통 응답이 먼저 깔린 뒤에 얹혀야 같은 상태 코드에서 공통 예시를 밀어내지 않는데, + * springdoc 이 커스터마이저를 부르는 순서는 {@code @Order} 로 정해지지 않기 때문이다. + */ +@Component +@RequiredArgsConstructor +public class DomainErrorResponses { + + private static final String JSON = org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + private static final String CODE_FIELD = "code"; + private static final String FALLBACK_EXAMPLE_NAME = "기본"; + private static final String DESCRIPTION_DELIMITER = " / "; + + private final ErrorCodeRegistry errorCodeRegistry; + + public void apply(Operation operation, HandlerMethod handlerMethod) { + ApiErrorCodes declared = handlerMethod.getMethodAnnotation(ApiErrorCodes.class); + ApiResponses responses = operation.getResponses(); + + if (declared == null || responses == null) { + return; + } + + groupByStatus(declared.value()).forEach((status, errorCodes) -> merge(responses, status, errorCodes)); + } + + // 한 상태 코드에 응답 객체는 하나뿐이다. 여러 도메인 코드가 같은 상태를 쓰면 예시로 나눠 담아야 한다. + private Map> groupByStatus(String[] codes) { + Map> grouped = new LinkedHashMap<>(); + + for (String code : codes) { + BaseCode errorCode = errorCodeRegistry.find(code); + String status = String.valueOf(errorCode.getHttpStatus().value()); + + grouped.computeIfAbsent(status, key -> new ArrayList<>()).add(errorCode); + } + + return grouped; + } + + private void merge(ApiResponses responses, String status, List errorCodes) { + ApiResponse response = responses.get(status); + + if (response == null) { + response = new ApiResponse().description(describe(errorCodes)); + responses.addApiResponse(status, response); + } + + if (response.getContent() == null) { + response.setContent(new Content()); + } + + MediaType mediaType = response.getContent().get(JSON); + + if (mediaType == null) { + mediaType = new MediaType().schema(ErrorResponseExamples.schemaRef()); + response.getContent().addMediaType(JSON, mediaType); + } + + moveSingleExampleIntoExamples(mediaType); + + for (BaseCode errorCode : errorCodes) { + mediaType.addExamples(errorCode.getCode(), new Example() + .summary(errorCode.getMessage()) + .value(ErrorResponseExamples.of(errorCode))); + } + } + + // OpenAPI 는 example 과 examples 가 함께 있으면 example 을 버린다. + // 공통 응답에 들어 있는 단일 예시를 그대로 두면 도메인 예시를 얹는 순간 사라진다. + private void moveSingleExampleIntoExamples(MediaType mediaType) { + Object example = mediaType.getExample(); + + if (example == null || mediaType.getExamples() != null) { + return; + } + + mediaType.addExamples(exampleName(example), new Example().value(example)); + + // setExample(null) 만으로는 지워지지 않는다. 값을 넣은 적이 있다는 표시가 남아 example: null 이 그대로 실린다. + mediaType.setExample(null); + mediaType.setExampleSetFlag(false); + } + + private String exampleName(Object example) { + if (example instanceof Map body && body.get(CODE_FIELD) instanceof String code) { + return code; + } + + return FALLBACK_EXAMPLE_NAME; + } + + private String describe(List errorCodes) { + return errorCodes.stream() + .map(BaseCode::getMessage) + .distinct() + .collect(Collectors.joining(DESCRIPTION_DELIMITER)); + } +} diff --git a/src/main/java/com/slatto/global/config/ErrorResponseExamples.java b/src/main/java/com/slatto/global/config/ErrorResponseExamples.java new file mode 100644 index 0000000..ef23374 --- /dev/null +++ b/src/main/java/com/slatto/global/config/ErrorResponseExamples.java @@ -0,0 +1,34 @@ +package com.slatto.global.config; + +import com.slatto.global.response.code.BaseCode; +import io.swagger.v3.oas.models.media.Schema; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 실패 응답 예시를 만든다. + * + *

공통 에러와 도메인 에러를 서로 다른 커스터마이저가 붙이는데, + * 예시 모양이 두 곳에서 갈라지면 같은 상태 코드 안에 형태가 다른 예시가 나란히 실린다. + */ +final class ErrorResponseExamples { + + private ErrorResponseExamples() { + } + + // 예시를 손으로 적으면 코드나 메시지가 바뀔 때 문서만 조용히 낡는다. enum 에서 그대로 가져온다. + static Map of(BaseCode errorCode) { + Map example = new LinkedHashMap<>(); + example.put("isSuccess", false); + example.put("code", errorCode.getCode()); + example.put("message", errorCode.getMessage()); + example.put("result", null); + + return example; + } + + static Schema schemaRef() { + return new Schema<>().$ref(SwaggerConfig.ERROR_RESPONSE_SCHEMA_REF); + } +} diff --git a/src/main/java/com/slatto/global/config/SwaggerErrorResponseCustomizer.java b/src/main/java/com/slatto/global/config/SwaggerErrorResponseCustomizer.java index 5f2069f..1b2df2f 100644 --- a/src/main/java/com/slatto/global/config/SwaggerErrorResponseCustomizer.java +++ b/src/main/java/com/slatto/global/config/SwaggerErrorResponseCustomizer.java @@ -10,6 +10,7 @@ import io.swagger.v3.oas.models.parameters.RequestBody; import io.swagger.v3.oas.models.responses.ApiResponse; import io.swagger.v3.oas.models.responses.ApiResponses; +import lombok.RequiredArgsConstructor; import org.springdoc.core.customizers.OperationCustomizer; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; @@ -27,14 +28,21 @@ * *

실제로 발생할 수 있는 상태 코드만 붙인다. 문서에 있는 상태 코드가 실제로 나지 않으면 * 명세와 구현이 어긋난 것과 같기 때문에, 조건 없이 전부 붙이지 않는다. + * + *

도메인 에러는 {@link DomainErrorResponses} 가 공통 응답을 다 붙인 뒤에 얹는다. + * 커스터마이저를 둘로 나눠 등록하면 springdoc 이 부르는 순서를 이쪽에서 정할 수 없어 + * 도메인 응답이 먼저 자리를 잡고 공통 예시가 통째로 빠지는 일이 생긴다. */ @Component +@RequiredArgsConstructor public class SwaggerErrorResponseCustomizer implements OperationCustomizer { private static final String JSON = org.springframework.http.MediaType.APPLICATION_JSON_VALUE; private static final String MULTIPART = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; private static final String PATH_PARAMETER = "path"; + private final DomainErrorResponses domainErrorResponses; + @Override public Operation customize(Operation operation, HandlerMethod handlerMethod) { ApiResponses responses = operation.getResponses(); @@ -76,6 +84,8 @@ public Operation customize(Operation operation, HandlerMethod handlerMethod) { singleExampleContent(CommonErrorCode.INTERNAL_SERVER_ERROR) ); + domainErrorResponses.apply(operation, handlerMethod); + return operation; } @@ -113,18 +123,11 @@ private Content singleExampleContent(CommonErrorCode errorCode) { } private Schema errorSchemaRef() { - return new Schema<>().$ref(SwaggerConfig.ERROR_RESPONSE_SCHEMA_REF); + return ErrorResponseExamples.schemaRef(); } - // 예시를 손으로 적으면 코드나 메시지가 바뀔 때 문서만 조용히 낡는다. enum 에서 그대로 가져온다. private Map errorExample(CommonErrorCode errorCode) { - Map example = new LinkedHashMap<>(); - example.put("isSuccess", false); - example.put("code", errorCode.getCode()); - example.put("message", errorCode.getMessage()); - example.put("result", null); - - return example; + return ErrorResponseExamples.of(errorCode); } private Map validationFailureExample() { diff --git a/src/main/java/com/slatto/global/response/code/ErrorCodeRegistry.java b/src/main/java/com/slatto/global/response/code/ErrorCodeRegistry.java new file mode 100644 index 0000000..fda7370 --- /dev/null +++ b/src/main/java/com/slatto/global/response/code/ErrorCodeRegistry.java @@ -0,0 +1,94 @@ +package com.slatto.global.response.code; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AssignableTypeFilter; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 코드 문자열로 {@link BaseCode} 를 되찾는다. + * + *

애노테이션 배열에는 한 가지 타입만 담을 수 있어서 + * {@code @ApiErrorCodes({ProjectErrorCode.X, CommonErrorCode.Y})} 같은 표기는 컴파일되지 않는다. + * 그래서 애노테이션은 코드 문자열을 받고, 실제 enum 은 여기서 찾는다. + * + *

문자열이라 오타를 컴파일러가 잡아주지 못한다. + * 대신 문서 검증 테스트가 모든 표기를 이 레지스트리로 해석해보고 실패시킨다. + */ +@Component +public class ErrorCodeRegistry { + + private static final String BASE_PACKAGE = "com.slatto"; + + private final Map codes; + + public ErrorCodeRegistry() { + this.codes = scanErrorCodes(); + } + + public BaseCode find(String code) { + BaseCode found = codes.get(code); + + if (found == null) { + throw new IllegalArgumentException("존재하지 않는 에러 코드입니다: " + code); + } + + return found; + } + + public boolean contains(String code) { + return codes.containsKey(code); + } + + // enum 을 손으로 등록하면 새 도메인을 추가할 때 빠뜨려도 아무 신호가 없다. + // BaseCode 구현체를 훑어서 자동으로 채운다. + private Map scanErrorCodes() { + ClassPathScanningCandidateComponentProvider scanner = + new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AssignableTypeFilter(BaseCode.class)); + + Map found = new LinkedHashMap<>(); + + for (BeanDefinition definition : scanner.findCandidateComponents(BASE_PACKAGE)) { + Class type = resolve(definition.getBeanClassName()); + + if (!type.isEnum()) { + continue; + } + + for (Object constant : type.getEnumConstants()) { + register(found, (BaseCode) constant); + } + } + + return found; + } + + // 코드 문자열이 겹치면 어느 쪽이 문서에 실릴지 정할 수 없다. + // 조용히 덮어쓰는 대신 기동 시점에 깨뜨린다. + private void register(Map found, BaseCode code) { + if (code.isSuccess()) { + return; + } + + BaseCode previous = found.put(code.getCode(), code); + + if (previous != null && previous != code) { + throw new IllegalStateException( + "에러 코드 문자열이 중복됩니다: " + code.getCode() + + " (" + previous.getClass().getSimpleName() + ", " + code.getClass().getSimpleName() + ")" + ); + } + } + + private Class resolve(String className) { + try { + return Class.forName(className); + } catch (ClassNotFoundException exception) { + throw new IllegalStateException("에러 코드 클래스를 읽을 수 없습니다: " + className, exception); + } + } +} diff --git a/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java b/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java index 35e0434..8d4bc0b 100644 --- a/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java +++ b/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java @@ -4,7 +4,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.slatto.global.response.ApiResponse; +import com.slatto.global.response.code.BaseCode; import com.slatto.global.response.code.CommonErrorCode; +import com.slatto.global.response.code.ErrorCodeRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -12,6 +14,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -42,6 +45,12 @@ class OpenApiDocumentationTest { @Autowired private ObjectMapper objectMapper; + @Autowired + private ErrorCodeRegistry errorCodeRegistry; + + @Autowired + private RequestMappingHandlerMapping handlerMapping; + private JsonNode apiDocs; @BeforeEach @@ -139,6 +148,102 @@ void payloadTooLargeIsDocumentedOnlyOnMultipartOperations() { assertThat(wrong).isEmpty(); } + // 코드를 문자열로 적기 때문에 오타를 컴파일러가 잡지 못한다. + // 오타가 나면 문서에 응답이 조용히 빠지므로, 여기서 해석해보고 깨뜨린다. + @Test + @DisplayName("@ApiErrorCodes 에 적힌 코드는 모두 실제로 존재한다") + void declaredErrorCodesExist() { + List unknown = new ArrayList<>(); + + forEachDeclaration((path, httpMethod, code) -> { + if (!errorCodeRegistry.contains(code)) { + unknown.add(httpMethod + " " + path + " → " + code); + } + }); + + assertThat(unknown) + .as("에러 코드 enum 에 없는 코드 문자열") + .isEmpty(); + } + + // 애노테이션만 붙고 문서에 반영되지 않으면 표기해둔 의미가 없다. + // 상태 코드와 예시가 실제 생성 결과에 있는지 확인한다. + @Test + @DisplayName("@ApiErrorCodes 에 적힌 코드는 문서에 해당 상태의 예시로 실린다") + void declaredErrorCodesAppearInDocument() { + List missing = new ArrayList<>(); + + forEachDeclaration((path, httpMethod, code) -> { + BaseCode errorCode = errorCodeRegistry.find(code); + String status = String.valueOf(errorCode.getHttpStatus().value()); + + JsonNode example = apiDocs.path("paths").path(path).path(httpMethod.toLowerCase()) + .path("responses").path(status) + .path("content").path("application/json") + .path("examples").path(code).path("value"); + + if (example.isMissingNode()) { + missing.add(httpMethod + " " + path + " → " + status + " / " + code); + } + }); + + assertThat(missing) + .as("애노테이션에는 있으나 문서에 실리지 않은 도메인 에러 응답") + .isEmpty(); + } + + // 예시 본문을 커스터마이저가 손으로 조립하기 때문에 실제 응답과 갈라질 수 있다. + @Test + @DisplayName("도메인 에러 예시는 실제 실패 응답 값과 일치한다") + void domainErrorExamplesMatchActualResponse() { + List mismatched = new ArrayList<>(); + + forEachDeclaration((path, httpMethod, code) -> { + BaseCode errorCode = errorCodeRegistry.find(code); + String status = String.valueOf(errorCode.getHttpStatus().value()); + + JsonNode example = apiDocs.path("paths").path(path).path(httpMethod.toLowerCase()) + .path("responses").path(status) + .path("content").path("application/json") + .path("examples").path(code).path("value"); + + if (example.isMissingNode()) { + return; + } + + Map documented = objectMapper.convertValue(example, new TypeReference<>() { + }); + + if (!documented.equals(actualResponse(errorCode))) { + mismatched.add(httpMethod + " " + path + " → " + code); + } + }); + + assertThat(mismatched).isEmpty(); + } + + // 도메인 예시를 얹을 때 공통 예시를 examples 로 옮기는데, 이 이사가 실패하면 + // OpenAPI 규칙상 example 이 무시돼 공통 실패 응답이 문서에서 사라진다. + @Test + @DisplayName("실패 응답은 example 과 examples 를 함께 갖지 않는다") + void errorResponsesDoNotMixExampleAndExamples() { + List mixed = new ArrayList<>(); + + forEachOperation((path, httpMethod, operation) -> { + JsonNode responses = operation.path("responses"); + + responses.fieldNames().forEachRemaining(status -> { + JsonNode mediaType = responses.path(status).path("content").path("application/json"); + + if (mediaType.has("example") && mediaType.has("examples")) { + mixed.add(httpMethod.toUpperCase() + " " + path + " → " + status); + } + }); + }); + + assertThat(mixed).isEmpty(); + } + // 401 을 일괄로 붙이면 인증 없이 열린 경로에도 발생하지 않는 상태 코드가 실린다. // 자물쇠 표시와 401 문서화는 항상 같은 판정에서 나와야 한다. @Test @@ -245,7 +350,7 @@ void serverErrorExampleMatchesActualResponse() { } // 실제 응답을 직렬화해서 비교한다. 필드명을 테스트에 적어두면 그 하드코딩도 같이 낡는다. - private Map actualResponse(CommonErrorCode errorCode) { + private Map actualResponse(BaseCode errorCode) { return objectMapper.convertValue(ApiResponse.failure(errorCode), new TypeReference<>() { }); } @@ -302,6 +407,25 @@ private Set fieldNames(JsonNode node) { return names; } + // 문서가 아니라 핸들러에서 애노테이션을 읽는다. + // 문서에서 읽으면 "문서에 실린 것이 문서에 실렸다" 를 확인하게 된다. + private void forEachDeclaration(DeclarationVisitor visitor) { + handlerMapping.getHandlerMethods().forEach((mappingInfo, handlerMethod) -> { + ApiErrorCodes declared = handlerMethod.getMethodAnnotation(ApiErrorCodes.class); + + if (declared == null || mappingInfo.getPathPatternsCondition() == null) { + return; + } + + mappingInfo.getPathPatternsCondition().getPatterns().forEach(pattern -> + mappingInfo.getMethodsCondition().getMethods().forEach(httpMethod -> { + for (String code : declared.value()) { + visitor.visit(pattern.getPatternString(), httpMethod.name(), code); + } + })); + }); + } + private void forEachOperation(OperationVisitor visitor) { JsonNode paths = apiDocs.path("paths"); @@ -323,4 +447,11 @@ private interface OperationVisitor { } + @FunctionalInterface + private interface DeclarationVisitor { + + void visit(String path, String httpMethod, String errorCode); + + } + } diff --git a/src/test/java/com/slatto/global/config/SwaggerErrorResponseCustomizerTest.java b/src/test/java/com/slatto/global/config/SwaggerErrorResponseCustomizerTest.java new file mode 100644 index 0000000..732566f --- /dev/null +++ b/src/test/java/com/slatto/global/config/SwaggerErrorResponseCustomizerTest.java @@ -0,0 +1,65 @@ +package com.slatto.global.config; + +import com.slatto.global.response.code.ErrorCodeRegistry; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.responses.ApiResponses; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.method.HandlerMethod; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 공통 에러와 도메인 에러가 같은 상태 코드에서 부딪힐 때를 확인한다. + * + *

한 상태 코드에 응답 객체는 하나뿐이라, 나중에 붙는 쪽이 앞의 예시를 지워도 문서는 멀쩡해 보인다. + * 실제 엔드포인트에 도메인 코드가 붙기 전까지는 이 충돌이 문서에 드러나지 않기 때문에 여기서 직접 부딪혀 본다. + */ +class SwaggerErrorResponseCustomizerTest { + + private final SwaggerErrorResponseCustomizer customizer = + new SwaggerErrorResponseCustomizer(new DomainErrorResponses(new ErrorCodeRegistry())); + + @Test + @DisplayName("같은 상태 코드에 공통 예시와 도메인 예시를 나란히 싣는다") + void keepsCommonExampleWhenDomainCodeSharesStatus() throws NoSuchMethodException { + MediaType mediaType = customizeNotFound(); + + assertThat(mediaType.getExamples()).containsKeys("COMMON404", "PROJECT404"); + } + + // example 과 examples 가 함께 실리면 OpenAPI 는 example 을 버린다. 값을 비우는 것만으로는 표시가 남는다. + @Test + @DisplayName("도메인 예시를 얹으면 단일 예시 자리를 비운다") + void clearsSingleExampleAfterMerging() throws NoSuchMethodException { + MediaType mediaType = customizeNotFound(); + + assertThat(mediaType.getExample()).isNull(); + assertThat(mediaType.getExampleSetFlag()).isFalse(); + } + + private MediaType customizeNotFound() throws NoSuchMethodException { + Method method = Endpoint.class.getDeclaredMethod("findOne"); + Operation operation = new Operation() + .responses(new ApiResponses()) + .addParametersItem(new Parameter().in("path").name("projectId")); + + customizer.customize(operation, new HandlerMethod(new Endpoint(), method)); + + return operation.getResponses() + .get("404") + .getContent() + .get(org.springframework.http.MediaType.APPLICATION_JSON_VALUE); + } + + private static class Endpoint { + + @ApiErrorCodes("PROJECT404") + void findOne() { + } + } +} diff --git a/src/test/java/com/slatto/global/response/code/ErrorCodeRegistryTest.java b/src/test/java/com/slatto/global/response/code/ErrorCodeRegistryTest.java new file mode 100644 index 0000000..dc7e017 --- /dev/null +++ b/src/test/java/com/slatto/global/response/code/ErrorCodeRegistryTest.java @@ -0,0 +1,56 @@ +package com.slatto.global.response.code; + +import com.slatto.domain.auth.exception.AuthErrorCode; +import com.slatto.domain.feedback.exception.FeedbackErrorCode; +import com.slatto.domain.project.exception.ProjectErrorCode; +import com.slatto.domain.recruitment.exception.RecruitmentErrorCode; +import com.slatto.domain.schedule.exception.ScheduleErrorCode; +import com.slatto.domain.sharelink.exception.ShareLinkErrorCode; +import com.slatto.domain.user.exception.UserErrorCode; +import com.slatto.domain.video.exception.VideoErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 코드 문자열로 에러 코드를 되찾을 수 있는지 확인한다. + * + *

레지스트리가 스스로 훑어 모은 목록을 그대로 다시 물어보면 무엇도 검증하지 못한다. + * 그래서 도메인마다 코드를 하나씩 손으로 적어 대조한다. + * 새 도메인이 스캔에서 빠지거나 enum 이 사라지면 여기서 먼저 깨진다. + */ +class ErrorCodeRegistryTest { + + private final ErrorCodeRegistry registry = new ErrorCodeRegistry(); + + @Test + @DisplayName("모든 도메인의 에러 코드를 코드 문자열로 찾는다") + void findsErrorCodeFromEveryDomain() { + assertThat(registry.find("COMMON500")).isEqualTo(CommonErrorCode.INTERNAL_SERVER_ERROR); + assertThat(registry.find("AUTH401")).isEqualTo(AuthErrorCode.INVALID_REFRESH_TOKEN); + assertThat(registry.find("ONBOARDING409")).isEqualTo(UserErrorCode.ONBOARDING_ALREADY_COMPLETED); + assertThat(registry.find("PROJECT403")).isEqualTo(ProjectErrorCode.PROJECT_ACCESS_DENIED); + assertThat(registry.find("RECRUITMENT403")).isEqualTo(RecruitmentErrorCode.RECRUITMENT_WRITER_ONLY); + assertThat(registry.find("SHARELINK410")).isEqualTo(ShareLinkErrorCode.SHARE_LINK_UNAVAILABLE); + assertThat(registry.find("VIDEO409")).isEqualTo(VideoErrorCode.VIDEO_ALREADY_REGISTERED); + assertThat(registry.find("FEEDBACK403")).isEqualTo(FeedbackErrorCode.FEEDBACK_WRITER_ONLY); + assertThat(registry.find("SCHEDULE403")).isEqualTo(ScheduleErrorCode.SCHEDULE_WRITER_ONLY); + } + + // 성공 코드까지 담으면 @ApiErrorCodes 에 COMMON200 을 적어도 통과한다. + @Test + @DisplayName("성공 코드는 담지 않는다") + void excludesSuccessCodes() { + assertThat(registry.contains(CommonSuccessCode.OK.getCode())).isFalse(); + } + + @Test + @DisplayName("없는 코드를 찾으면 무엇이 없는지 알려주고 실패한다") + void rejectsUnknownCode() { + assertThatThrownBy(() -> registry.find("PROJECT499")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("PROJECT499"); + } +} From 068efe5ee163a5552d4006995223939f29294059 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 12 Aug 2026 03:49:42 +0900 Subject: [PATCH 05/14] =?UTF-8?q?docs:=20=EC=9D=B8=EC=A6=9D=C2=B7=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=C2=B7=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=EC=97=90=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=97=90=EB=9F=AC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=91=9C=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 컨트롤러에서 서비스 호출을 따라가며 실제로 던지는 403 / 409 / 429 만 적었다. 전역 주입 대상인 400 / 401 / 404 / 413 / 500 은 범위 밖이다. 문서 검증 테스트는 @ApiErrorCodes 를 훑어 돌기 때문에 애노테이션이 한 곳도 없으면 훑을 것이 없어 전부 통과한다. 표기가 사라져도 조용히 초록불이 되므로 표기가 존재한다는 것 자체를 먼저 단언한다. --- .../domain/auth/controller/AuthController.java | 3 +++ .../controller/RecentActivityController.java | 4 ++++ .../project/controller/ProjectController.java | 7 +++++++ .../project/controller/ProjectFileController.java | 8 ++++++++ .../controller/ProjectInvitationController.java | 3 +++ .../project/controller/ProjectMemberController.java | 6 ++++++ .../project/controller/ProjectNoticeController.java | 7 +++++++ .../domain/user/controller/UserController.java | 2 ++ .../global/config/OpenApiDocumentationTest.java | 13 +++++++++++++ 9 files changed, 53 insertions(+) diff --git a/src/main/java/com/slatto/domain/auth/controller/AuthController.java b/src/main/java/com/slatto/domain/auth/controller/AuthController.java index 79e70c5..882e679 100644 --- a/src/main/java/com/slatto/domain/auth/controller/AuthController.java +++ b/src/main/java/com/slatto/domain/auth/controller/AuthController.java @@ -13,6 +13,7 @@ import com.slatto.domain.auth.service.AuthService; import com.slatto.domain.auth.service.EmailVerificationService; import com.slatto.domain.auth.support.AuthCookieFactory; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Hidden; @@ -121,6 +122,7 @@ public ApiResponse reissueAccessToken( """ ) @SecurityRequirements + @ApiErrorCodes({"AUTH_SIGNUP_DUPLICATE409", "AUTH_SIGNUP_SOCIAL409"}) @PostMapping("/signup") public ResponseEntity> signup( @Valid @RequestBody EmailSignupRequest request @@ -170,6 +172,7 @@ public ResponseEntity> login( ) @SecurityRequirements @ResponseStatus(HttpStatus.CREATED) + @ApiErrorCodes({"AUTH_VERIFICATION_LIMIT429", "AUTH_VERIFICATION_RESEND429"}) @PostMapping("/email/verification-codes") public ApiResponse sendEmailVerificationCode( @Valid @RequestBody EmailVerificationSendRequest request diff --git a/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java b/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java index aa376a9..fa8e709 100644 --- a/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java +++ b/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java @@ -2,6 +2,7 @@ import com.slatto.domain.notification.dto.ActivityLogListResponse; import com.slatto.domain.notification.service.RecentActivityService; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -29,6 +30,7 @@ public class RecentActivityController { 프로젝트 멤버만 조회할 수 있다. 각 항목에 내가 읽었는지 여부가 함께 담긴다. cursor 는 응답의 nextCursor 를 그대로 넘기는 문자열이며 size 는 기본 20, 최대 50이다.""" ) + @ApiErrorCodes("PROJECT403") @GetMapping public ApiResponse getRecentActivities( @AuthenticationPrincipal Long currentUserId, @@ -50,6 +52,7 @@ public ApiResponse getRecentActivities( summary = "프로젝트 최근활동 단건 읽음 처리", description = "읽음은 호출한 사람에게만 기록된다. 이미 읽은 활동에 다시 호출해도 실패하지 않는다." ) + @ApiErrorCodes("PROJECT403") @PatchMapping("/{activityId}/read") public ApiResponse markActivityAsRead( @AuthenticationPrincipal Long currentUserId, @@ -65,6 +68,7 @@ public ApiResponse markActivityAsRead( summary = "프로젝트 최근활동 전체 읽음 처리", description = "해당 프로젝트의 활동만 읽음 처리한다. 다른 프로젝트에는 영향이 없다." ) + @ApiErrorCodes("PROJECT403") @PatchMapping("/read-all") public ApiResponse markAllActivitiesAsRead( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectController.java b/src/main/java/com/slatto/domain/project/controller/ProjectController.java index 5369c35..26a1df3 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectController.java @@ -8,6 +8,7 @@ import com.slatto.domain.project.dto.ProjectUpdateRequest; import com.slatto.domain.project.enums.ProjectStatus; import com.slatto.domain.project.service.ProjectService; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -59,6 +60,7 @@ public ApiResponse getProjects( 만든 사람이 ADMIN 역할의 멤버로 함께 등록된다. 한 사람이 가질 수 있는 프로젝트는 5개까지이며, 삭제한 프로젝트는 개수에 포함되지 않는다.""" ) + @ApiErrorCodes("PROJECT409") @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse createProject( @@ -74,6 +76,7 @@ public ApiResponse createProject( summary = "프로젝트 상세 조회", description = "프로젝트 멤버만 조회할 수 있다." ) + @ApiErrorCodes("PROJECT403") @GetMapping("/{projectId}") public ApiResponse getProject( @AuthenticationPrincipal Long currentUserId, @@ -104,6 +107,7 @@ public ApiResponse getProject( `title` 은 생성·수정 요청 모두 필수라 실제로는 `kind` 만 이 조건에 걸린다. """ ) + @ApiErrorCodes({"PROJECT403", "PROJECT_ADMIN403", "PROJECT_COMPLETED409"}) @PatchMapping("/{projectId}") public ApiResponse updateProject( @AuthenticationPrincipal Long currentUserId, @@ -119,6 +123,7 @@ public ApiResponse updateProject( summary = "프로젝트 삭제", description = "ADMIN 만 삭제할 수 있다. 실제로 지우지 않고 삭제 표시만 남긴다." ) + @ApiErrorCodes({"PROJECT403", "PROJECT_ADMIN403"}) @DeleteMapping("/{projectId}") public ApiResponse deleteProject( @AuthenticationPrincipal Long currentUserId, @@ -135,6 +140,7 @@ public ApiResponse deleteProject( 고정은 호출한 사람에게만 적용되며 다른 멤버의 목록 순서에는 영향을 주지 않는다. 이미 고정한 프로젝트에 다시 호출해도 실패하지 않는다.""" ) + @ApiErrorCodes("PROJECT403") @PostMapping("/{projectId}/pin") public ApiResponse pinProject( @AuthenticationPrincipal Long currentUserId, @@ -149,6 +155,7 @@ public ApiResponse pinProject( summary = "프로젝트 고정 해제", description = "고정하지 않은 프로젝트에 호출해도 실패하지 않는다." ) + @ApiErrorCodes("PROJECT403") @DeleteMapping("/{projectId}/pin") public ApiResponse unpinProject( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java index 0915d37..145891f 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java @@ -7,6 +7,7 @@ import com.slatto.domain.project.dto.ProjectFileUpdateRequest; import com.slatto.domain.project.dto.ProjectFileUploadRequest; import com.slatto.domain.project.service.ProjectFileService; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -48,6 +49,7 @@ public class ProjectFileController { 고정된 파일이 먼저 오고, keyword 로 파일명을 검색할 수 있다. cursor 기반 페이지네이션이며 size 는 기본 20, 최대 50이다.""" ) + @ApiErrorCodes("PROJECT403") @GetMapping public ApiResponse getProjectFiles( @AuthenticationPrincipal Long currentUserId, @@ -73,6 +75,7 @@ public ApiResponse getProjectFiles( multipart/form-data 로 보낸다. 최대 100MB 이며 pdf, jpg, jpeg, png, doc, docx 만 허용한다. 확장자와 Content-Type 이 서로 맞지 않으면 거부한다. 업로드하면 다른 멤버에게 알림이 간다.""" ) + @ApiErrorCodes("PROJECT403") @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseStatus(HttpStatus.CREATED) public ApiResponse uploadProjectFile( @@ -95,6 +98,7 @@ public ApiResponse uploadProjectFile( summary = "프로젝트 파일 수정", description = "업로더 본인 또는 ADMIN 만 수정할 수 있다. 보내지 않은 필드는 기존 값이 유지된다." ) + @ApiErrorCodes("PROJECT403") @PatchMapping("/{fileId}") public ApiResponse updateProjectFile( @AuthenticationPrincipal Long currentUserId, @@ -118,6 +122,7 @@ public ApiResponse updateProjectFile( 업로더 본인 또는 ADMIN 만 삭제할 수 있다. 삭제 표시만 남기며 저장소의 파일 자체는 지우지 않는다.""" ) + @ApiErrorCodes("PROJECT403") @DeleteMapping("/{fileId}") public ApiResponse deleteProjectFile( @AuthenticationPrincipal Long currentUserId, @@ -135,6 +140,7 @@ public ApiResponse deleteProjectFile( 파일 고정은 프로젝트 멤버 모두에게 함께 보인다. 개인별로 적용되는 프로젝트 고정과 다르다. 업로더 본인 또는 ADMIN 만 할 수 있다.""" ) + @ApiErrorCodes("PROJECT403") @PostMapping("/{fileId}/pin") public ApiResponse pinProjectFile( @AuthenticationPrincipal Long currentUserId, @@ -154,6 +160,7 @@ public ApiResponse pinProjectFile( summary = "프로젝트 파일 고정 해제", description = "업로더 본인 또는 ADMIN 만 할 수 있다." ) + @ApiErrorCodes("PROJECT403") @DeleteMapping("/{fileId}/pin") public ApiResponse unpinProjectFile( @AuthenticationPrincipal Long currentUserId, @@ -173,6 +180,7 @@ public ApiResponse unpinProjectFile( summary = "프로젝트 파일 다운로드", description = "공통 응답 래퍼가 아니라 파일 본문을 그대로 반환한다. Content-Disposition 이 attachment 로 내려간다." ) + @ApiErrorCodes("PROJECT403") @GetMapping("/{fileId}/download") public ResponseEntity downloadProjectFile( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java b/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java index 6000079..ce468b5 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java @@ -6,6 +6,7 @@ import com.slatto.domain.project.dto.ProjectInvitationCreateResponse; import com.slatto.domain.project.dto.ProjectInvitationDetailResponse; import com.slatto.domain.project.service.ProjectInvitationService; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -38,6 +39,7 @@ public class ProjectInvitationController { 원본 토큰은 응답의 inviteUrl 에만 담기고 서버에는 해시로 저장된다. 응답을 잃으면 서버에서 원본 토큰을 되찾거나 같은 링크를 다시 받을 수 없고, 새로 만들어야 한다.""" ) + @ApiErrorCodes({"PROJECT403", "PROJECT_ADMIN403"}) @PostMapping("/projects/{projectId}/invitations") @ResponseStatus(HttpStatus.CREATED) public ApiResponse createInvitation( @@ -76,6 +78,7 @@ public ApiResponse getInvitation( 수락할 때 맡을 역할을 함께 보낸다. 한 번 수락한 링크는 다시 쓸 수 없고, 기간이 지났거나 이미 멤버인 경우에도 실패한다.""" ) + @ApiErrorCodes({"PROJECT_INVITATION409", "PROJECT_MEMBER409"}) @PostMapping("/project-invitations/{token}/accept") public ApiResponse acceptInvitation( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java b/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java index 091adec..6034a2b 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java @@ -4,6 +4,7 @@ import com.slatto.domain.project.dto.ProjectMemberListResponse; import com.slatto.domain.project.dto.ProjectMemberUpdateRequest; import com.slatto.domain.project.service.ProjectMemberService; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -31,6 +32,7 @@ public class ProjectMemberController { summary = "프로젝트 멤버 목록 조회", description = "페이지네이션 없이 전체를 반환한다. 프로젝트를 나간 멤버는 제외된다." ) + @ApiErrorCodes("PROJECT403") @GetMapping public ApiResponse getProjectMembers( @AuthenticationPrincipal Long currentUserId, @@ -48,6 +50,7 @@ public ApiResponse getProjectMembers( summary = "프로젝트 나가기", description = "ADMIN 은 나갈 수 없다. 나가면 멤버 목록에서 빠지지만 작성한 글과 파일은 남는다." ) + @ApiErrorCodes("PROJECT403") @DeleteMapping("/me") public ApiResponse leaveProject( @AuthenticationPrincipal Long currentUserId, @@ -62,6 +65,7 @@ public ApiResponse leaveProject( summary = "프로젝트 멤버 상세 조회", description = "경로의 memberId 는 사용자 ID 가 아니라 프로젝트 멤버 ID 다." ) + @ApiErrorCodes("PROJECT403") @GetMapping("/{memberId}") public ApiResponse getProjectMember( @AuthenticationPrincipal Long currentUserId, @@ -83,6 +87,7 @@ public ApiResponse getProjectMember( ADMIN 이거나 본인의 역할일 때만 수정할 수 있다. 보낸 역할 목록으로 전체를 교체하므로, 유지할 역할도 함께 보내야 한다.""" ) + @ApiErrorCodes("PROJECT403") @PatchMapping("/{memberId}") public ApiResponse updateProjectMemberRoles( @AuthenticationPrincipal Long currentUserId, @@ -106,6 +111,7 @@ public ApiResponse updateProjectMemberRoles( ADMIN 만 다른 멤버를 내보낼 수 있다. 자기 자신은 이 API 로 내보낼 수 없고 나가기를 써야 한다.""" ) + @ApiErrorCodes({"PROJECT403", "PROJECT_ADMIN403"}) @DeleteMapping("/{memberId}") public ApiResponse removeProjectMember( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java b/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java index 8200ece..a645125 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java @@ -6,6 +6,7 @@ import com.slatto.domain.project.dto.ProjectNoticeResponse; import com.slatto.domain.project.dto.ProjectNoticeUpdateRequest; import com.slatto.domain.project.service.ProjectNoticeService; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -39,6 +40,7 @@ public class ProjectNoticeController { 각 항목에 내가 읽었는지 여부가 함께 담긴다. cursor 기반 페이지네이션이며 size 는 기본 20, 최대 50이다.""" ) + @ApiErrorCodes("PROJECT403") @GetMapping public ApiResponse getProjectNotices( @AuthenticationPrincipal Long currentUserId, @@ -60,6 +62,7 @@ public ApiResponse getProjectNotices( summary = "프로젝트 공지 상세 조회", description = "조회만으로는 읽음 처리되지 않는다. 읽음 처리는 별도 엔드포인트를 호출해야 한다." ) + @ApiErrorCodes("PROJECT403") @GetMapping("/{noticeId}") public ApiResponse getProjectNotice( @AuthenticationPrincipal Long currentUserId, @@ -81,6 +84,7 @@ public ApiResponse getProjectNotice( 프로젝트 멤버면 누구나 등록할 수 있다. 작성자 본인은 처음부터 읽음 상태이며, 나머지 멤버에게는 알림이 간다.""" ) + @ApiErrorCodes("PROJECT403") @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse createProjectNotice( @@ -101,6 +105,7 @@ public ApiResponse createProjectNotice( summary = "프로젝트 공지 수정", description = "작성자 본인 또는 ADMIN 만 수정할 수 있다. 제목과 내용을 모두 덮어쓴다." ) + @ApiErrorCodes("PROJECT403") @PatchMapping("/{noticeId}") public ApiResponse updateProjectNotice( @AuthenticationPrincipal Long currentUserId, @@ -122,6 +127,7 @@ public ApiResponse updateProjectNotice( summary = "프로젝트 공지 삭제", description = "작성자 본인 또는 ADMIN 만 삭제할 수 있다. 삭제 표시만 남긴다." ) + @ApiErrorCodes("PROJECT403") @DeleteMapping("/{noticeId}") public ApiResponse deleteProjectNotice( @AuthenticationPrincipal Long currentUserId, @@ -139,6 +145,7 @@ public ApiResponse deleteProjectNotice( 읽음은 호출한 사람에게만 기록된다. 이미 읽은 공지에 다시 호출해도 실패하지 않고, 읽은 시각만 갱신된다.""" ) + @ApiErrorCodes("PROJECT403") @PatchMapping("/{noticeId}/read") public ApiResponse readProjectNotice( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/user/controller/UserController.java b/src/main/java/com/slatto/domain/user/controller/UserController.java index af54a57..112c97f 100644 --- a/src/main/java/com/slatto/domain/user/controller/UserController.java +++ b/src/main/java/com/slatto/domain/user/controller/UserController.java @@ -11,6 +11,7 @@ import com.slatto.domain.user.dto.UserWithdrawRequest; import com.slatto.domain.auth.support.AuthCookieFactory; import com.slatto.domain.user.service.UserService; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -59,6 +60,7 @@ public ApiResponse getMyInfo(@AuthenticationPrincipal Long userI `nickname` 은 특수문자 없이 2~20자, `bio` 는 200자 이하다. """ ) + @ApiErrorCodes("ONBOARDING409") @PostMapping("/onboarding") public ApiResponse completeOnboarding( @AuthenticationPrincipal Long userId, diff --git a/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java b/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java index 8d4bc0b..9095a74 100644 --- a/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java +++ b/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java @@ -148,6 +148,19 @@ void payloadTooLargeIsDocumentedOnlyOnMultipartOperations() { assertThat(wrong).isEmpty(); } + // 아래 검증은 모두 @ApiErrorCodes 를 훑는다. 애노테이션이 한 곳도 없으면 훑을 것이 없어 전부 통과한다. + @Test + @DisplayName("도메인 에러를 표기한 엔드포인트가 존재한다") + void declaresDomainErrorCodesSomewhere() { + List declarations = new ArrayList<>(); + + forEachDeclaration((path, httpMethod, code) -> declarations.add(httpMethod + " " + path + " → " + code)); + + assertThat(declarations) + .as("@ApiErrorCodes 로 표기된 도메인 에러") + .isNotEmpty(); + } + // 코드를 문자열로 적기 때문에 오타를 컴파일러가 잡지 못한다. // 오타가 나면 문서에 응답이 조용히 빠지므로, 여기서 해석해보고 깨뜨린다. @Test From f40b5d9aa43b7c29463f6b3306e72d544ce70221 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 12 Aug 2026 03:50:55 +0900 Subject: [PATCH 06/14] =?UTF-8?q?docs:=20=EC=B1=84=EC=9A=A9=C2=B7=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=EB=A7=81=ED=81=AC=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=EC=97=90=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=20=ED=91=9C=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공고 작성자만 되는 것과 지원 본인도 되는 것을 나눠 적었다. 지원자 목록과 상태 변경은 RECRUITMENT403, 지원 상세와 첨부 다운로드는 APPLICATION403 이다. 공유 링크 생성과 토글은 프로젝트 멤버 검증을 거치므로 SHARELINK403 이 아니라 PROJECT403 이 나간다. 토큰으로 들어오는 경로만 410 을 낸다. --- .../controller/RecruitmentApplicationController.java | 5 +++++ .../controller/RecruitmentApplicationFileController.java | 2 ++ .../recruitment/controller/RecruitmentController.java | 3 +++ .../domain/sharelink/controller/ShareLinkController.java | 6 ++++++ 4 files changed, 16 insertions(+) diff --git a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java index 5fbf47f..1e49615 100644 --- a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java +++ b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java @@ -7,6 +7,7 @@ import com.slatto.domain.recruitment.dto.RecruitmentApplicationStatusUpdateRequest; import com.slatto.domain.recruitment.enums.RecruitmentApplicationStatus; import com.slatto.domain.recruitment.service.RecruitmentApplicationService; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -43,6 +44,7 @@ public class RecruitmentApplicationController { 첨부를 의도한 지원이 첨부 없이 접수되면 지원자는 성공 응답을 받고도 서류가 빠진 상태가 되기 때문이다. """ ) + @ApiErrorCodes("APPLICATION409") @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse applyToRecruitment( @@ -63,6 +65,7 @@ public ApiResponse applyToRecruitment( summary = "구인구직 공고 지원자 목록 조회", description = "공고 작성자만 조회할 수 있다. nextCursor 는 지원 ID 기준이다." ) + @ApiErrorCodes("RECRUITMENT403") @GetMapping public ApiResponse getApplicants( @AuthenticationPrincipal Long currentUserId, @@ -108,6 +111,7 @@ public ApiResponse getApplicants( 본 항목을 열 수 없게 되기 때문이다. """ ) + @ApiErrorCodes("APPLICATION403") @GetMapping("/{applicationId}") public ApiResponse getApplicationDetail( @AuthenticationPrincipal Long currentUserId, @@ -127,6 +131,7 @@ public ApiResponse getApplicationDetail( summary = "구인구직 공고 지원 상태 변경", description = "공고 작성자만 변경할 수 있다. PENDING 상태의 지원만 ACCEPTED 또는 REJECTED 로 바꿀 수 있다." ) + @ApiErrorCodes("RECRUITMENT403") @PatchMapping("/{applicationId}") public ApiResponse changeApplicationStatus( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationFileController.java b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationFileController.java index f42ceba..dce97f7 100644 --- a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationFileController.java +++ b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationFileController.java @@ -3,6 +3,7 @@ import com.slatto.domain.recruitment.dto.RecruitmentApplicationFileDownloadResponse; import com.slatto.domain.recruitment.dto.RecruitmentApplicationFileResponse; import com.slatto.domain.recruitment.service.RecruitmentApplicationFileService; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -71,6 +72,7 @@ public ApiResponse uploadApplicationFile( summary = "지원 첨부 파일 다운로드", description = "공고 작성자와 지원 본인만 받을 수 있다. 그 외에는 403 이다." ) + @ApiErrorCodes("APPLICATION403") @GetMapping("/applications/{applicationId}/files/{fileId}/download") public ResponseEntity downloadApplicationFile( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java index 33f8709..c1f7488 100644 --- a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java +++ b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java @@ -12,6 +12,7 @@ import com.slatto.domain.user.enums.CategoryName; import com.slatto.domain.user.enums.RegionName; import com.slatto.domain.user.enums.RoleName; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -153,6 +154,7 @@ public ApiResponse getRecruitment( 공고를 되살리려면 `deadline` 도 함께 보내야 한다. """ ) + @ApiErrorCodes("RECRUITMENT403") @PatchMapping("/{recruitmentId}") public ApiResponse updateRecruitment( @AuthenticationPrincipal Long currentUserId, @@ -172,6 +174,7 @@ public ApiResponse updateRecruitment( summary = "구인구직 공고 삭제", description = "작성자 본인만 삭제할 수 있다. 삭제 표시만 남긴다." ) + @ApiErrorCodes("RECRUITMENT403") @DeleteMapping("/{recruitmentId}") public ApiResponse deleteRecruitment( @AuthenticationPrincipal Long currentUserId, 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 03e2d23..d2b1bf4 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.global.config.ApiErrorCodes; import org.springframework.security.core.annotation.AuthenticationPrincipal; import com.slatto.domain.sharelink.service.ShareLinkService; import com.slatto.global.response.ApiResponse; @@ -29,6 +30,7 @@ public class ShareLinkController { @Operation(summary = "공유 링크 생성", description = "영상당 1개만 생성 가능하며, 이미 있으면 409를 반환합니다.") @ResponseStatus(HttpStatus.CREATED) + @ApiErrorCodes({"PROJECT403", "SHARELINK409"}) @PostMapping("/videos/{videoId}/share-links") public ApiResponse createShareLink( @PathVariable Long videoId, @@ -43,6 +45,7 @@ public ApiResponse createShareLink( @Operation(summary = "공유 링크 진입 검증", description = "게스트가 링크로 접근했을 때 유효성을 확인합니다. 인증이 필요 없습니다.") @SecurityRequirements + @ApiErrorCodes("SHARELINK410") @GetMapping("/share-links/{token}") public ApiResponse getShareLinkByToken( @PathVariable String token @@ -56,6 +59,7 @@ public ApiResponse getShareLinkByToken( @Operation(summary = "게스트 등록", description = "링크로 진입한 게스트가 이름을 등록하고 guestId를 발급받습니다. 인증이 필요 없습니다.") @ResponseStatus(HttpStatus.CREATED) @SecurityRequirements + @ApiErrorCodes("SHARELINK410") @PostMapping("/share-links/{token}/guests") public ApiResponse registerGuest( @PathVariable String token, @@ -68,6 +72,7 @@ public ApiResponse registerGuest( } @Operation(summary = "공유 링크 조회 (소유자용)", description = "영상의 공유 링크를 조회합니다. 프로젝트 멤버만 가능합니다.") + @ApiErrorCodes("PROJECT403") @GetMapping("/videos/{videoId}/share-links") public ApiResponse< ShareLinkInfoResDTO> getShareLinkByVideo( @@ -81,6 +86,7 @@ ShareLinkInfoResDTO> getShareLinkByVideo( } @Operation(summary = "공유 링크 활성/비활성 토글", description = "공유 링크의 활성 상태를 뒤집습니다. 프로젝트 멤버만 가능합니다.") + @ApiErrorCodes("PROJECT403") @PatchMapping("/share-links/{shareLinkId}") public ApiResponse toggleShareLink( @PathVariable Long shareLinkId, From 5f61c606eab5ed9e77744aab9e515bb1dbeb7380 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 12 Aug 2026 03:53:11 +0900 Subject: [PATCH 07/14] =?UTF-8?q?docs:=20=EC=98=81=EC=83=81=C2=B7=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=C2=B7=EC=9D=BC=EC=A0=95=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=EC=97=90=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=20=ED=91=9C?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 피드백과 답글은 멤버와 게스트가 같은 경로로 들어온다. 어느 쪽으로 들어오느냐에 따라 PROJECT403 과 SHARELINK403 / SHARELINK410 이 갈리므로 셋을 함께 적었다. 수정과 삭제만 작성자 검증이 더 붙는다. 일정 비공개 메모는 개인 일정이면 작성자만, 프로젝트 일정이면 멤버만 접근한다. 한쪽만 적으면 나머지 경로가 문서에서 빠져 둘 다 적었다. --- .../domain/feedback/controller/FeedbackController.java | 6 ++++++ .../feedback/controller/FeedbackDetailController.java | 6 ++++++ .../domain/schedule/controller/ScheduleController.java | 7 +++++++ .../slatto/domain/video/controller/VideoController.java | 7 +++++++ .../video/controller/VideoReferenceFileController.java | 4 ++++ .../slatto/domain/video/controller/YoutubeController.java | 2 ++ 6 files changed, 32 insertions(+) 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 5da8ffc..048aa78 100644 --- a/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java +++ b/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java @@ -8,6 +8,7 @@ import com.slatto.domain.feedback.dto.request.FeedbackRequest.FeedbackStatusReqDTO; import com.slatto.domain.feedback.dto.response.FeedbackResponse.FeedbackStatusResDTO; import com.slatto.domain.feedback.service.FeedbackService; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.config.OptionalAuthentication; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; @@ -30,6 +31,7 @@ public class FeedbackController { @Operation(summary = "피드백 작성") @OptionalAuthentication + @ApiErrorCodes({"PROJECT403", "SHARELINK403", "SHARELINK410"}) @PostMapping("/videos/{videoId}/feedbacks") public ResponseEntity> createFeedback( @PathVariable Long videoId, @@ -45,6 +47,7 @@ public ResponseEntity> createFeedback( @Operation(summary = "피드백 수정") @OptionalAuthentication + @ApiErrorCodes({"FEEDBACK403", "PROJECT403", "SHARELINK403", "SHARELINK410"}) @PatchMapping("/feedbacks/{feedbackId}") public ResponseEntity> updateFeedback( @PathVariable Long feedbackId, @@ -59,6 +62,7 @@ public ResponseEntity> updateFeedback( @Operation(summary = "피드백 삭제") @OptionalAuthentication + @ApiErrorCodes({"FEEDBACK403", "PROJECT403", "SHARELINK403", "SHARELINK410"}) @DeleteMapping("/feedbacks/{feedbackId}") public ResponseEntity> deleteFeedback( @PathVariable Long feedbackId, @@ -73,6 +77,7 @@ public ResponseEntity> deleteFeedback( @Operation(summary = "피드백 목록 조회") @OptionalAuthentication + @ApiErrorCodes({"PROJECT403", "SHARELINK403", "SHARELINK410"}) @GetMapping("/videos/{videoId}/feedbacks") public ResponseEntity> getFeedbackList( @PathVariable Long videoId, @@ -91,6 +96,7 @@ public ResponseEntity> getFeedbackList( summary = "피드백 해결 상태 변경", description = "활성 프로젝트 멤버만 변경할 수 있다. 다른 피드백 API 와 달리 게스트는 호출할 수 없다." ) + @ApiErrorCodes("PROJECT403") @PatchMapping("/feedbacks/{feedbackId}/status") public ResponseEntity> changeFeedbackStatus( @PathVariable Long feedbackId, 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 ed8a465..e064770 100644 --- a/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java +++ b/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java @@ -8,6 +8,7 @@ import com.slatto.domain.feedback.dto.request.FeedbackDetailRequest.ReplyStatusReqDTO; import com.slatto.domain.feedback.dto.response.FeedbackDetailResponse.ReplyStatusResDTO; import com.slatto.domain.feedback.service.FeedbackDetailService; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.config.OptionalAuthentication; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; @@ -30,6 +31,7 @@ public class FeedbackDetailController { @Operation(summary = "답글 작성") @OptionalAuthentication + @ApiErrorCodes({"PROJECT403", "SHARELINK403", "SHARELINK410"}) @PostMapping("/feedbacks/{feedbackId}/replies") public ResponseEntity> createReply( @PathVariable Long feedbackId, @@ -46,6 +48,7 @@ public ResponseEntity> createReply( @Operation(summary = "답글 목록 조회") @OptionalAuthentication + @ApiErrorCodes({"PROJECT403", "SHARELINK403", "SHARELINK410"}) @GetMapping("/feedbacks/{feedbackId}/replies") public ResponseEntity> getReplyList( @PathVariable Long feedbackId, @@ -63,6 +66,7 @@ public ResponseEntity> getReplyList( @Operation(summary = "답글 수정") @OptionalAuthentication + @ApiErrorCodes({"FEEDBACK_REPLY403", "PROJECT403", "SHARELINK403", "SHARELINK410"}) @PatchMapping("/replies/{replyId}") public ResponseEntity> updateReply( @PathVariable Long replyId, @@ -78,6 +82,7 @@ public ResponseEntity> updateReply( @Operation(summary = "답글 삭제") @OptionalAuthentication + @ApiErrorCodes({"FEEDBACK_REPLY403", "PROJECT403", "SHARELINK403", "SHARELINK410"}) @DeleteMapping("/replies/{replyId}") public ResponseEntity> deleteReply( @PathVariable Long replyId, @@ -95,6 +100,7 @@ public ResponseEntity> deleteReply( summary = "답글 해결 상태 변경", description = "활성 프로젝트 멤버만 변경할 수 있다. 다른 답글 API 와 달리 게스트는 호출할 수 없다." ) + @ApiErrorCodes("PROJECT403") @PatchMapping("/replies/{replyId}/status") public ResponseEntity> changeReplyStatus( @PathVariable Long replyId, diff --git a/src/main/java/com/slatto/domain/schedule/controller/ScheduleController.java b/src/main/java/com/slatto/domain/schedule/controller/ScheduleController.java index f8f868f..611d4bf 100644 --- a/src/main/java/com/slatto/domain/schedule/controller/ScheduleController.java +++ b/src/main/java/com/slatto/domain/schedule/controller/ScheduleController.java @@ -9,6 +9,7 @@ import com.slatto.domain.schedule.dto.ScheduleUpdateRequest; import com.slatto.domain.schedule.enums.ScheduleQueryScope; import com.slatto.domain.schedule.service.ScheduleService; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -45,6 +46,7 @@ public class ScheduleController { summary = "특정 날짜 일정 조회", description = "선택한 날짜에 표시할 일정 목록을 조회합니다. 응답에는 일정 대상자, 공용 메모, 로그인한 사용자의 개인 메모, 수정 가능 여부가 포함됩니다." ) + @ApiErrorCodes("PROJECT403") @GetMapping("/daily") public ApiResponse getDailySchedules( @AuthenticationPrincipal Long currentUserId, @@ -69,6 +71,7 @@ public ApiResponse getDailySchedules( summary = "통합 캘린더 일정 조회", description = "캘린더에 표시할 일정 목록을 기간 기준으로 조회합니다. 프로젝트 캘린더 조회 시 scope=PROJECT와 projectId를 함께 전달합니다." ) + @ApiErrorCodes("PROJECT403") @GetMapping public ApiResponse getCalendarSchedules( @AuthenticationPrincipal Long currentUserId, @@ -96,6 +99,7 @@ public ApiResponse getCalendarSchedules( summary = "일정 생성", description = "개인 일정 또는 프로젝트 일정을 생성합니다. PERSONAL 일정은 projectId와 participantIds를 전달하지 않고, PROJECT 일정은 projectId와 participantIds가 필요합니다." ) + @ApiErrorCodes("PROJECT403") @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse createSchedule( @@ -111,6 +115,7 @@ public ApiResponse createSchedule( summary = "일정 수정", description = "일정 생성자만 일정을 수정할 수 있습니다. 전달된 항목만 부분 수정되며, 프로젝트 일정의 participantIds를 전달하지 않으면 기존 대상자를 유지하고 빈 배열을 전달하면 모든 대상자를 제거합니다." ) + @ApiErrorCodes("SCHEDULE403") @PatchMapping("/{scheduleId}") public ApiResponse updateSchedule( @AuthenticationPrincipal Long currentUserId, @@ -127,6 +132,7 @@ public ApiResponse updateSchedule( summary = "일정 개인 메모 저장/수정", description = "로그인한 사용자가 볼 수 있는 일정에 대해 나에게만 보이는 개인 메모를 저장하거나 수정합니다." ) + @ApiErrorCodes({"SCHEDULE403", "PROJECT403"}) @PatchMapping("/{scheduleId}/private-memo") public ApiResponse upsertPrivateMemo( @AuthenticationPrincipal Long currentUserId, @@ -147,6 +153,7 @@ public ApiResponse upsertPrivateMemo( summary = "일정 삭제", description = "일정 생성자만 일정을 삭제할 수 있습니다. 일정, 일정 대상자, 개인 메모는 soft delete 처리됩니다." ) + @ApiErrorCodes("SCHEDULE403") @DeleteMapping("/{scheduleId}") public ApiResponse deleteSchedule( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/video/controller/VideoController.java b/src/main/java/com/slatto/domain/video/controller/VideoController.java index 8617866..dcf3c3f 100644 --- a/src/main/java/com/slatto/domain/video/controller/VideoController.java +++ b/src/main/java/com/slatto/domain/video/controller/VideoController.java @@ -10,6 +10,7 @@ import com.slatto.domain.video.dto.response.VideoResponse.VideoListResDTO; import com.slatto.domain.video.dto.response.VideoResponse.VideoUpdateResDTO; import com.slatto.domain.video.service.VideoService; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -43,6 +44,7 @@ public class VideoController { private final VideoService videoService; + @ApiErrorCodes("PROJECT403") @GetMapping("/{videoId}") @Operation( summary = "영상 상세 조회", @@ -62,6 +64,7 @@ public ApiResponse getVideo( ); } + @ApiErrorCodes("PROJECT403") @PatchMapping("/{videoId}/bookmark") @Operation( summary = "영상 북마크 상태 변경", @@ -82,6 +85,7 @@ public ApiResponse updateBookmark( ); } + @ApiErrorCodes("PROJECT403") @PatchMapping("/{videoId}") @Operation(summary = "영상 수정", description = "프로젝트 멤버가 영상의 제목과 메모를 수정합니다. " + @@ -100,6 +104,7 @@ public ApiResponse updateVideo( ); } + @ApiErrorCodes("PROJECT403") @DeleteMapping("/{videoId}") @Operation(summary = "영상 삭제", description = "프로젝트 멤버가 프로젝트에 등록된 영상을 삭제합니다.") public ApiResponse deleteVideo( @@ -115,6 +120,7 @@ public ApiResponse deleteVideo( ); } + @ApiErrorCodes({"PROJECT403", "VIDEO409"}) @PostMapping @ResponseStatus(HttpStatus.CREATED) @Operation( @@ -134,6 +140,7 @@ public ApiResponse createVideo( ); } + @ApiErrorCodes("PROJECT403") @GetMapping @Operation( summary = "영상 목록 조회", diff --git a/src/main/java/com/slatto/domain/video/controller/VideoReferenceFileController.java b/src/main/java/com/slatto/domain/video/controller/VideoReferenceFileController.java index 8e1e1a0..52c30f8 100644 --- a/src/main/java/com/slatto/domain/video/controller/VideoReferenceFileController.java +++ b/src/main/java/com/slatto/domain/video/controller/VideoReferenceFileController.java @@ -5,6 +5,7 @@ import com.slatto.domain.video.dto.response.VideoResponse.VideoReferenceFileDeleteResDTO; import com.slatto.domain.video.dto.response.VideoResponse.VideoReferenceFileListResDTO; import com.slatto.domain.video.service.VideoReferenceFileService; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -37,6 +38,7 @@ public class VideoReferenceFileController { private final VideoReferenceFileService videoReferenceFileService; + @ApiErrorCodes("PROJECT403") @GetMapping @Operation( summary = "영상 참조 파일 목록 조회", @@ -68,6 +70,7 @@ public ApiResponse getReferenceFiles( return ApiResponse.success(CommonSuccessCode.OK, response); } + @ApiErrorCodes({"PROJECT403", "VIDEO_REFERENCE_FILE409"}) @PostMapping @ResponseStatus(HttpStatus.CREATED) @Operation( @@ -92,6 +95,7 @@ public ApiResponse createReferenceFile( return ApiResponse.success(CommonSuccessCode.CREATED, response); } + @ApiErrorCodes("PROJECT403") @DeleteMapping("/{referenceFileId}") @Operation( summary = "영상 참조 파일 연결 제거", diff --git a/src/main/java/com/slatto/domain/video/controller/YoutubeController.java b/src/main/java/com/slatto/domain/video/controller/YoutubeController.java index 7bfc8cb..184dc44 100644 --- a/src/main/java/com/slatto/domain/video/controller/YoutubeController.java +++ b/src/main/java/com/slatto/domain/video/controller/YoutubeController.java @@ -3,6 +3,7 @@ import com.slatto.domain.video.dto.request.VideoRequest.YoutubeValidateReqDTO; import com.slatto.domain.video.dto.response.VideoResponse.YoutubeValidateResDTO; import com.slatto.domain.video.service.VideoService; +import com.slatto.global.config.ApiErrorCodes; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; @@ -23,6 +24,7 @@ public class YoutubeController { private final VideoService videoService; + @ApiErrorCodes({"PROJECT403", "VIDEO409"}) @PostMapping("/validate") @Operation( summary = "YouTube URL 검증", From fddb54da2eb0cb97a1cb7b12425ed78479d709af Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 12 Aug 2026 04:23:39 +0900 Subject: [PATCH 08/14] =?UTF-8?q?docs:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20404?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=EC=97=90=20=ED=91=9C?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 404 는 상태 코드만 문서화돼 있고 예시는 항상 COMMON404 였다. 실제로는 getProjectOrThrow 등이 PROJECT404 를 던져 프론트가 문서대로 분기하면 맞지 않는 엔드포인트가 30 개가 넘었다. 도메인 404 를 실제로 던지는 경로만 골라 표기한다. 서비스 메서드를 따라가 호출 여부를 확인했고, 프로젝트 조회를 거치지 않는 엔드포인트에는 붙이지 않았다. 404 는 공통 응답이 이미 깔려 있는 유일한 상태라서, 이번 표기로 공통 예시와 도메인 예시를 나란히 싣는 병합 경로가 실제 문서에서 처음으로 동작한다. 지금까지 단위 테스트로만 검증되던 경로다. 공통 예시가 밀려나면 example 과 examples 가 섞이지 않아 기존 검증을 빠져나가므로, 문서 테스트를 하나 추가해 잠근다. --- .../controller/RecentActivityController.java | 6 ++-- .../project/controller/ProjectController.java | 10 +++--- .../controller/ProjectFileController.java | 14 ++++---- .../ProjectInvitationController.java | 5 +-- .../controller/ProjectMemberController.java | 10 +++--- .../controller/ProjectNoticeController.java | 12 +++---- .../controller/ScheduleController.java | 6 ++-- .../controller/ShareLinkController.java | 8 ++--- .../VideoReferenceFileController.java | 6 ++-- .../config/OpenApiDocumentationTest.java | 33 +++++++++++++++++++ 10 files changed, 72 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java b/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java index fa8e709..5b7dcd7 100644 --- a/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java +++ b/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java @@ -30,7 +30,7 @@ public class RecentActivityController { 프로젝트 멤버만 조회할 수 있다. 각 항목에 내가 읽었는지 여부가 함께 담긴다. cursor 는 응답의 nextCursor 를 그대로 넘기는 문자열이며 size 는 기본 20, 최대 50이다.""" ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @GetMapping public ApiResponse getRecentActivities( @AuthenticationPrincipal Long currentUserId, @@ -52,7 +52,7 @@ public ApiResponse getRecentActivities( summary = "프로젝트 최근활동 단건 읽음 처리", description = "읽음은 호출한 사람에게만 기록된다. 이미 읽은 활동에 다시 호출해도 실패하지 않는다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @PatchMapping("/{activityId}/read") public ApiResponse markActivityAsRead( @AuthenticationPrincipal Long currentUserId, @@ -68,7 +68,7 @@ public ApiResponse markActivityAsRead( summary = "프로젝트 최근활동 전체 읽음 처리", description = "해당 프로젝트의 활동만 읽음 처리한다. 다른 프로젝트에는 영향이 없다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @PatchMapping("/read-all") public ApiResponse markAllActivitiesAsRead( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectController.java b/src/main/java/com/slatto/domain/project/controller/ProjectController.java index 26a1df3..50c9019 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectController.java @@ -76,7 +76,7 @@ public ApiResponse createProject( summary = "프로젝트 상세 조회", description = "프로젝트 멤버만 조회할 수 있다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @GetMapping("/{projectId}") public ApiResponse getProject( @AuthenticationPrincipal Long currentUserId, @@ -107,7 +107,7 @@ public ApiResponse getProject( `title` 은 생성·수정 요청 모두 필수라 실제로는 `kind` 만 이 조건에 걸린다. """ ) - @ApiErrorCodes({"PROJECT403", "PROJECT_ADMIN403", "PROJECT_COMPLETED409"}) + @ApiErrorCodes({"PROJECT403", "PROJECT_ADMIN403", "PROJECT404", "PROJECT_COMPLETED409"}) @PatchMapping("/{projectId}") public ApiResponse updateProject( @AuthenticationPrincipal Long currentUserId, @@ -123,7 +123,7 @@ public ApiResponse updateProject( summary = "프로젝트 삭제", description = "ADMIN 만 삭제할 수 있다. 실제로 지우지 않고 삭제 표시만 남긴다." ) - @ApiErrorCodes({"PROJECT403", "PROJECT_ADMIN403"}) + @ApiErrorCodes({"PROJECT403", "PROJECT_ADMIN403", "PROJECT404"}) @DeleteMapping("/{projectId}") public ApiResponse deleteProject( @AuthenticationPrincipal Long currentUserId, @@ -140,7 +140,7 @@ public ApiResponse deleteProject( 고정은 호출한 사람에게만 적용되며 다른 멤버의 목록 순서에는 영향을 주지 않는다. 이미 고정한 프로젝트에 다시 호출해도 실패하지 않는다.""" ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @PostMapping("/{projectId}/pin") public ApiResponse pinProject( @AuthenticationPrincipal Long currentUserId, @@ -155,7 +155,7 @@ public ApiResponse pinProject( summary = "프로젝트 고정 해제", description = "고정하지 않은 프로젝트에 호출해도 실패하지 않는다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @DeleteMapping("/{projectId}/pin") public ApiResponse unpinProject( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java index 145891f..0c93d61 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java @@ -49,7 +49,7 @@ public class ProjectFileController { 고정된 파일이 먼저 오고, keyword 로 파일명을 검색할 수 있다. cursor 기반 페이지네이션이며 size 는 기본 20, 최대 50이다.""" ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404", "PROJECT_FILE404"}) @GetMapping public ApiResponse getProjectFiles( @AuthenticationPrincipal Long currentUserId, @@ -75,7 +75,7 @@ public ApiResponse getProjectFiles( multipart/form-data 로 보낸다. 최대 100MB 이며 pdf, jpg, jpeg, png, doc, docx 만 허용한다. 확장자와 Content-Type 이 서로 맞지 않으면 거부한다. 업로드하면 다른 멤버에게 알림이 간다.""" ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @ResponseStatus(HttpStatus.CREATED) public ApiResponse uploadProjectFile( @@ -98,7 +98,7 @@ public ApiResponse uploadProjectFile( summary = "프로젝트 파일 수정", description = "업로더 본인 또는 ADMIN 만 수정할 수 있다. 보내지 않은 필드는 기존 값이 유지된다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404", "PROJECT_FILE404"}) @PatchMapping("/{fileId}") public ApiResponse updateProjectFile( @AuthenticationPrincipal Long currentUserId, @@ -122,7 +122,7 @@ public ApiResponse updateProjectFile( 업로더 본인 또는 ADMIN 만 삭제할 수 있다. 삭제 표시만 남기며 저장소의 파일 자체는 지우지 않는다.""" ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404", "PROJECT_FILE404"}) @DeleteMapping("/{fileId}") public ApiResponse deleteProjectFile( @AuthenticationPrincipal Long currentUserId, @@ -140,7 +140,7 @@ public ApiResponse deleteProjectFile( 파일 고정은 프로젝트 멤버 모두에게 함께 보인다. 개인별로 적용되는 프로젝트 고정과 다르다. 업로더 본인 또는 ADMIN 만 할 수 있다.""" ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404", "PROJECT_FILE404"}) @PostMapping("/{fileId}/pin") public ApiResponse pinProjectFile( @AuthenticationPrincipal Long currentUserId, @@ -160,7 +160,7 @@ public ApiResponse pinProjectFile( summary = "프로젝트 파일 고정 해제", description = "업로더 본인 또는 ADMIN 만 할 수 있다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404", "PROJECT_FILE404"}) @DeleteMapping("/{fileId}/pin") public ApiResponse unpinProjectFile( @AuthenticationPrincipal Long currentUserId, @@ -180,7 +180,7 @@ public ApiResponse unpinProjectFile( summary = "프로젝트 파일 다운로드", description = "공통 응답 래퍼가 아니라 파일 본문을 그대로 반환한다. Content-Disposition 이 attachment 로 내려간다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404", "PROJECT_FILE404"}) @GetMapping("/{fileId}/download") public ResponseEntity downloadProjectFile( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java b/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java index ce468b5..2ea0bf3 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java @@ -39,7 +39,7 @@ public class ProjectInvitationController { 원본 토큰은 응답의 inviteUrl 에만 담기고 서버에는 해시로 저장된다. 응답을 잃으면 서버에서 원본 토큰을 되찾거나 같은 링크를 다시 받을 수 없고, 새로 만들어야 한다.""" ) - @ApiErrorCodes({"PROJECT403", "PROJECT_ADMIN403"}) + @ApiErrorCodes({"PROJECT403", "PROJECT_ADMIN403", "PROJECT404"}) @PostMapping("/projects/{projectId}/invitations") @ResponseStatus(HttpStatus.CREATED) public ApiResponse createInvitation( @@ -63,6 +63,7 @@ public ApiResponse createInvitation( status 로 PENDING, ACCEPTED, EXPIRED 를 구분한다.""" ) @SecurityRequirements + @ApiErrorCodes("PROJECT_INVITATION404") @GetMapping("/project-invitations/{token}") public ApiResponse getInvitation( @PathVariable String token @@ -78,7 +79,7 @@ public ApiResponse getInvitation( 수락할 때 맡을 역할을 함께 보낸다. 한 번 수락한 링크는 다시 쓸 수 없고, 기간이 지났거나 이미 멤버인 경우에도 실패한다.""" ) - @ApiErrorCodes({"PROJECT_INVITATION409", "PROJECT_MEMBER409"}) + @ApiErrorCodes({"PROJECT_INVITATION404", "PROJECT_INVITATION409", "PROJECT_MEMBER409"}) @PostMapping("/project-invitations/{token}/accept") public ApiResponse acceptInvitation( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java b/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java index 6034a2b..50cae66 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java @@ -32,7 +32,7 @@ public class ProjectMemberController { summary = "프로젝트 멤버 목록 조회", description = "페이지네이션 없이 전체를 반환한다. 프로젝트를 나간 멤버는 제외된다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @GetMapping public ApiResponse getProjectMembers( @AuthenticationPrincipal Long currentUserId, @@ -50,7 +50,7 @@ public ApiResponse getProjectMembers( summary = "프로젝트 나가기", description = "ADMIN 은 나갈 수 없다. 나가면 멤버 목록에서 빠지지만 작성한 글과 파일은 남는다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @DeleteMapping("/me") public ApiResponse leaveProject( @AuthenticationPrincipal Long currentUserId, @@ -65,7 +65,7 @@ public ApiResponse leaveProject( summary = "프로젝트 멤버 상세 조회", description = "경로의 memberId 는 사용자 ID 가 아니라 프로젝트 멤버 ID 다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404", "PROJECT_MEMBER404"}) @GetMapping("/{memberId}") public ApiResponse getProjectMember( @AuthenticationPrincipal Long currentUserId, @@ -87,7 +87,7 @@ public ApiResponse getProjectMember( ADMIN 이거나 본인의 역할일 때만 수정할 수 있다. 보낸 역할 목록으로 전체를 교체하므로, 유지할 역할도 함께 보내야 한다.""" ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404", "PROJECT_MEMBER404"}) @PatchMapping("/{memberId}") public ApiResponse updateProjectMemberRoles( @AuthenticationPrincipal Long currentUserId, @@ -111,7 +111,7 @@ public ApiResponse updateProjectMemberRoles( ADMIN 만 다른 멤버를 내보낼 수 있다. 자기 자신은 이 API 로 내보낼 수 없고 나가기를 써야 한다.""" ) - @ApiErrorCodes({"PROJECT403", "PROJECT_ADMIN403"}) + @ApiErrorCodes({"PROJECT403", "PROJECT_ADMIN403", "PROJECT404", "PROJECT_MEMBER404"}) @DeleteMapping("/{memberId}") public ApiResponse removeProjectMember( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java b/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java index a645125..15a5159 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java @@ -40,7 +40,7 @@ public class ProjectNoticeController { 각 항목에 내가 읽었는지 여부가 함께 담긴다. cursor 기반 페이지네이션이며 size 는 기본 20, 최대 50이다.""" ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @GetMapping public ApiResponse getProjectNotices( @AuthenticationPrincipal Long currentUserId, @@ -62,7 +62,7 @@ public ApiResponse getProjectNotices( summary = "프로젝트 공지 상세 조회", description = "조회만으로는 읽음 처리되지 않는다. 읽음 처리는 별도 엔드포인트를 호출해야 한다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404", "PROJECT_NOTICE404"}) @GetMapping("/{noticeId}") public ApiResponse getProjectNotice( @AuthenticationPrincipal Long currentUserId, @@ -84,7 +84,7 @@ public ApiResponse getProjectNotice( 프로젝트 멤버면 누구나 등록할 수 있다. 작성자 본인은 처음부터 읽음 상태이며, 나머지 멤버에게는 알림이 간다.""" ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse createProjectNotice( @@ -105,7 +105,7 @@ public ApiResponse createProjectNotice( summary = "프로젝트 공지 수정", description = "작성자 본인 또는 ADMIN 만 수정할 수 있다. 제목과 내용을 모두 덮어쓴다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404", "PROJECT_NOTICE404"}) @PatchMapping("/{noticeId}") public ApiResponse updateProjectNotice( @AuthenticationPrincipal Long currentUserId, @@ -127,7 +127,7 @@ public ApiResponse updateProjectNotice( summary = "프로젝트 공지 삭제", description = "작성자 본인 또는 ADMIN 만 삭제할 수 있다. 삭제 표시만 남긴다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404", "PROJECT_NOTICE404"}) @DeleteMapping("/{noticeId}") public ApiResponse deleteProjectNotice( @AuthenticationPrincipal Long currentUserId, @@ -145,7 +145,7 @@ public ApiResponse deleteProjectNotice( 읽음은 호출한 사람에게만 기록된다. 이미 읽은 공지에 다시 호출해도 실패하지 않고, 읽은 시각만 갱신된다.""" ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404", "PROJECT_NOTICE404"}) @PatchMapping("/{noticeId}/read") public ApiResponse readProjectNotice( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/schedule/controller/ScheduleController.java b/src/main/java/com/slatto/domain/schedule/controller/ScheduleController.java index 611d4bf..0fc7e47 100644 --- a/src/main/java/com/slatto/domain/schedule/controller/ScheduleController.java +++ b/src/main/java/com/slatto/domain/schedule/controller/ScheduleController.java @@ -46,7 +46,7 @@ public class ScheduleController { summary = "특정 날짜 일정 조회", description = "선택한 날짜에 표시할 일정 목록을 조회합니다. 응답에는 일정 대상자, 공용 메모, 로그인한 사용자의 개인 메모, 수정 가능 여부가 포함됩니다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @GetMapping("/daily") public ApiResponse getDailySchedules( @AuthenticationPrincipal Long currentUserId, @@ -71,7 +71,7 @@ public ApiResponse getDailySchedules( summary = "통합 캘린더 일정 조회", description = "캘린더에 표시할 일정 목록을 기간 기준으로 조회합니다. 프로젝트 캘린더 조회 시 scope=PROJECT와 projectId를 함께 전달합니다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @GetMapping public ApiResponse getCalendarSchedules( @AuthenticationPrincipal Long currentUserId, @@ -99,7 +99,7 @@ public ApiResponse getCalendarSchedules( summary = "일정 생성", description = "개인 일정 또는 프로젝트 일정을 생성합니다. PERSONAL 일정은 projectId와 participantIds를 전달하지 않고, PROJECT 일정은 projectId와 participantIds가 필요합니다." ) - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse createSchedule( 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 d2b1bf4..e89fb41 100644 --- a/src/main/java/com/slatto/domain/sharelink/controller/ShareLinkController.java +++ b/src/main/java/com/slatto/domain/sharelink/controller/ShareLinkController.java @@ -45,7 +45,7 @@ public ApiResponse createShareLink( @Operation(summary = "공유 링크 진입 검증", description = "게스트가 링크로 접근했을 때 유효성을 확인합니다. 인증이 필요 없습니다.") @SecurityRequirements - @ApiErrorCodes("SHARELINK410") + @ApiErrorCodes({"SHARELINK404", "SHARELINK410"}) @GetMapping("/share-links/{token}") public ApiResponse getShareLinkByToken( @PathVariable String token @@ -59,7 +59,7 @@ public ApiResponse getShareLinkByToken( @Operation(summary = "게스트 등록", description = "링크로 진입한 게스트가 이름을 등록하고 guestId를 발급받습니다. 인증이 필요 없습니다.") @ResponseStatus(HttpStatus.CREATED) @SecurityRequirements - @ApiErrorCodes("SHARELINK410") + @ApiErrorCodes({"SHARELINK404", "SHARELINK410"}) @PostMapping("/share-links/{token}/guests") public ApiResponse registerGuest( @PathVariable String token, @@ -72,7 +72,7 @@ public ApiResponse registerGuest( } @Operation(summary = "공유 링크 조회 (소유자용)", description = "영상의 공유 링크를 조회합니다. 프로젝트 멤버만 가능합니다.") - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "SHARELINK404"}) @GetMapping("/videos/{videoId}/share-links") public ApiResponse< ShareLinkInfoResDTO> getShareLinkByVideo( @@ -86,7 +86,7 @@ ShareLinkInfoResDTO> getShareLinkByVideo( } @Operation(summary = "공유 링크 활성/비활성 토글", description = "공유 링크의 활성 상태를 뒤집습니다. 프로젝트 멤버만 가능합니다.") - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "SHARELINK404"}) @PatchMapping("/share-links/{shareLinkId}") public ApiResponse toggleShareLink( @PathVariable Long shareLinkId, diff --git a/src/main/java/com/slatto/domain/video/controller/VideoReferenceFileController.java b/src/main/java/com/slatto/domain/video/controller/VideoReferenceFileController.java index 52c30f8..ada6163 100644 --- a/src/main/java/com/slatto/domain/video/controller/VideoReferenceFileController.java +++ b/src/main/java/com/slatto/domain/video/controller/VideoReferenceFileController.java @@ -38,7 +38,7 @@ public class VideoReferenceFileController { private final VideoReferenceFileService videoReferenceFileService; - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @GetMapping @Operation( summary = "영상 참조 파일 목록 조회", @@ -70,7 +70,7 @@ public ApiResponse getReferenceFiles( return ApiResponse.success(CommonSuccessCode.OK, response); } - @ApiErrorCodes({"PROJECT403", "VIDEO_REFERENCE_FILE409"}) + @ApiErrorCodes({"PROJECT403", "PROJECT404", "VIDEO_REFERENCE_FILE409"}) @PostMapping @ResponseStatus(HttpStatus.CREATED) @Operation( @@ -95,7 +95,7 @@ public ApiResponse createReferenceFile( return ApiResponse.success(CommonSuccessCode.CREATED, response); } - @ApiErrorCodes("PROJECT403") + @ApiErrorCodes({"PROJECT403", "PROJECT404"}) @DeleteMapping("/{referenceFileId}") @Operation( summary = "영상 참조 파일 연결 제거", diff --git a/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java b/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java index 9095a74..a495409 100644 --- a/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java +++ b/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java @@ -257,6 +257,39 @@ void errorResponsesDoNotMixExampleAndExamples() { assertThat(mixed).isEmpty(); } + // 404 는 공통 응답과 도메인 응답이 겹치는 유일한 상태다. + // 도메인 예시를 얹는 쪽이 공통 예시를 밀어내도 example 과 examples 가 섞이지는 않아 + // 위 검증은 통과하면서 공통 예시만 조용히 사라진다. + // + // 경로 변수가 있는 엔드포인트에만 공통 404 가 깔리므로, 겹침도 그쪽에서만 일어난다. + @Test + @DisplayName("도메인 예시를 얹은 404 도 공통 예시를 그대로 갖는다") + void notFoundKeepsCommonExampleAlongsideDomainExamples() { + List merged = new ArrayList<>(); + List dropped = new ArrayList<>(); + + forEachOperation((path, httpMethod, operation) -> { + JsonNode examples = operation.path("responses").path("404") + .path("content").path("application/json").path("examples"); + + if (examples.isMissingNode() || !hasPathParameter(operation)) { + return; + } + + String endpoint = httpMethod.toUpperCase() + " " + path; + merged.add(endpoint); + + if (!examples.has(CommonErrorCode.NOT_FOUND.getCode())) { + dropped.add(endpoint); + } + }); + + assertThat(merged).as("공통 404 와 도메인 404 가 함께 실린 응답").isNotEmpty(); + assertThat(dropped) + .as("도메인 예시에 밀려 공통 404 예시가 사라진 응답") + .isEmpty(); + } + // 401 을 일괄로 붙이면 인증 없이 열린 경로에도 발생하지 않는 상태 코드가 실린다. // 자물쇠 표시와 401 문서화는 항상 같은 판정에서 나와야 한다. @Test From c316a5ee54c6e43cb79d6c036271423657681548 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 12 Aug 2026 05:00:17 +0900 Subject: [PATCH 09/14] =?UTF-8?q?docs:=20201=C2=B7302=20=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=ED=95=98=EB=8A=94=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=EC=9D=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A5=BC=20=EB=AC=B8=EC=84=9C=EC=97=90=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit springdoc 은 @ResponseStatus 만 읽고 ResponseEntity.status(...) 는 읽지 않는다. 그래서 실제로는 201 을 반환하는 생성 API 세 곳과 302 로 리다이렉트하는 구글 로그인 진입이 문서에는 200 으로 실려 있었다. 런타임 상태는 ResponseEntity 가 결정하므로 동작은 바뀌지 않는다. 생성된 문서로는 이 어긋남을 잡을 수 없다. 문서의 성공 상태 코드 자체가 @ResponseStatus 에서 나오기 때문에 둘을 비교하면 항상 통과한다. 소스를 읽어 ResponseEntity 로 상태를 지정한 핸들러에 애노테이션이 함께 있는지 확인하는 테스트를 따로 두었다. --- .../auth/controller/AuthController.java | 2 + .../controller/FeedbackController.java | 1 + .../controller/FeedbackDetailController.java | 1 + .../config/ControllerResponseStatusTest.java | 95 +++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 src/test/java/com/slatto/global/config/ControllerResponseStatusTest.java diff --git a/src/main/java/com/slatto/domain/auth/controller/AuthController.java b/src/main/java/com/slatto/domain/auth/controller/AuthController.java index 882e679..e47ee87 100644 --- a/src/main/java/com/slatto/domain/auth/controller/AuthController.java +++ b/src/main/java/com/slatto/domain/auth/controller/AuthController.java @@ -60,6 +60,7 @@ public class AuthController { """ ) @SecurityRequirements + @ResponseStatus(HttpStatus.FOUND) @GetMapping("/login/google") public ResponseEntity loginWithGoogle( @RequestParam(name = "redirectTo", required = false) String redirectTo @@ -122,6 +123,7 @@ public ApiResponse reissueAccessToken( """ ) @SecurityRequirements + @ResponseStatus(HttpStatus.CREATED) @ApiErrorCodes({"AUTH_SIGNUP_DUPLICATE409", "AUTH_SIGNUP_SOCIAL409"}) @PostMapping("/signup") public ResponseEntity> signup( 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 048aa78..2db6d7a 100644 --- a/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java +++ b/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java @@ -31,6 +31,7 @@ public class FeedbackController { @Operation(summary = "피드백 작성") @OptionalAuthentication + @ResponseStatus(HttpStatus.CREATED) @ApiErrorCodes({"PROJECT403", "SHARELINK403", "SHARELINK410"}) @PostMapping("/videos/{videoId}/feedbacks") public ResponseEntity> createFeedback( 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 e064770..972787e 100644 --- a/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java +++ b/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java @@ -31,6 +31,7 @@ public class FeedbackDetailController { @Operation(summary = "답글 작성") @OptionalAuthentication + @ResponseStatus(HttpStatus.CREATED) @ApiErrorCodes({"PROJECT403", "SHARELINK403", "SHARELINK410"}) @PostMapping("/feedbacks/{feedbackId}/replies") public ResponseEntity> createReply( diff --git a/src/test/java/com/slatto/global/config/ControllerResponseStatusTest.java b/src/test/java/com/slatto/global/config/ControllerResponseStatusTest.java new file mode 100644 index 0000000..dae8170 --- /dev/null +++ b/src/test/java/com/slatto/global/config/ControllerResponseStatusTest.java @@ -0,0 +1,95 @@ +package com.slatto.global.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * springdoc 은 핸들러의 {@code @ResponseStatus} 만 읽는다. + * {@code ResponseEntity} 로 지정한 상태는 문서에 반영되지 않아, 실제로 201·302 를 반환하면서 + * 문서에는 200 이 실린다. 컴파일도 호출도 성공하므로 문서를 열어보기 전에는 드러나지 않는다. + * + *

생성된 문서로는 잡을 수 없다. 문서의 성공 상태 코드 자체가 {@code @ResponseStatus} 에서 + * 나오기 때문에, 둘을 비교하면 같은 값을 두 번 읽고 항상 통과한다. 그래서 소스를 직접 읽는다. + */ +class ControllerResponseStatusTest { + + private static final Path SOURCE_ROOT = Path.of("src/main/java"); + + private static final Pattern MAPPING = + Pattern.compile("^\\s*@(Get|Post|Put|Patch|Delete|Request)Mapping\\b"); + + @Test + @DisplayName("ResponseEntity 로 상태를 지정한 핸들러는 @ResponseStatus 도 함께 표기한다") + void handlersSettingStatusExplicitlyDeclareResponseStatus() throws IOException { + List checked = new ArrayList<>(); + List missing = new ArrayList<>(); + + for (Path controller : controllerSources()) { + List lines = Files.readAllLines(controller); + + for (int line = 0; line < lines.size(); line++) { + if (!lines.get(line).contains(".status(")) { + continue; + } + + int mapping = previousMapping(lines, line); + + if (mapping < 0) { + continue; + } + + String annotations = String.join("\n", lines.subList(previousMapping(lines, mapping - 1) + 1, mapping)); + + // 문서에 노출되지 않는 핸들러는 어긋날 문서가 없다. + if (annotations.contains("@Hidden")) { + continue; + } + + String location = controller.getFileName() + ":" + (line + 1); + checked.add(location); + + if (!annotations.contains("@ResponseStatus")) { + missing.add(location); + } + } + } + + assertThat(checked) + .as("ResponseEntity 로 상태를 직접 지정하는 핸들러") + .isNotEmpty(); + assertThat(missing) + .as("상태를 직접 지정했지만 @ResponseStatus 가 없어 문서에는 200 으로 실리는 핸들러") + .isEmpty(); + } + + private List controllerSources() throws IOException { + try (Stream paths = Files.walk(SOURCE_ROOT)) { + return paths + .filter(path -> path.getFileName().toString().endsWith("Controller.java")) + .toList(); + } + } + + // 애노테이션 블록은 바로 앞 매핑과 이 매핑 사이에 있다. + // 빈 줄을 경계로 삼으면 @Operation 의 텍스트 블록 안 빈 줄에서 잘린다. + private int previousMapping(List lines, int from) { + for (int line = from; line >= 0; line--) { + if (MAPPING.matcher(lines.get(line)).find()) { + return line; + } + } + + return -1; + } + +} From fd3a15f41b961002dfba60fa8946a6c368ce2de1 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 12 Aug 2026 10:11:43 +0900 Subject: [PATCH 10/14] =?UTF-8?q?docs:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=ED=9D=90=EB=A6=84=EA=B3=BC=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EB=A7=81=ED=81=AC=20=ED=86=A0=ED=81=B0=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EC=9D=98=EB=8F=84=EB=A5=BC=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EC=97=90=20=ED=91=9C=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공유 링크 토큰이 URL 경로에 노출되는 것이 의도된 설계임을 명시하고, 게스트 등록 -> sessionToken 발급 -> X-Guest-Token 사용 순서를 태그 설명에 적었다. --- .../controller/ShareLinkController.java | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) 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 e89fb41..db72be9 100644 --- a/src/main/java/com/slatto/domain/sharelink/controller/ShareLinkController.java +++ b/src/main/java/com/slatto/domain/sharelink/controller/ShareLinkController.java @@ -23,7 +23,15 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -@Tag(name = "ShareLink", description = "공유 링크 API") +@Tag( + name = "ShareLink", + description = """ + 공유 링크 API. 로그인하지 않은 게스트가 영상 한 편을 열람하고 피드백을 남기는 경로다. + 게스트 호출 순서는 다음과 같다. + 1. GET /share-links/{token} 으로 링크가 살아있는지 확인한다. + 2. POST /share-links/{token}/guests 로 이름을 등록하고 guestId 와 sessionToken 을 받는다. + 3. 이후 피드백·답글 요청에 sessionToken 을 X-Guest-Token 헤더로, guestId 를 파라미터로 함께 보낸다.""" +) public class ShareLinkController { private final ShareLinkService shareLinkService; @@ -43,7 +51,14 @@ public ApiResponse createShareLink( ); } - @Operation(summary = "공유 링크 진입 검증", description = "게스트가 링크로 접근했을 때 유효성을 확인합니다. 인증이 필요 없습니다.") + @Operation( + summary = "공유 링크 진입 검증", + description = """ + 게스트가 링크로 접근했을 때 유효성을 확인합니다. 인증이 필요 없습니다. + 토큰이 URL 경로에 드러나는 것은 의도된 설계입니다. 링크를 아는 것 자체가 이 영상 한 편에 대한 열람 자격이기 때문에, + 별도 자격 증명을 요구하지 않습니다. 토큰은 UUID 이고 영상 하나에만 연결되며, + 소유자가 비활성화하거나 만료되면 SHARELINK410 으로 즉시 막힙니다.""" + ) @SecurityRequirements @ApiErrorCodes({"SHARELINK404", "SHARELINK410"}) @GetMapping("/share-links/{token}") @@ -56,7 +71,13 @@ public ApiResponse getShareLinkByToken( ); } - @Operation(summary = "게스트 등록", description = "링크로 진입한 게스트가 이름을 등록하고 guestId를 발급받습니다. 인증이 필요 없습니다.") + @Operation( + summary = "게스트 등록", + description = """ + 링크로 진입한 게스트가 이름을 등록하고 guestId 와 sessionToken 을 발급받습니다. 인증이 필요 없습니다. + 응답의 sessionToken 이 이후 게스트 요청의 X-Guest-Token 헤더 값이고, guestId 는 같은 요청의 guestId 파라미터 값입니다. + 서버에는 해시만 저장하므로 sessionToken 원문은 이 응답에서만 확인할 수 있습니다.""" + ) @ResponseStatus(HttpStatus.CREATED) @SecurityRequirements @ApiErrorCodes({"SHARELINK404", "SHARELINK410"}) From 901d6e82bb1f137bf7cf5b09ebed46ae0f57123e Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 12 Aug 2026 10:11:52 +0900 Subject: [PATCH 11/14] =?UTF-8?q?docs:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20API?= =?UTF-8?q?=20=EC=9D=98=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=EC=97=90=20=EC=84=A4=EB=AA=85=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 guestId 와 X-Guest-Token 이 게스트 등록 응답에서 온 짝이라는 점, 로그인 사용자는 둘 다 생략한다는 점을 파라미터 설명에 적었다. --- .../controller/FeedbackController.java | 18 +++++++++++++++++- .../controller/FeedbackDetailController.java | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) 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 2db6d7a..c1f23c8 100644 --- a/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java +++ b/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java @@ -13,6 +13,7 @@ import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -21,7 +22,14 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "Feedback", description = "피드백 API") +@Tag( + name = "Feedback", + description = """ + 피드백 API. 로그인 사용자와 공유 링크로 들어온 게스트가 함께 쓴다. + 게스트로 호출하려면 ShareLink API 에서 먼저 게스트 등록을 마치고, + 받은 sessionToken 을 X-Guest-Token 헤더에, guestId 를 파라미터나 본문에 실어 보낸다. + 두 값의 짝이 맞지 않으면 SHARELINK403 으로 막힌다.""" +) @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -37,6 +45,7 @@ public class FeedbackController { public ResponseEntity> createFeedback( @PathVariable Long videoId, @AuthenticationPrincipal Long userId, + @Parameter(description = "게스트로 작성할 때만 보냅니다. 게스트 등록 응답의 sessionToken 값이며, 본문의 guestId 와 짝이 맞아야 합니다. 로그인 사용자는 생략합니다.") @RequestHeader(value = "X-Guest-Token", required = false) String guestToken, @Valid @RequestBody FeedbackCreateReqDTO request ) { @@ -53,6 +62,7 @@ public ResponseEntity> createFeedback( public ResponseEntity> updateFeedback( @PathVariable Long feedbackId, @AuthenticationPrincipal Long userId, + @Parameter(description = "게스트로 수정할 때만 보냅니다. 게스트 등록 응답의 sessionToken 값이며, 본문의 guestId 와 짝이 맞아야 합니다. 로그인 사용자는 생략합니다.") @RequestHeader(value = "X-Guest-Token", required = false) String guestToken, @Valid @RequestBody FeedbackUpdateReqDTO request ) { @@ -68,7 +78,9 @@ public ResponseEntity> updateFeedback( public ResponseEntity> deleteFeedback( @PathVariable Long feedbackId, @AuthenticationPrincipal Long userId, + @Parameter(description = "게스트로 삭제할 때만 보냅니다. 게스트 등록 응답의 guestId 값입니다. 로그인 사용자는 생략합니다.", example = "20") @RequestParam(required = false) Long guestId, + @Parameter(description = "게스트로 삭제할 때만 보냅니다. 게스트 등록 응답의 sessionToken 값이며, guestId 와 짝이 맞아야 합니다. 로그인 사용자는 생략합니다.") @RequestHeader(value = "X-Guest-Token", required = false) String guestToken ) { feedbackService.deleteFeedback(feedbackId, userId, guestId, guestToken); @@ -83,9 +95,13 @@ public ResponseEntity> deleteFeedback( public ResponseEntity> getFeedbackList( @PathVariable Long videoId, @AuthenticationPrincipal Long userId, + @Parameter(description = "게스트로 조회할 때만 보냅니다. 게스트 등록 응답의 guestId 값입니다. 로그인 사용자는 생략합니다.", example = "20") @RequestParam(required = false) Long guestId, + @Parameter(description = "게스트로 조회할 때만 보냅니다. 게스트 등록 응답의 sessionToken 값이며, guestId 와 짝이 맞아야 합니다. 로그인 사용자는 생략합니다.") @RequestHeader(value = "X-Guest-Token", required = false) String guestToken, + @Parameter(description = "이전 응답의 nextCursor 를 그대로 넣습니다. {재생지점초}_{피드백ID} 형식이고 재생 지점이 없는 피드백은 앞이 n 입니다. 첫 페이지에서는 생략합니다.", example = "12_57") @RequestParam(required = false) String cursor, + @Parameter(description = "조회 개수. 생략 시 10, 최대 50입니다.", example = "10") @RequestParam(required = false) Integer size ) { FeedbackListResDTO result = feedbackService.getFeedbackList(videoId, userId, guestId, guestToken, cursor, size); 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 972787e..1530fb2 100644 --- a/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java +++ b/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java @@ -13,6 +13,7 @@ import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -21,7 +22,14 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "Feedback Reply", description = "피드백 답글 API") +@Tag( + name = "Feedback Reply", + description = """ + 피드백 답글 API. 로그인 사용자와 공유 링크로 들어온 게스트가 함께 쓴다. + 게스트로 호출하려면 ShareLink API 에서 먼저 게스트 등록을 마치고, + 받은 sessionToken 을 X-Guest-Token 헤더에, guestId 를 파라미터나 본문에 실어 보낸다. + 두 값의 짝이 맞지 않으면 SHARELINK403 으로 막힌다.""" +) @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor @@ -37,6 +45,7 @@ public class FeedbackDetailController { public ResponseEntity> createReply( @PathVariable Long feedbackId, @AuthenticationPrincipal Long userId, + @Parameter(description = "게스트로 작성할 때만 보냅니다. 게스트 등록 응답의 sessionToken 값이며, 본문의 guestId 와 짝이 맞아야 합니다. 로그인 사용자는 생략합니다.") @RequestHeader(value = "X-Guest-Token", required = false) String guestToken, @Valid @RequestBody ReplyCreateReqDTO request ) { @@ -54,9 +63,13 @@ public ResponseEntity> createReply( public ResponseEntity> getReplyList( @PathVariable Long feedbackId, @AuthenticationPrincipal Long userId, + @Parameter(description = "게스트로 조회할 때만 보냅니다. 게스트 등록 응답의 guestId 값입니다. 로그인 사용자는 생략합니다.", example = "20") @RequestParam(required = false) Long guestId, + @Parameter(description = "게스트로 조회할 때만 보냅니다. 게스트 등록 응답의 sessionToken 값이며, guestId 와 짝이 맞아야 합니다. 로그인 사용자는 생략합니다.") @RequestHeader(value = "X-Guest-Token", required = false) String guestToken, + @Parameter(description = "이전 응답의 nextCursor. 첫 페이지에서는 생략합니다.", example = "31") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회 개수. 생략 시 10, 최대 50입니다.", example = "10") @RequestParam(required = false) Integer size ) { ReplyListResDTO result = feedbackDetailService.getReplyList(feedbackId, userId, guestId, guestToken, cursor, size); @@ -72,6 +85,7 @@ public ResponseEntity> getReplyList( public ResponseEntity> updateReply( @PathVariable Long replyId, @AuthenticationPrincipal Long userId, + @Parameter(description = "게스트로 수정할 때만 보냅니다. 게스트 등록 응답의 sessionToken 값이며, 본문의 guestId 와 짝이 맞아야 합니다. 로그인 사용자는 생략합니다.") @RequestHeader(value = "X-Guest-Token", required = false) String guestToken, @Valid @RequestBody ReplyUpdateReqDTO request ) { @@ -88,7 +102,9 @@ public ResponseEntity> updateReply( public ResponseEntity> deleteReply( @PathVariable Long replyId, @AuthenticationPrincipal Long userId, + @Parameter(description = "게스트로 삭제할 때만 보냅니다. 게스트 등록 응답의 guestId 값입니다. 로그인 사용자는 생략합니다.", example = "20") @RequestParam(required = false) Long guestId, + @Parameter(description = "게스트로 삭제할 때만 보냅니다. 게스트 등록 응답의 sessionToken 값이며, guestId 와 짝이 맞아야 합니다. 로그인 사용자는 생략합니다.") @RequestHeader(value = "X-Guest-Token", required = false) String guestToken ) { feedbackDetailService.deleteReply(replyId, userId, guestId, guestToken); From ce42e2dd09414464d6e33ae5980e1a68eab65d73 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 12 Aug 2026 10:11:52 +0900 Subject: [PATCH 12/14] =?UTF-8?q?docs:=20=EC=BB=A4=EC=84=9C=C2=B7=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=81=AC=EA=B8=B0=C2=B7=ED=95=84=ED=84=B0?= =?UTF-8?q?=20=EC=BF=BC=EB=A6=AC=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0?= =?UTF-8?q?=EC=97=90=20=EC=84=A4=EB=AA=85=EA=B3=BC=20=EC=98=88=EC=8B=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 cursor 는 이전 응답의 nextCursor 를 그대로 넣는 값이고 목록마다 기준 ID 가 다르다. size 의 기본값·최댓값과 status·sort·필터 enum 이 가질 수 있는 값을 문서에서 바로 확인할 수 있게 했다. --- .../domain/auth/controller/AuthController.java | 4 ++++ .../controller/NotificationController.java | 3 +++ .../controller/RecentActivityController.java | 3 +++ .../project/controller/ProjectController.java | 4 ++++ .../controller/ProjectFileController.java | 4 ++++ .../controller/ProjectNoticeController.java | 3 +++ .../controller/MyRecruitmentController.java | 9 +++++++++ .../RecruitmentApplicationController.java | 4 ++++ .../controller/RecruitmentController.java | 18 ++++++++++++++++++ .../user/controller/PortfolioController.java | 3 +++ 10 files changed, 55 insertions(+) diff --git a/src/main/java/com/slatto/domain/auth/controller/AuthController.java b/src/main/java/com/slatto/domain/auth/controller/AuthController.java index e47ee87..6ecdb93 100644 --- a/src/main/java/com/slatto/domain/auth/controller/AuthController.java +++ b/src/main/java/com/slatto/domain/auth/controller/AuthController.java @@ -18,6 +18,7 @@ import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -63,6 +64,7 @@ public class AuthController { @ResponseStatus(HttpStatus.FOUND) @GetMapping("/login/google") public ResponseEntity loginWithGoogle( + @Parameter(description = "로그인을 마친 뒤 돌아갈 프론트엔드 경로. 생략하면 기본 경로로 보냅니다.", example = "/projects") @RequestParam(name = "redirectTo", required = false) String redirectTo ) { AuthService.GoogleLoginEntry entry = authService.createGoogleLoginEntry(redirectTo); @@ -107,6 +109,7 @@ public ResponseEntity handleGoogleCallback( @SecurityRequirements @PostMapping("/refresh") public ApiResponse reissueAccessToken( + @Parameter(description = "리프레시 토큰 쿠키. 로그인 시 서버가 HttpOnly 로 심어주므로 브라우저가 자동으로 보냅니다. 직접 넣을 값이 아닙니다.") @CookieValue(name = "${app.cookie.refresh-token-name}", required = false) String refreshToken ) { return ApiResponse.success(CommonSuccessCode.OK, authService.reissueAccessToken(refreshToken)); @@ -251,6 +254,7 @@ public ApiResponse resetPassword(@Valid @RequestBody PasswordResetRequest @Operation(summary = "로그아웃", description = "서버에 저장된 리프레시 토큰을 무효화하고 쿠키를 삭제한다.") @PostMapping("/logout") public ResponseEntity> logout( + @Parameter(description = "리프레시 토큰 쿠키. 브라우저가 자동으로 보냅니다. 직접 넣을 값이 아닙니다.") @CookieValue(name = "${app.cookie.refresh-token-name}", required = false) String refreshToken ) { authService.logout(refreshToken); diff --git a/src/main/java/com/slatto/domain/notification/controller/NotificationController.java b/src/main/java/com/slatto/domain/notification/controller/NotificationController.java index a5f8bd2..7f78f3b 100644 --- a/src/main/java/com/slatto/domain/notification/controller/NotificationController.java +++ b/src/main/java/com/slatto/domain/notification/controller/NotificationController.java @@ -5,6 +5,7 @@ import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -32,7 +33,9 @@ public class NotificationController { @GetMapping public ApiResponse getNotifications( @AuthenticationPrincipal Long currentUserId, + @Parameter(description = "이전 응답의 nextCursor. 첫 페이지에서는 생략합니다.", example = "42") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회 개수. 생략 시 20, 최대 50입니다.", example = "20") @RequestParam(defaultValue = "20") int size ) { NotificationListResponse response = notificationService.getNotifications( diff --git a/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java b/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java index 5b7dcd7..fb042cd 100644 --- a/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java +++ b/src/main/java/com/slatto/domain/notification/controller/RecentActivityController.java @@ -6,6 +6,7 @@ import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -35,7 +36,9 @@ public class RecentActivityController { public ApiResponse getRecentActivities( @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId, + @Parameter(description = "이전 응답의 nextCursor 를 그대로 넣습니다. {발생일시}_{활동ID} 형식입니다. 첫 페이지에서는 생략합니다.", example = "2026-08-11T14:30:00_57") @RequestParam(required = false) String cursor, + @Parameter(description = "조회 개수. 생략 시 20, 최대 50입니다.", example = "20") @RequestParam(defaultValue = "20") int size ) { ActivityLogListResponse response = recentActivityService.getRecentActivities( diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectController.java b/src/main/java/com/slatto/domain/project/controller/ProjectController.java index 50c9019..f1c1c8d 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectController.java @@ -12,6 +12,7 @@ import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -45,8 +46,11 @@ public class ProjectController { @GetMapping public ApiResponse getProjects( @AuthenticationPrincipal Long currentUserId, + @Parameter(description = "진행 단계로 거릅니다. PREPARING, EDITING, REVIEWING, COMPLETED 중 하나이며 생략하면 전체를 조회합니다.", example = "EDITING") @RequestParam(required = false) ProjectStatus status, + @Parameter(description = "이전 응답의 nextCursor. 첫 페이지에서는 생략합니다.", example = "12") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회 개수. 생략 시 20, 최대 50입니다.", example = "20") @RequestParam(defaultValue = "20") int size ) { ProjectListResponse response = projectService.getProjects(currentUserId, status, cursor, size); diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java index 0c93d61..2611fd2 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java @@ -11,6 +11,7 @@ import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -54,8 +55,11 @@ public class ProjectFileController { public ApiResponse getProjectFiles( @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId, + @Parameter(description = "파일명 검색어. 생략하면 전체를 조회합니다.", example = "콘티") @RequestParam(required = false) String keyword, + @Parameter(description = "이전 응답의 nextCursor. 첫 페이지에서는 생략합니다.", example = "18") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회 개수. 생략 시 20, 최대 50입니다.", example = "20") @RequestParam(defaultValue = "20") int size ) { ProjectFileListResponse response = projectFileService.getProjectFiles( diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java b/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java index 15a5159..9eb945d 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java @@ -10,6 +10,7 @@ import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -45,7 +46,9 @@ public class ProjectNoticeController { public ApiResponse getProjectNotices( @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId, + @Parameter(description = "이전 응답의 nextCursor. 첫 페이지에서는 생략합니다.", example = "7") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회 개수. 생략 시 20, 최대 50입니다.", example = "20") @RequestParam(defaultValue = "20") int size ) { ProjectNoticeListResponse response = projectNoticeService.getProjectNotices( diff --git a/src/main/java/com/slatto/domain/recruitment/controller/MyRecruitmentController.java b/src/main/java/com/slatto/domain/recruitment/controller/MyRecruitmentController.java index 64a85f5..09feb1b 100644 --- a/src/main/java/com/slatto/domain/recruitment/controller/MyRecruitmentController.java +++ b/src/main/java/com/slatto/domain/recruitment/controller/MyRecruitmentController.java @@ -11,6 +11,7 @@ import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -36,8 +37,11 @@ public class MyRecruitmentController { @GetMapping("/recruitments") public ApiResponse getMyRecruitments( @AuthenticationPrincipal Long currentUserId, + @Parameter(description = "공고 상태로 거릅니다. RECRUITING 또는 CLOSED 이며 생략하면 전체를 조회합니다.", example = "RECRUITING") @RequestParam(required = false) RecruitmentStatus status, + @Parameter(description = "이전 응답의 nextCursor. 첫 페이지에서는 생략합니다.", example = "31") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회 개수. 생략 시 10, 최대 50입니다.", example = "10") @RequestParam(defaultValue = "10") int size ) { MyRecruitmentListResponse response = recruitmentService.getMyRecruitments( @@ -57,7 +61,9 @@ public ApiResponse getMyRecruitments( @GetMapping("/recruitment-bookmarks") public ApiResponse getMyBookmarks( @AuthenticationPrincipal Long currentUserId, + @Parameter(description = "이전 응답의 nextCursor. 공고 ID 가 아니라 관심 등록 ID 입니다. 첫 페이지에서는 생략합니다.", example = "14") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회 개수. 생략 시 10, 최대 50입니다.", example = "10") @RequestParam(defaultValue = "10") int size ) { RecruitmentListResponse response = recruitmentBookmarkService.getMyBookmarks( @@ -77,8 +83,11 @@ public ApiResponse getMyBookmarks( @GetMapping("/applications") public ApiResponse getMyApplications( @AuthenticationPrincipal Long currentUserId, + @Parameter(description = "지원 상태로 거릅니다. PENDING, ACCEPTED, REJECTED 중 하나이며 생략하면 전체를 조회합니다.", example = "PENDING") @RequestParam(required = false) RecruitmentApplicationStatus status, + @Parameter(description = "이전 응답의 nextCursor. 공고 ID 가 아니라 지원 ID 입니다. 첫 페이지에서는 생략합니다.", example = "22") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회 개수. 생략 시 10, 최대 50입니다.", example = "10") @RequestParam(defaultValue = "10") int size ) { MyApplicationListResponse response = recruitmentApplicationService.getMyApplications( diff --git a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java index 1e49615..6f7d304 100644 --- a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java +++ b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java @@ -11,6 +11,7 @@ import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -70,8 +71,11 @@ public ApiResponse applyToRecruitment( public ApiResponse getApplicants( @AuthenticationPrincipal Long currentUserId, @PathVariable Long recruitmentId, + @Parameter(description = "지원 상태로 거릅니다. PENDING, ACCEPTED, REJECTED 중 하나이며 생략하면 전체를 조회합니다.", example = "PENDING") @RequestParam(required = false) RecruitmentApplicationStatus status, + @Parameter(description = "이전 응답의 nextCursor. 지원 ID 기준입니다. 첫 페이지에서는 생략합니다.", example = "22") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회 개수. 생략 시 10, 최대 50입니다.", example = "10") @RequestParam(defaultValue = "10") int size ) { RecruitmentApplicantListResponse response = recruitmentApplicationService.getApplicants( diff --git a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java index c1f7488..fd109a6 100644 --- a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java +++ b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java @@ -16,6 +16,7 @@ import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -82,14 +83,30 @@ public ApiResponse createRecruitment( @GetMapping public ApiResponse getRecruitments( @AuthenticationPrincipal Long currentUserId, + @Parameter(description = "제목·내용 검색어. 생략하면 전체를 조회합니다.", example = "뮤직비디오") @RequestParam(required = false) String keyword, + @Parameter(description = "촬영 카테고리. 같은 키를 반복해 여러 개를 보낼 수 있고 값끼리는 OR 입니다. " + + "YOUTUBE_CONTENT, AD_BRAND, MUSIC_VIDEO, WEDDING_EVENT, DOCUMENTARY, FILM_DRAMA, CORPORATE_PROMO, ETC", + example = "MUSIC_VIDEO") @RequestParam(required = false) List category, + @Parameter(description = "영상 길이 유형. 현재 LONG_FORM 만 있습니다.", example = "LONG_FORM") @RequestParam(required = false) LengthType lengthType, + @Parameter(description = "모집 파트. 같은 키를 반복해 여러 개를 보낼 수 있고 값끼리는 OR 입니다. " + + "DIRECTOR, PD, CINEMATOGRAPHER, EDITOR, ART, SOUND, WRITER, LIGHTING, ACTOR, ETC", + example = "EDITOR") @RequestParam(required = false) List recruitPart, + @Parameter(description = "촬영 지역. 같은 키를 반복해 여러 개를 보낼 수 있고 값끼리는 OR 입니다. " + + "SEOUL, GYEONGGI, GANGWON, CHUNGCHEONGNAM, CHUNGCHEONGBUK, JEOLLABUK, JEOLLANAM, " + + "GYEONGSANGBUK, GYEONGSANGNAM, JEJU, NATIONWIDE", + example = "SEOUL") @RequestParam(required = false) List location, + @Parameter(description = "공고 상태로 거릅니다. RECRUITING 또는 CLOSED 이며 생략하면 전체를 조회합니다.", example = "RECRUITING") @RequestParam(required = false) RecruitmentStatus status, + @Parameter(description = "정렬 기준. LATEST(최신순), DEADLINE(마감임박순), POPULAR(인기순) 이며 생략 시 LATEST 입니다.", example = "LATEST") @RequestParam(defaultValue = "LATEST") RecruitmentSortType sort, + @Parameter(description = "이전 응답의 nextCursor. 공고 ID 기준입니다. 정렬·필터를 바꾸면 버리고 첫 페이지부터 다시 조회합니다.", example = "31") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회 개수. 생략 시 10, 최대 50입니다.", example = "10") @RequestParam(defaultValue = "10") int size ) { RecruitmentListResponse response = recruitmentService.getRecruitments( @@ -117,6 +134,7 @@ public ApiResponse getRecruitments( @GetMapping("/recommended") public ApiResponse getRecommendedRecruitments( @AuthenticationPrincipal Long currentUserId, + @Parameter(description = "추천 개수. 생략 시 4, 최대 20입니다.", example = "4") @RequestParam(defaultValue = "4") int size ) { RecruitmentRecommendationResponse response = diff --git a/src/main/java/com/slatto/domain/user/controller/PortfolioController.java b/src/main/java/com/slatto/domain/user/controller/PortfolioController.java index 3a91e9d..87c9513 100644 --- a/src/main/java/com/slatto/domain/user/controller/PortfolioController.java +++ b/src/main/java/com/slatto/domain/user/controller/PortfolioController.java @@ -10,6 +10,7 @@ import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -38,7 +39,9 @@ public class PortfolioController { @GetMapping("/{userId}/portfolios") public ApiResponse getPortfolios( @PathVariable Long userId, + @Parameter(description = "이전 응답의 nextCursor. 첫 페이지에서는 생략합니다.", example = "5") @RequestParam(required = false) Long cursor, + @Parameter(description = "조회 개수. 생략 시 10, 최대 50입니다.", example = "10") @RequestParam(defaultValue = "10") int size ) { PortfolioListResponse response = portfolioService.getPortfolios(userId, cursor, size); From b64f60ff4a5e2f097d2709b76a954327a3d83611 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 12 Aug 2026 12:54:52 +0900 Subject: [PATCH 13/14] =?UTF-8?q?docs:=20=EC=84=A4=EB=AA=85=EC=97=90?= =?UTF-8?q?=EB=A7=8C=20=EC=A0=81=ED=98=80=20=EC=9E=88=EB=8D=98=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20@ApiErrorCodes=20=EC=97=90=20=ED=91=9C=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DomainErrorResponses 는 @ApiErrorCodes 에 선언된 코드만 문서에 병합한다. 설명에 코드 이름을 적어두고 애노테이션에 넣지 않은 5개 엔드포인트는 해당 응답 예시가 Swagger 에서 통째로 빠져 있었다. - 프로젝트 수정: PROJECT_COMPLETION400 - 공고 지원: APPLICATION_FILE_LINK400 - 공고 수정: RECRUITMENT_CLOSED_EDIT400 - 공유 링크 생성: SHARELINK400 - 회원 탈퇴: USER_WITHDRAW_PASSWORD401 400·401 은 공통 응답과 겹치지만 공통 예시를 밀어내지 않는다. 겹침 처리는 도메인 404 에 쓰던 경로를 그대로 탄다. 애노테이션 문서가 403·409·410·429 만 적는 자리라고 못 박고 있어 이번 누락의 빌미가 됐다. 상태 코드로 갈리지 않는다고 고쳤다. --- .../domain/project/controller/ProjectController.java | 2 +- .../controller/RecruitmentApplicationController.java | 2 +- .../recruitment/controller/RecruitmentController.java | 2 +- .../domain/sharelink/controller/ShareLinkController.java | 2 +- .../com/slatto/domain/user/controller/UserController.java | 1 + src/main/java/com/slatto/global/config/ApiErrorCodes.java | 8 ++++++-- .../com/slatto/global/config/DomainErrorResponses.java | 4 ++-- 7 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectController.java b/src/main/java/com/slatto/domain/project/controller/ProjectController.java index f1c1c8d..2ed82dd 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectController.java @@ -111,7 +111,7 @@ public ApiResponse getProject( `title` 은 생성·수정 요청 모두 필수라 실제로는 `kind` 만 이 조건에 걸린다. """ ) - @ApiErrorCodes({"PROJECT403", "PROJECT_ADMIN403", "PROJECT404", "PROJECT_COMPLETED409"}) + @ApiErrorCodes({"PROJECT_COMPLETION400", "PROJECT403", "PROJECT_ADMIN403", "PROJECT404", "PROJECT_COMPLETED409"}) @PatchMapping("/{projectId}") public ApiResponse updateProject( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java index 6f7d304..646bc5e 100644 --- a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java +++ b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentApplicationController.java @@ -45,7 +45,7 @@ public class RecruitmentApplicationController { 첨부를 의도한 지원이 첨부 없이 접수되면 지원자는 성공 응답을 받고도 서류가 빠진 상태가 되기 때문이다. """ ) - @ApiErrorCodes("APPLICATION409") + @ApiErrorCodes({"APPLICATION_FILE_LINK400", "APPLICATION409"}) @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse applyToRecruitment( diff --git a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java index fd109a6..2310e15 100644 --- a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java +++ b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java @@ -172,7 +172,7 @@ public ApiResponse getRecruitment( 공고를 되살리려면 `deadline` 도 함께 보내야 한다. """ ) - @ApiErrorCodes("RECRUITMENT403") + @ApiErrorCodes({"RECRUITMENT_CLOSED_EDIT400", "RECRUITMENT403"}) @PatchMapping("/{recruitmentId}") public ApiResponse updateRecruitment( @AuthenticationPrincipal Long currentUserId, 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 db72be9..7ee598e 100644 --- a/src/main/java/com/slatto/domain/sharelink/controller/ShareLinkController.java +++ b/src/main/java/com/slatto/domain/sharelink/controller/ShareLinkController.java @@ -38,7 +38,7 @@ public class ShareLinkController { @Operation(summary = "공유 링크 생성", description = "영상당 1개만 생성 가능하며, 이미 있으면 409를 반환합니다.") @ResponseStatus(HttpStatus.CREATED) - @ApiErrorCodes({"PROJECT403", "SHARELINK409"}) + @ApiErrorCodes({"SHARELINK400", "PROJECT403", "SHARELINK409"}) @PostMapping("/videos/{videoId}/share-links") public ApiResponse createShareLink( @PathVariable Long videoId, diff --git a/src/main/java/com/slatto/domain/user/controller/UserController.java b/src/main/java/com/slatto/domain/user/controller/UserController.java index 112c97f..b08d145 100644 --- a/src/main/java/com/slatto/domain/user/controller/UserController.java +++ b/src/main/java/com/slatto/domain/user/controller/UserController.java @@ -123,6 +123,7 @@ public ApiResponse uploadProfileImage( 구글로만 가입해 비밀번호가 없는 계정은 `password` 를 보내지 않아도 된다. """ ) + @ApiErrorCodes("USER_WITHDRAW_PASSWORD401") @DeleteMapping("/me") public ResponseEntity> withdraw( @AuthenticationPrincipal Long userId, diff --git a/src/main/java/com/slatto/global/config/ApiErrorCodes.java b/src/main/java/com/slatto/global/config/ApiErrorCodes.java index c0b1bb3..dbcaad1 100644 --- a/src/main/java/com/slatto/global/config/ApiErrorCodes.java +++ b/src/main/java/com/slatto/global/config/ApiErrorCodes.java @@ -10,8 +10,12 @@ * 이 엔드포인트에서 발생할 수 있는 도메인 에러 코드를 적는다. * *

공통 에러(400, 401, 404, 413, 500)는 {@code SwaggerErrorResponseCustomizer} 가 - * 조건을 보고 알아서 붙이므로 여기 적지 않는다. - * 403, 409, 410, 429 처럼 도메인 규칙에서만 나오는 응답을 적는 자리다. + * 조건을 보고 알아서 붙이므로 여기 적지 않는다. 도메인 규칙에서만 나오는 응답을 적는 자리다. + * + *

상태 코드로 갈리지 않는다. 403, 409, 410, 429 처럼 공통 응답이 없는 상태든, + * 공통 응답과 겹치는 400, 401, 404 든 도메인 코드라면 적는다. + * 적지 않으면 그 코드의 예시가 문서에서 통째로 빠지고, 호출하는 쪽은 공통 예시만 보게 된다. + * 겹치는 상태에서는 {@link DomainErrorResponses} 가 공통 예시를 남긴 채 도메인 예시를 얹는다. * *

값은 enum 상수가 아니라 코드 문자열이다. * 애노테이션 배열은 한 가지 타입만 담을 수 있어서 diff --git a/src/main/java/com/slatto/global/config/DomainErrorResponses.java b/src/main/java/com/slatto/global/config/DomainErrorResponses.java index d2e4b36..4f76aa4 100644 --- a/src/main/java/com/slatto/global/config/DomainErrorResponses.java +++ b/src/main/java/com/slatto/global/config/DomainErrorResponses.java @@ -21,8 +21,8 @@ /** * {@link ApiErrorCodes} 에 적힌 도메인 에러 응답을 문서에 붙인다. * - *

403, 409, 410, 429 는 도메인 규칙에서만 나오기 때문에 조건으로 추론할 수 없다. - * 공통 에러처럼 자동으로 판단하지 않고, 엔드포인트가 직접 밝힌 것만 싣는다. + *

도메인 에러는 규칙에서만 나오기 때문에 공통 에러처럼 조건으로 추론할 수 없다. + * 엔드포인트가 직접 밝힌 것만 싣는다. * *

{@code OperationCustomizer} 로 따로 등록하지 않고 {@link SwaggerErrorResponseCustomizer} 가 마지막에 부른다. * 공통 응답이 먼저 깔린 뒤에 얹혀야 같은 상태 코드에서 공통 예시를 밀어내지 않는데, From 2431cb7c250ba1eafe5af87dc48622ebcea6ae83 Mon Sep 17 00:00:00 2001 From: chazy-d Date: Wed, 12 Aug 2026 12:54:53 +0900 Subject: [PATCH 14/14] =?UTF-8?q?docs:=20=ED=94=BC=EB=93=9C=EB=B0=B1=C2=B7?= =?UTF-8?q?=EB=8B=B5=EA=B8=80=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EC=9E=91=EC=97=85=20=EC=84=A4=EB=AA=85=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 피드백과 답글 8개 엔드포인트에 summary 만 있고 설명이 없었다. 회원과 게스트가 함께 쓰는 경로라 식별 규칙, 접근 조건, 본인 확인처럼 호출하는 쪽이 알아야 할 제약이 가장 많은데 코드를 열어야만 알 수 있었다. 설명이 없으면 깨지도록 만든 검증이 이 8개를 놓치고 있었다. 인증이 선택인 엔드포인트에는 안내 문구가 자동으로 붙는데, 그 문구가 채워지면서 설명을 한 줄도 적지 않아도 통과했다. 문구를 걷어내고 남은 것만 설명으로 세도록 고쳤다. --- .../controller/FeedbackController.java | 55 +++++++++++++++++-- .../controller/FeedbackDetailController.java | 51 +++++++++++++++-- .../SwaggerAuthenticationCustomizer.java | 4 +- .../config/OpenApiDocumentationTest.java | 7 ++- 4 files changed, 107 insertions(+), 10 deletions(-) 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 c1f23c8..6fbd53b 100644 --- a/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java +++ b/src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java @@ -37,7 +37,20 @@ public class FeedbackController { private final FeedbackService feedbackService; - @Operation(summary = "피드백 작성") + @Operation( + summary = "피드백 작성", + description = """ + 회원은 토큰으로, 게스트는 본문 `guestId` 와 `X-Guest-Token` 으로 식별한다. + **둘을 함께 보내거나 둘 다 보내지 않으면 400** 이다. 회원은 `guestId` 를 넣지 않는다. + + 회원은 이 영상이 속한 프로젝트의 활성 멤버여야 하고, 게스트는 자기 공유 링크의 영상에만 남길 수 있다. + + `startTime` 과 `endTime` 은 영상 재생 지점(초)이다. 둘 다 생략하면 영상 전체에 대한 피드백이 되고, + 함께 보내면 `startTime` 이 `endTime` 보다 클 수 없다. + + 작성되면 작성자를 뺀 프로젝트 멤버 전원에게 알림이 가고 최근 활동에 남는다. + """ + ) @OptionalAuthentication @ResponseStatus(HttpStatus.CREATED) @ApiErrorCodes({"PROJECT403", "SHARELINK403", "SHARELINK410"}) @@ -55,7 +68,18 @@ public ResponseEntity> createFeedback( .body(ApiResponse.success(CommonSuccessCode.CREATED, result)); } - @Operation(summary = "피드백 수정") + @Operation( + summary = "피드백 수정", + description = """ + **작성자 본인만 수정할 수 있다.** 남의 피드백에 요청하면 `FEEDBACK403` 이다. + 같은 프로젝트 멤버여도, 같은 공유 링크의 다른 게스트여도 마찬가지다. + + 전달한 항목만 부분 수정된다. `content`, `startTime`, `endTime` 모두 생략할 수 있다. + 시간 검증은 수정을 반영한 뒤의 최종 값으로 한다. 한쪽만 보내도 기존 값과 묶여 `startTime` ≤ `endTime` 이어야 한다. + + 이미 삭제된 피드백은 404 다. + """ + ) @OptionalAuthentication @ApiErrorCodes({"FEEDBACK403", "PROJECT403", "SHARELINK403", "SHARELINK410"}) @PatchMapping("/feedbacks/{feedbackId}") @@ -71,7 +95,18 @@ public ResponseEntity> updateFeedback( .ok(ApiResponse.success(CommonSuccessCode.OK, result)); } - @Operation(summary = "피드백 삭제") + @Operation( + summary = "피드백 삭제", + description = """ + **작성자 본인만 삭제할 수 있다.** 남의 피드백에 요청하면 `FEEDBACK403` 이다. + + 게스트는 `guestId` 를 쿼리 파라미터로, `X-Guest-Token` 을 헤더로 함께 보낸다. + 회원은 둘 다 생략하고 토큰만 보낸다. + + 실제로 행을 지우지 않고 삭제 시각만 남긴다. 목록과 답글 조회에서 함께 빠진다. + 이미 삭제된 피드백은 404 다. + """ + ) @OptionalAuthentication @ApiErrorCodes({"FEEDBACK403", "PROJECT403", "SHARELINK403", "SHARELINK410"}) @DeleteMapping("/feedbacks/{feedbackId}") @@ -88,7 +123,19 @@ public ResponseEntity> deleteFeedback( .ok(ApiResponse.success(CommonSuccessCode.OK, null)); } - @Operation(summary = "피드백 목록 조회") + @Operation( + summary = "피드백 목록 조회", + description = """ + **익명 조회는 막혀 있다.** 회원 토큰이나 게스트 자격(`guestId` + `X-Guest-Token`) 중 하나는 있어야 하고, + 둘 다 없으면 `SHARELINK403` 이다. 인증이 선택이라는 것은 게스트도 볼 수 있다는 뜻이지 누구나 볼 수 있다는 뜻이 아니다. + + 재생 지점이 있는 피드백이 앞에 오고 그 안에서 `startTime` 오름차순, 같은 지점이면 등록순이다. + 재생 지점이 없는 피드백은 모두 뒤로 밀린 뒤 등록순으로 붙는다. + + 커서는 직전 응답의 `nextCursor` 를 그대로 넣는다. 형식이 어긋나면 400 이다. + 각 항목에는 답글 개수가 함께 담기고, 삭제된 피드백은 빠진다. + """ + ) @OptionalAuthentication @ApiErrorCodes({"PROJECT403", "SHARELINK403", "SHARELINK410"}) @GetMapping("/videos/{videoId}/feedbacks") 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 1530fb2..2e02b26 100644 --- a/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java +++ b/src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java @@ -37,7 +37,20 @@ public class FeedbackDetailController { private final FeedbackDetailService feedbackDetailService; - @Operation(summary = "답글 작성") + @Operation( + summary = "답글 작성", + description = """ + 회원은 토큰으로, 게스트는 본문 `guestId` 와 `X-Guest-Token` 으로 식별한다. + **둘을 함께 보내거나 둘 다 보내지 않으면 400** 이다. 회원은 `guestId` 를 넣지 않는다. + + 회원은 원 피드백이 달린 영상의 프로젝트 활성 멤버여야 하고, 게스트는 그 영상의 공유 링크로 들어온 게스트여야 한다. + 원 피드백이 삭제됐으면 404 다. + + 답글은 한 단계까지만 달린다. 답글에 다시 답글을 달 수는 없다. + + 작성되면 작성자를 뺀 프로젝트 멤버 전원에게 알림이 가고 최근 활동에 남는다. + """ + ) @OptionalAuthentication @ResponseStatus(HttpStatus.CREATED) @ApiErrorCodes({"PROJECT403", "SHARELINK403", "SHARELINK410"}) @@ -56,7 +69,17 @@ public ResponseEntity> createReply( .body(ApiResponse.success(CommonSuccessCode.CREATED, result)); } - @Operation(summary = "답글 목록 조회") + @Operation( + summary = "답글 목록 조회", + description = """ + **익명 조회는 막혀 있다.** 회원 토큰이나 게스트 자격(`guestId` + `X-Guest-Token`) 중 하나는 있어야 하고, + 둘 다 없으면 `SHARELINK403` 이다. + + 등록순으로 내려간다. 커서는 직전 응답의 `nextCursor` 를 그대로 넣는다. + + 원 피드백이 삭제됐으면 404 이고, 삭제된 답글은 목록에서 빠진다. + """ + ) @OptionalAuthentication @ApiErrorCodes({"PROJECT403", "SHARELINK403", "SHARELINK410"}) @GetMapping("/feedbacks/{feedbackId}/replies") @@ -78,7 +101,17 @@ public ResponseEntity> getReplyList( .ok(ApiResponse.success(CommonSuccessCode.OK, result)); } - @Operation(summary = "답글 수정") + @Operation( + summary = "답글 수정", + description = """ + **작성자 본인만 수정할 수 있다.** 남의 답글에 요청하면 `FEEDBACK_REPLY403` 이다. + 원 피드백을 쓴 사람이라도 남이 단 답글은 고칠 수 없다. + + 피드백 수정과 달리 `content` 는 필수다. 답글에는 부분 수정할 다른 항목이 없다. + + 이미 삭제된 답글은 404 다. + """ + ) @OptionalAuthentication @ApiErrorCodes({"FEEDBACK_REPLY403", "PROJECT403", "SHARELINK403", "SHARELINK410"}) @PatchMapping("/replies/{replyId}") @@ -95,7 +128,17 @@ public ResponseEntity> updateReply( .ok(ApiResponse.success(CommonSuccessCode.OK, result)); } - @Operation(summary = "답글 삭제") + @Operation( + summary = "답글 삭제", + description = """ + **작성자 본인만 삭제할 수 있다.** 남의 답글에 요청하면 `FEEDBACK_REPLY403` 이다. + + 게스트는 `guestId` 를 쿼리 파라미터로, `X-Guest-Token` 을 헤더로 함께 보낸다. + 회원은 둘 다 생략하고 토큰만 보낸다. + + 실제로 행을 지우지 않고 삭제 시각만 남긴다. 이미 삭제된 답글은 404 다. + """ + ) @OptionalAuthentication @ApiErrorCodes({"FEEDBACK_REPLY403", "PROJECT403", "SHARELINK403", "SHARELINK410"}) @DeleteMapping("/replies/{replyId}") diff --git a/src/main/java/com/slatto/global/config/SwaggerAuthenticationCustomizer.java b/src/main/java/com/slatto/global/config/SwaggerAuthenticationCustomizer.java index 1d2054f..1f246ff 100644 --- a/src/main/java/com/slatto/global/config/SwaggerAuthenticationCustomizer.java +++ b/src/main/java/com/slatto/global/config/SwaggerAuthenticationCustomizer.java @@ -22,7 +22,9 @@ public class SwaggerAuthenticationCustomizer implements OperationCustomizer { private static final String BEARER_AUTH = "bearerAuth"; - private static final String OPTIONAL_AUTH_NOTE = + + // 엔드포인트가 직접 적은 설명과 구분해야 하는 쪽이 있어서 감추지 않는다. + static final String OPTIONAL_AUTH_NOTE = "인증은 선택입니다. 토큰을 보내면 로그인 사용자로, 보내지 않으면 게스트로 처리됩니다."; @Override diff --git a/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java b/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java index a495409..e429e15 100644 --- a/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java +++ b/src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java @@ -83,13 +83,18 @@ void everyExposedOperationHasSummary() { // summary 만 있으면 이름만 아는 상태다. 호출하는 쪽이 알아야 할 제약은 코드를 열어봐야 나온다. // summary 를 그대로 옮겨 적은 설명은 그 공백을 메우지 않으므로 없는 것으로 친다. + // + // 인증이 선택인 엔드포인트에는 안내 문구가 자동으로 붙는다. 그 문구까지 설명으로 세면 + // 설명을 한 줄도 적지 않은 엔드포인트가 통과한다. 걷어내고 남은 것만 본다. @Test @DisplayName("문서에 노출된 모든 엔드포인트는 summary 를 되풀이하지 않는 설명을 가진다") void everyExposedOperationHasMeaningfulDescription() { List missing = new ArrayList<>(); forEachOperation((path, httpMethod, operation) -> { - String description = operation.path("description").asText("").strip(); + String description = operation.path("description").asText("") + .replace(SwaggerAuthenticationCustomizer.OPTIONAL_AUTH_NOTE, "") + .strip(); String summary = operation.path("summary").asText("").strip(); if (description.isBlank() || description.equals(summary)) {