From 3ddf46dd971cd22873bd7d737d68ca433f2f736c Mon Sep 17 00:00:00 2001 From: ff1451 Date: Fri, 16 Oct 2026 16:22:36 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor:=20=ED=8A=B8=EB=9E=99=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EA=B4=80=EB=A6=AC=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EA=B2=80=EC=82=AC=EB=A5=BC=20TrackAccessGuard?= =?UTF-8?q?=EB=A1=9C=20=EB=AA=A8=EC=9D=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 게임·트랙 페이지·커리큘럼 서비스 7곳이 "회원 조회 -> Member.canManage 확인"을 각자 복사해 갖고 있었고, 게임 쪽 3곳은 "GAME" 트랙 코드 상수까지 따로 두었다. - TrackAccessGuard.requireManager / requireGameManager 하나로 모은다. - 도메인마다 응답 메시지가 달라 예외 생성자와 "회원 없음"·"권한 없음" 타입은 AccessErrors로 넘긴다. 응답(상태 코드·메시지)과 예외 클래스는 그대로다. - GAME 트랙 행이 없을 때 관리자만 통과하는 기존 동작을 유지한다(canManage(null)). --- .../service/AdminCurriculumService.java | 17 +++--- .../service/AdminCurriculumTreeService.java | 17 +++--- .../game/service/AdminGameBuildService.java | 20 ++----- .../game/service/AdminGameMemberService.java | 20 ++----- .../game/service/AdminGameService.java | 20 ++----- .../service/AdminTrackPageMemberService.java | 11 ++-- .../track/service/AdminTrackPageService.java | 10 ++-- .../track/service/TrackAccessGuard.java | 44 ++++++++++++++ .../track/TrackAccessGuardTest.java | 58 +++++++++++++++++++ 9 files changed, 150 insertions(+), 67 deletions(-) create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/TrackAccessGuard.java create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackAccessGuardTest.java diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java index aae0476..4e75feb 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java @@ -8,12 +8,12 @@ import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum; import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumRepository; import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; -import com.bcsdlab.bcsdinternalapiv2.member.model.Member; -import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException; import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType; import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard.AccessErrors; import java.time.Instant; import java.util.List; import lombok.RequiredArgsConstructor; @@ -25,10 +25,15 @@ @Transactional(readOnly = true) public class AdminCurriculumService { + private static final AccessErrors ACCESS_ERRORS = new AccessErrors( + CurriculumException::new, + CurriculumExceptionType.MEMBER_NOT_FOUND, + CurriculumExceptionType.CURRICULUM_ACCESS_DENIED); + private final CurriculumRepository curriculumRepository; private final TrackPageRepository trackPageRepository; private final AdminCurriculumTreeService adminCurriculumTreeService; - private final MemberRepository memberRepository; + private final TrackAccessGuard trackAccessGuard; public List getCurriculums(Long trackPageId) { return curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).stream() @@ -101,10 +106,6 @@ private Curriculum findOrThrow(Long id) { } private void requireCurriculumAccess(Long memberId, TrackPage trackPage) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new CurriculumException(CurriculumExceptionType.MEMBER_NOT_FOUND)); - if (!member.canManage(trackPage.getTrack())) { - throw new CurriculumException(CurriculumExceptionType.CURRICULUM_ACCESS_DENIED); - } + trackAccessGuard.requireManager(memberId, trackPage.getTrack(), ACCESS_ERRORS); } } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java index b8164dc..876c8e6 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java @@ -20,9 +20,9 @@ import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumWeekRepository; import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders; -import com.bcsdlab.bcsdinternalapiv2.member.model.Member; -import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard.AccessErrors; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -36,11 +36,16 @@ @Transactional(readOnly = true) public class AdminCurriculumTreeService { + private static final AccessErrors ACCESS_ERRORS = new AccessErrors( + CurriculumException::new, + CurriculumExceptionType.MEMBER_NOT_FOUND, + CurriculumExceptionType.CURRICULUM_ACCESS_DENIED); + private final CurriculumRepository curriculumRepository; private final CurriculumWeekRepository curriculumWeekRepository; private final CurriculumTopicRepository curriculumTopicRepository; private final CurriculumTopicDetailRepository curriculumTopicDetailRepository; - private final MemberRepository memberRepository; + private final TrackAccessGuard trackAccessGuard; public AdminCurriculumTreeResponse getTree(Long curriculumId) { Curriculum curriculum = curriculumRepository.findById(curriculumId) @@ -225,10 +230,6 @@ private CurriculumTopic findTopicOrThrow(Long topicId) { } private void requireCurriculumAccess(Long memberId, TrackPage trackPage) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new CurriculumException(CurriculumExceptionType.MEMBER_NOT_FOUND)); - if (!member.canManage(trackPage.getTrack())) { - throw new CurriculumException(CurriculumExceptionType.CURRICULUM_ACCESS_DENIED); - } + trackAccessGuard.requireManager(memberId, trackPage.getTrack(), ACCESS_ERRORS); } } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameBuildService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameBuildService.java index 0bb81e6..4268548 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameBuildService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameBuildService.java @@ -13,8 +13,8 @@ import com.bcsdlab.bcsdinternalapiv2.game.repository.GameRepository; import com.bcsdlab.bcsdinternalapiv2.member.model.Member; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; -import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; -import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard.AccessErrors; import java.time.Instant; import java.util.List; import java.util.Set; @@ -38,14 +38,15 @@ public class AdminGameBuildService { private static final Set TOKEN_ISSUABLE_STATUSES = Set.of( GameBuildStatus.PENDING, GameBuildStatus.PROCESSING, GameBuildStatus.FAILED); - private static final String GAME_TRACK_CODE = "GAME"; + private static final AccessErrors ACCESS_ERRORS = new AccessErrors( + GameException::new, GameExceptionType.MEMBER_NOT_FOUND, GameExceptionType.GAME_ACCESS_DENIED); private final GameRepository gameRepository; private final GameBuildRepository gameBuildRepository; private final MemberRepository memberRepository; - private final TrackMasterRepository trackMasterRepository; private final GameBuildProperties gameBuildProperties; private final GameBuildTokenService gameBuildTokenService; + private final TrackAccessGuard trackAccessGuard; public List getBuilds(Long gameId) { findGameOrThrow(gameId); @@ -105,15 +106,6 @@ private GameBuild findBuildOrThrow(Long gameId, Long buildId) { } private void requireGameAccess(Long memberId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new GameException(GameExceptionType.MEMBER_NOT_FOUND)); - if (member.isAdmin()) { - return; - } - TrackMaster gameTrack = trackMasterRepository.findByCode(GAME_TRACK_CODE) - .orElseThrow(() -> new GameException(GameExceptionType.GAME_ACCESS_DENIED)); - if (!member.canManage(gameTrack)) { - throw new GameException(GameExceptionType.GAME_ACCESS_DENIED); - } + trackAccessGuard.requireGameManager(memberId, ACCESS_ERRORS); } } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java index f4c3ae5..e075e57 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java @@ -12,8 +12,8 @@ import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders; import com.bcsdlab.bcsdinternalapiv2.member.model.Member; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; -import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; -import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard.AccessErrors; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -33,12 +33,13 @@ @Transactional(readOnly = true) public class AdminGameMemberService { - private static final String GAME_TRACK_CODE = "GAME"; + private static final AccessErrors ACCESS_ERRORS = new AccessErrors( + GameException::new, GameExceptionType.MEMBER_NOT_FOUND, GameExceptionType.GAME_ACCESS_DENIED); private final GameRepository gameRepository; private final GameMemberRepository gameMemberRepository; private final MemberRepository memberRepository; - private final TrackMasterRepository trackMasterRepository; + private final TrackAccessGuard trackAccessGuard; public List getMembers(Long gameId) { findGameOrThrow(gameId); @@ -106,15 +107,6 @@ private Game findGameOrThrow(Long id) { } private void requireGameAccess(Long memberId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new GameException(GameExceptionType.MEMBER_NOT_FOUND)); - if (member.isAdmin()) { - return; - } - TrackMaster gameTrack = trackMasterRepository.findByCode(GAME_TRACK_CODE) - .orElseThrow(() -> new GameException(GameExceptionType.GAME_ACCESS_DENIED)); - if (!member.canManage(gameTrack)) { - throw new GameException(GameExceptionType.GAME_ACCESS_DENIED); - } + trackAccessGuard.requireGameManager(memberId, ACCESS_ERRORS); } } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java index 34eada1..e1d73ab 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java @@ -25,12 +25,12 @@ import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders; import com.bcsdlab.bcsdinternalapiv2.global.util.SlugGenerator; -import com.bcsdlab.bcsdinternalapiv2.member.model.Member; -import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException; import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType; import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard.AccessErrors; import java.time.Instant; import java.util.ArrayList; import java.util.EnumSet; @@ -57,9 +57,10 @@ public class AdminGameService { private final GameRatingRepository gameRatingRepository; private final GameMemberRepository gameMemberRepository; private final TrackMasterRepository trackMasterRepository; - private final MemberRepository memberRepository; + private final TrackAccessGuard trackAccessGuard; - private static final String GAME_TRACK_CODE = "GAME"; + private static final AccessErrors ACCESS_ERRORS = new AccessErrors( + GameException::new, GameExceptionType.MEMBER_NOT_FOUND, GameExceptionType.GAME_ACCESS_DENIED); public List getGames() { return gameRepository.findAllByOrderByDisplayOrderAsc().stream() @@ -227,15 +228,6 @@ private TrackMaster findTrackOrNull(Long trackId) { } private void requireGameAccess(Long memberId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new GameException(GameExceptionType.MEMBER_NOT_FOUND)); - if (member.isAdmin()) { - return; - } - TrackMaster gameTrack = trackMasterRepository.findByCode(GAME_TRACK_CODE) - .orElseThrow(() -> new GameException(GameExceptionType.GAME_ACCESS_DENIED)); - if (!member.canManage(gameTrack)) { - throw new GameException(GameExceptionType.GAME_ACCESS_DENIED); - } + trackAccessGuard.requireGameManager(memberId, ACCESS_ERRORS); } } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageMemberService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageMemberService.java index 8ad20be..0e55f3d 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageMemberService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageMemberService.java @@ -13,6 +13,7 @@ import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPageMember; import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageMemberRepository; import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard.AccessErrors; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -31,9 +32,13 @@ @Transactional(readOnly = true) public class AdminTrackPageMemberService { + private static final AccessErrors ACCESS_ERRORS = new AccessErrors( + TrackException::new, TrackExceptionType.MEMBER_NOT_FOUND, TrackExceptionType.TRACK_ACCESS_DENIED); + private final TrackPageRepository trackPageRepository; private final TrackPageMemberRepository trackPageMemberRepository; private final MemberRepository memberRepository; + private final TrackAccessGuard trackAccessGuard; public List getMembers(Long trackPageId) { findTrackPageOrThrow(trackPageId); @@ -115,10 +120,6 @@ private TrackPage findTrackPageOrThrow(Long id) { } private void requireTrackAccess(Long memberId, TrackPage trackPage) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new TrackException(TrackExceptionType.MEMBER_NOT_FOUND)); - if (!member.canManage(trackPage.getTrack())) { - throw new TrackException(TrackExceptionType.TRACK_ACCESS_DENIED); - } + trackAccessGuard.requireManager(memberId, trackPage.getTrack(), ACCESS_ERRORS); } } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageService.java index d61b7be..912b392 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageService.java @@ -30,6 +30,7 @@ import com.bcsdlab.bcsdinternalapiv2.global.util.SlugGenerator; import com.bcsdlab.bcsdinternalapiv2.member.model.Member; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard.AccessErrors; import java.time.Instant; import java.util.ArrayList; import java.util.HashSet; @@ -45,6 +46,9 @@ @Transactional(readOnly = true) public class AdminTrackPageService { + private static final AccessErrors ACCESS_ERRORS = new AccessErrors( + TrackException::new, TrackExceptionType.MEMBER_NOT_FOUND, TrackExceptionType.TRACK_ACCESS_DENIED); + private final TrackPageRepository trackPageRepository; private final TrackMasterRepository trackMasterRepository; private final TrackStudyPointRepository trackStudyPointRepository; @@ -52,6 +56,7 @@ public class AdminTrackPageService { private final TrackPageTechStackRepository trackPageTechStackRepository; private final TrackPageMemberRepository trackPageMemberRepository; private final MemberRepository memberRepository; + private final TrackAccessGuard trackAccessGuard; public List getTrackPages() { return trackPageRepository.findAllByOrderByDisplayOrderAsc().stream() @@ -217,9 +222,6 @@ private void requireAdmin(Long memberId) { } private void requireTrackAccess(Long memberId, TrackPage trackPage) { - Member member = findMemberOrThrow(memberId); - if (!member.canManage(trackPage.getTrack())) { - throw new TrackException(TrackExceptionType.TRACK_ACCESS_DENIED); - } + trackAccessGuard.requireManager(memberId, trackPage.getTrack(), ACCESS_ERRORS); } } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/TrackAccessGuard.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/TrackAccessGuard.java new file mode 100644 index 0000000..ea0b85a --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/TrackAccessGuard.java @@ -0,0 +1,44 @@ +package com.bcsdlab.bcsdinternalapiv2.track.service; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdException; +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +import com.bcsdlab.bcsdinternalapiv2.member.model.Track; +import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 트랙 콘텐츠(트랙 페이지·커리큘럼·게임)를 고칠 수 있는지 확인한다. 관리자, 또는 해당 트랙의 멘토·정회원만 + * 통과한다({@link Member#canManage}). 도메인마다 응답 메시지가 달라서 던질 예외는 호출하는 쪽이 정한다. + */ +@Component +@RequiredArgsConstructor +public class TrackAccessGuard { + + private final MemberRepository memberRepository; + private final TrackMasterRepository trackMasterRepository; + + public void requireManager(Long memberId, TrackMaster track, AccessErrors errors) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> errors.exception().apply(errors.memberNotFound())); + if (!member.canManage(track)) { + throw errors.exception().apply(errors.accessDenied()); + } + } + + /** 게임은 GAME 트랙 소관이다. GAME 트랙 행이 없으면 관리자만 통과한다. */ + public void requireGameManager(Long memberId, AccessErrors errors) { + TrackMaster gameTrack = trackMasterRepository.findByCode(Track.GAME.name()).orElse(null); + requireManager(memberId, gameTrack, errors); + } + + /** 도메인별 예외 생성자와 "회원 없음"·"권한 없음" 예외 타입. */ + public record AccessErrors(Function exception, + BcsdExceptionType memberNotFound, + BcsdExceptionType accessDenied) { + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackAccessGuardTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackAccessGuardTest.java new file mode 100644 index 0000000..5aa1773 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackAccessGuardTest.java @@ -0,0 +1,58 @@ +package com.bcsdlab.bcsdinternalapiv2.track; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.bcsdlab.bcsdinternalapiv2.game.exception.GameException; +import com.bcsdlab.bcsdinternalapiv2.game.exception.GameExceptionType; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberRole; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; +import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackAccessGuard.AccessErrors; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class TrackAccessGuardTest { + + private static final AccessErrors ERRORS = new AccessErrors( + GameException::new, GameExceptionType.MEMBER_NOT_FOUND, GameExceptionType.GAME_ACCESS_DENIED); + + private final MemberRepository memberRepository = mock(MemberRepository.class); + private final TrackMasterRepository trackMasterRepository = mock(TrackMasterRepository.class); + private final TrackAccessGuard guard = new TrackAccessGuard(memberRepository, trackMasterRepository); + + @Test + void 회원이_없으면_회원_없음_예외다() { + when(memberRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> guard.requireManager(1L, null, ERRORS)) + .isInstanceOf(GameException.class) + .satisfies(e -> assertExceptionType(e, GameExceptionType.MEMBER_NOT_FOUND)); + } + + @Test + void GAME_트랙_행이_없으면_관리자만_통과한다() { + when(trackMasterRepository.findByCode("GAME")).thenReturn(Optional.empty()); + when(memberRepository.findById(1L)).thenReturn(Optional.of(member(MemberRole.ADMIN))); + when(memberRepository.findById(2L)).thenReturn(Optional.of(member(MemberRole.MEMBER))); + + assertThatCode(() -> guard.requireGameManager(1L, ERRORS)).doesNotThrowAnyException(); + assertThatThrownBy(() -> guard.requireGameManager(2L, ERRORS)) + .isInstanceOf(GameException.class) + .satisfies(e -> assertExceptionType(e, GameExceptionType.GAME_ACCESS_DENIED)); + } + + private static Member member(MemberRole role) { + return Member.builder().role(role).memberType(MemberType.MENTOR).build(); + } + + private static void assertExceptionType(Throwable e, GameExceptionType expected) { + assertThat(((GameException) e).getExceptionType()).isEqualTo(expected); + } +} From e0fbc2ca696fde4265505d4363a4fa0235104fa5 Mon Sep 17 00:00:00 2001 From: ff1451 Date: Fri, 16 Oct 2026 16:33:35 +0900 Subject: [PATCH 02/10] =?UTF-8?q?refactor:=20=EC=93=B0=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EC=95=8A=EB=8A=94=20=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 참조가 없는 에러 상수 INVALID_GOOGLE_FORM_URL, INVALID_PHOTO - 호출부가 없는 ReservationGroup.includesWeekday(), SoftDeletableEntity.isDeleted() (isDeleted는 @SQLRestriction 때문에 조회된 엔티티에서 항상 false였다) - 아무도 주입받지 않는 S3Client 빈과 테스트의 목(같은 SDK의 S3Presigner는 계속 쓴다) - 대입만 하고 쓰지 않는 지역 변수 3개(게임 존재 확인 호출은 남긴다) - 존재하지 않는 캐시를 무효화한다는 주석 --- .../game/service/AdminGameMemberService.java | 4 ++-- .../bcsdinternalapiv2/game/service/AdminGameService.java | 3 +-- .../bcsdinternalapiv2/global/SoftDeletableEntity.java | 4 ---- .../bcsdlab/bcsdinternalapiv2/global/config/S3Config.java | 8 -------- .../home/exception/HomeExceptionType.java | 1 - .../member/exception/MemberExceptionType.java | 1 - .../reservation/model/ReservationGroup.java | 4 ---- .../ledger/LedgerIntegrationTestSupport.java | 4 ---- 8 files changed, 3 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java index f4c3ae5..1eccd72 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java @@ -82,7 +82,7 @@ public List attachMembers(Long gameId, GameMembersAttac @Transactional public void detachMember(Long gameId, Long memberId, Long requesterId) { requireGameAccess(requesterId); - Game game = findGameOrThrow(gameId); + findGameOrThrow(gameId); GameMember assignment = gameMemberRepository.findByGame_IdAndMember_Id(gameId, memberId) .orElseThrow(() -> new GameException(GameExceptionType.GAME_MEMBER_NOT_FOUND)); gameMemberRepository.delete(assignment); @@ -91,7 +91,7 @@ public void detachMember(Long gameId, Long memberId, Long requesterId) { @Transactional public void reorder(Long gameId, OrderRequest request, Long requesterId) { requireGameAccess(requesterId); - Game game = findGameOrThrow(gameId); + findGameOrThrow(gameId); List assignments = gameMemberRepository.findAllByGame_IdOrderByDisplayOrderAsc(gameId); Map byId = assignments.stream() .collect(Collectors.toMap(GameMember::getId, assignment -> assignment)); diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java index 34eada1..6e1a16f 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java @@ -173,7 +173,6 @@ public List replaceScreenshots(Long id, GameScreenshotsR .build())); } game.updateThumbnail(imageUrls.isEmpty() ? null : imageUrls.get(0)); - // 스크린샷 첫 장이 게임 목록의 thumbnailUrl이기도 하므로 목록 캐시도 함께 무효화한다. return saved.stream().map(GameScreenshotResponse::from).toList(); } @@ -194,7 +193,7 @@ public GameRatingResponse upsertRating(Long id, GameRatingRequest request, Long @Transactional public void deleteRating(Long id, Long memberId) { requireGameAccess(memberId); - Game game = findGameOrThrow(id); + findGameOrThrow(id); gameRatingRepository.findByGame_Id(id).ifPresent(gameRatingRepository::delete); } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/SoftDeletableEntity.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/SoftDeletableEntity.java index cbd24a2..45902bd 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/SoftDeletableEntity.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/SoftDeletableEntity.java @@ -21,10 +21,6 @@ public abstract class SoftDeletableEntity extends BaseTimeEntity { @Column(name = "deleted_at") private Instant deletedAt; - public boolean isDeleted() { - return deletedAt != null; - } - public void delete(Instant now) { this.deletedAt = now; } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Config.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Config.java index 5472386..b0d3caa 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Config.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Config.java @@ -4,7 +4,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; @Configuration @@ -13,13 +12,6 @@ public class S3Config { private final S3Properties s3Properties; - @Bean - public S3Client s3Client() { - return S3Client.builder() - .region(Region.of(s3Properties.region())) - .build(); - } - @Bean public S3Presigner s3Presigner() { return S3Presigner.builder() diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/exception/HomeExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/exception/HomeExceptionType.java index 70bcfbc..003aa98 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/exception/HomeExceptionType.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/exception/HomeExceptionType.java @@ -9,7 +9,6 @@ public enum HomeExceptionType implements BcsdExceptionType { MENTOR_ALREADY_ASSIGNED(HttpStatus.CONFLICT, "이미 노출 중인 멘토입니다."), MENTOR_SLOT_NOT_FOUND(HttpStatus.NOT_FOUND, "노출 중이지 않은 멘토입니다."), QNA_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 질문입니다."), - INVALID_GOOGLE_FORM_URL(HttpStatus.BAD_REQUEST, "forms.gle 또는 docs.google.com/forms 주소만 등록할 수 있습니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/exception/MemberExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/exception/MemberExceptionType.java index 8b712d7..980fdcb 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/exception/MemberExceptionType.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/exception/MemberExceptionType.java @@ -11,7 +11,6 @@ public enum MemberExceptionType implements BcsdExceptionType { STUDENT_NUMBER_DUPLICATED(HttpStatus.CONFLICT, "이미 등록된 학번입니다."), INVALID_PHONE(HttpStatus.BAD_REQUEST, "올바른 전화번호 형식이 아닙니다."), INVALID_GITHUB_ID(HttpStatus.BAD_REQUEST, "올바른 깃허브 아이디가 아닙니다."), - INVALID_PHOTO(HttpStatus.BAD_REQUEST, "올바른 사진 파일이 아닙니다."), POSITION_NOT_FOUND(HttpStatus.BAD_REQUEST, "존재하지 않는 직책입니다."), SLACK_PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "Slack에서 해당 이메일로 등록된 프로필 이미지를 찾을 수 없습니다."), SLACK_SYNC_FAILED(HttpStatus.BAD_GATEWAY, "Slack 연동 중 오류가 발생했습니다."), diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/reservation/model/ReservationGroup.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/reservation/model/ReservationGroup.java index 7474453..7f10556 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/reservation/model/ReservationGroup.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/reservation/model/ReservationGroup.java @@ -70,8 +70,4 @@ public List getWeekdayList() { .map(DayOfWeek::valueOf) .toList(); } - - public boolean includesWeekday(DayOfWeek dayOfWeek) { - return getWeekdayList().contains(dayOfWeek); - } } diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/ledger/LedgerIntegrationTestSupport.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/ledger/LedgerIntegrationTestSupport.java index ee95975..b961208 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/ledger/LedgerIntegrationTestSupport.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/ledger/LedgerIntegrationTestSupport.java @@ -36,7 +36,6 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.AbstractMockHttpServletRequestBuilder; -import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; /** @@ -77,9 +76,6 @@ public abstract class LedgerIntegrationTestSupport extends IntegrationTestSuppor @MockitoBean protected S3Presigner s3Presigner; - @MockitoBean - protected S3Client s3Client; - /** Slack에는 실제로 보내지 않는다. */ @MockitoBean protected SlackClient slackClient; From 6c57b2f5d794bc3ba88374aea793b69e19b0aa27 Mon Sep 17 00:00:00 2001 From: ff1451 Date: Fri, 16 Oct 2026 16:36:18 +0900 Subject: [PATCH 03/10] =?UTF-8?q?perf:=20=EC=A7=80=EC=97=B0=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EC=97=B0=EA=B4=80=EC=9D=84=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=BD=EC=96=B4=20=ED=9A=8C=EC=9B=90=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=98=20N+1=20=EC=BF=BC=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=97=86=EC=95=A4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hibernate.default_batch_fetch_size가 없어, 회원 명부처럼 목록을 돌며 직책(@ManyToMany)·트랙을 읽는 곳이 회원마다 쿼리를 하나씩 더 보냈다. 회원 21명 관리자 명부 조회가 SQL 40개에서 10개로 준다. --- src/main/resources/application.yml | 2 + ...berDirectoryQueryCountIntegrationTest.java | 105 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberDirectoryQueryCountIntegrationTest.java diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5dbaf9a..7d483b4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -15,6 +15,8 @@ spring: properties: hibernate: format_sql: true + # 지연 로딩 연관(회원의 직책·트랙 등)을 목록 크기만큼 한 번에 읽는다(N+1 방지). + default_batch_fetch_size: 100 open-in-view: false flyway: diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberDirectoryQueryCountIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberDirectoryQueryCountIntegrationTest.java new file mode 100644 index 0000000..606390d --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberDirectoryQueryCountIntegrationTest.java @@ -0,0 +1,105 @@ +package com.bcsdlab.bcsdinternalapiv2.member; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberRole; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; +import com.bcsdlab.bcsdinternalapiv2.member.model.Position; +import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.member.repository.PositionRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManagerFactory; +import java.util.List; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.TestPropertySource; + +/** 명부 목록은 회원 수와 무관하게 일정한 쿼리 수로 조회한다(회원마다 직책·트랙을 따로 읽지 않는다). */ +@TestPropertySource(properties = "spring.jpa.properties.hibernate.generate_statistics=true") +class MemberDirectoryQueryCountIntegrationTest extends IntegrationTestSupport { + + private static final int MEMBER_COUNT = 20; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private PositionRepository positionRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @Test + void 명부_조회_쿼리_수는_회원_수에_비례하지_않는다() throws Exception { + List tracks = trackMasterRepository.findAll(); + List positions = positionRepository.findAll(); + for (int i = 0; i < MEMBER_COUNT; i++) { + memberRepository.save(Member.builder() + .studentNumber("2030%04d".formatted(i)) + .name("명부" + i) + .track(tracks.get(i % tracks.size())) + .generation("24-하") + .memberType(MemberType.REGULAR) + .university("한국기술교육대학교") + .department("컴퓨터공학부") + .positions(positions.isEmpty() ? List.of() : List.of(positions.get(i % positions.size()))) + .email("dir" + i + "@bcsd.club") + .phoneNumber("0100000%04d".formatted(i)) + .status(MemberStatus.ACTIVE) + .role(MemberRole.MEMBER) + .build()); + } + String token = adminToken(); + + Statistics statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); + statistics.clear(); + mockMvc.perform(get("/v1/admin/members").param("size", "50") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.members.length()").value(MEMBER_COUNT + 1)); + + assertThat(statistics.getPrepareStatementCount()).isLessThan(15); + } + + private String adminToken() throws Exception { + memberRepository.save(Member.builder() + .studentNumber("20309999") + .password(passwordEncoder.encode("Temp1234")) + .name("관리자") + .track(trackMasterRepository.findByCode("BACKEND").orElseThrow()) + .generation("16") + .memberType(MemberType.MENTOR) + .university("한국기술교육대학교") + .department("컴퓨터공학부") + .email("admin@bcsd.club") + .phoneNumber("01099999999") + .status(MemberStatus.ACTIVE) + .role(MemberRole.ADMIN) + .build()); + String body = mockMvc.perform(post("/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"studentNumber\":\"20309999\",\"password\":\"Temp1234\",\"rememberMe\":false}")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return new ObjectMapper().readTree(body).get("accessToken").asText(); + } +} From 91938e0fc4d935368d0d7cf9a54b7eb2bad0c350 Mon Sep 17 00:00:00 2001 From: ff1451 Date: Fri, 16 Oct 2026 16:46:25 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20=EC=83=88=20=ED=95=AD=EB=AA=A9?= =?UTF-8?q?=EC=9D=98=20=ED=91=9C=EC=8B=9C=20=EC=88=9C=EC=84=9C=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=9C=EC=88=98=EA=B0=80=20=EC=95=84=EB=8B=8C=20=EB=A7=88?= =?UTF-8?q?=EC=A7=80=EB=A7=89=20=EC=88=9C=EC=84=9C=20+=201=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10곳이 새 항목의 display_order를 "현재 행 수"로 정했는데, 삭제가 순서를 다시 매기지 않아 삭제 뒤 새 항목이 기존 항목과 같은 순서를 받았다(예: [0, 2]에 추가하면 2가 겹친다). 같은 순서끼리는 정렬이 정해지지 않아 공개 페이지 순서가 흔들릴 수 있었다. - DisplayOrders.next(형제 목록, 순서 getter): 비어 있으면 0, 아니면 마지막 순서 + 1. - 활동 분류, 커리큘럼·주차·토픽, 게임, 게임·트랙 페이지 부원, 멘토 슬롯, Q&A, 트랙 페이지에 적용한다. --- .../service/AdminActivityCategoryService.java | 3 +- .../service/AdminCurriculumService.java | 5 ++- .../service/AdminCurriculumTreeService.java | 7 ++-- .../game/service/AdminGameMemberService.java | 3 +- .../game/service/AdminGameService.java | 3 +- .../global/util/DisplayOrders.java | 14 ++++++++ .../home/service/AdminMentorSlotService.java | 3 +- .../home/service/AdminQnaService.java | 3 +- .../service/AdminTrackPageMemberService.java | 4 ++- .../track/service/AdminTrackPageService.java | 3 +- .../global/DisplayOrdersTest.java | 23 ++++++++++++ ...AdminQnaAndRecruitLinkIntegrationTest.java | 35 +++++++++++++++++++ 12 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/global/DisplayOrdersTest.java diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java index eb7e208..a400e87 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java @@ -39,7 +39,8 @@ public AdminActivityCategoryResponse createCategory(ActivityCategoryCreateReques throw new ActivityException(ActivityExceptionType.CATEGORY_SLUG_DUPLICATED); } - int displayOrder = activityCategoryRepository.findAllByOrderByDisplayOrderAsc().size(); + int displayOrder = DisplayOrders.next(activityCategoryRepository.findAllByOrderByDisplayOrderAsc(), + ActivityCategory::getDisplayOrder); ActivityCategory category = activityCategoryRepository.save(ActivityCategory.builder() .slug(request.slug()) .name(request.name()) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java index aae0476..6532f4c 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java @@ -8,6 +8,7 @@ import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum; import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumRepository; import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders; import com.bcsdlab.bcsdinternalapiv2.member.model.Member; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException; @@ -54,7 +55,9 @@ public AdminCurriculumSummaryResponse createCurriculum(Long trackPageId, Curricu throw new CurriculumException(CurriculumExceptionType.NAME_REQUIRED); } - int displayOrder = curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).size(); + int displayOrder = DisplayOrders.next( + curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId), + Curriculum::getDisplayOrder); Curriculum curriculum = curriculumRepository.save(Curriculum.builder() .trackPage(trackPage) .name(name) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java index b8164dc..e6dfc8b 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java @@ -75,7 +75,9 @@ public CurriculumWeekResponse createWeek(Long curriculumId, WeekRequest request, .orElseThrow(() -> new CurriculumException(CurriculumExceptionType.CURRICULUM_NOT_FOUND)); requireCurriculumAccess(memberId, curriculum.getTrackPage()); - int displayOrder = curriculumWeekRepository.findAllByCurriculum_IdOrderByDisplayOrderAsc(curriculumId).size(); + int displayOrder = DisplayOrders.next( + curriculumWeekRepository.findAllByCurriculum_IdOrderByDisplayOrderAsc(curriculumId), + CurriculumWeek::getDisplayOrder); CurriculumWeek week = curriculumWeekRepository.save(CurriculumWeek.builder() .curriculum(curriculum) .weekFrom(request.weekFrom()) @@ -119,7 +121,8 @@ public void reorderWeeks(Long curriculumId, OrderRequest request, Long memberId) public CurriculumTopicResponse createTopic(Long weekId, TopicRequest request, Long memberId) { CurriculumWeek week = findWeekOrThrow(weekId); requireCurriculumAccess(memberId, week.getCurriculum().getTrackPage()); - int displayOrder = curriculumTopicRepository.findAllByWeek_IdOrderByDisplayOrderAsc(weekId).size(); + int displayOrder = DisplayOrders.next(curriculumTopicRepository.findAllByWeek_IdOrderByDisplayOrderAsc(weekId), + CurriculumTopic::getDisplayOrder); CurriculumTopic topic = curriculumTopicRepository.save(CurriculumTopic.builder() .week(week) .title(request.title()) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java index f4c3ae5..e25d289 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java @@ -68,7 +68,8 @@ public List attachMembers(Long gameId, GameMembersAttac } } - int nextOrder = gameMemberRepository.findAllByGame_IdOrderByDisplayOrderAsc(gameId).size(); + int nextOrder = DisplayOrders.next(gameMemberRepository.findAllByGame_IdOrderByDisplayOrderAsc(gameId), + GameMember::getDisplayOrder); for (Long memberId : memberIds) { gameMemberRepository.save(GameMember.builder() .game(game) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java index 34eada1..a46dd69 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java @@ -101,7 +101,8 @@ public AdminGameDetailResponse createGame(GameCreateRequest request, Long member .oneLiner(request.oneLiner()) .track(track) .teamLabel(request.teamLabel()) - .displayOrder((int) gameRepository.count()) + .displayOrder(DisplayOrders.next(gameRepository.findAllByOrderByDisplayOrderAsc(), + Game::getDisplayOrder)) .published(false) .build(); diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrders.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrders.java index 9067176..81b0545 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrders.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrders.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.ToIntFunction; /** * {@code display_order} 재부여 순수 함수. 특정 엔티티 타입에 의존하지 않는다 — @@ -22,6 +23,19 @@ public final class DisplayOrders { private DisplayOrders() { } + /** + * 새 항목의 display_order. 삭제가 순서를 다시 매기지 않아 번호에 빈칸이 생기므로, 개수가 아니라 + * 마지막 순서 + 1이다(개수를 쓰면 기존 항목과 순서가 겹친다). + * + * @param siblingsInOrder 같은 부모 아래 형제 항목, display_order 오름차순 + */ + public static int next(List siblingsInOrder, ToIntFunction displayOrder) { + if (siblingsInOrder.isEmpty()) { + return 0; + } + return displayOrder.applyAsInt(siblingsInOrder.getLast()) + 1; + } + /** * @param requestedIds 새 순서대로 나열된 id 목록 (배열 인덱스가 곧 새 display_order) * @param existingIds 현재 그 부모 아래 존재하는 전체 id 집합 diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminMentorSlotService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminMentorSlotService.java index aeef46c..80442bc 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminMentorSlotService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminMentorSlotService.java @@ -44,7 +44,8 @@ public List addSlot(MentorSlotCreateRequest request) { throw new HomeException(HomeExceptionType.MENTOR_ALREADY_ASSIGNED); } - int nextOrder = (int) mentorSlotRepository.count(); + int nextOrder = DisplayOrders.next(mentorSlotRepository.findAllByOrderByDisplayOrderAsc(), + MentorSlot::getDisplayOrder); mentorSlotRepository.save(MentorSlot.builder().member(member).displayOrder(nextOrder).build()); return getSlots(); } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminQnaService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminQnaService.java index 903a5a3..91efba2 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminQnaService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminQnaService.java @@ -35,7 +35,8 @@ public AdminQnaResponse createQnaItem(QnaCreateRequest request) { QnaItem saved = qnaItemRepository.save(QnaItem.builder() .question(request.question()) .answer(request.answer()) - .displayOrder((int) qnaItemRepository.count()) + .displayOrder(DisplayOrders.next(qnaItemRepository.findAllByOrderByDisplayOrderAsc(), + QnaItem::getDisplayOrder)) .published(true) .build()); return AdminQnaResponse.from(saved); diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageMemberService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageMemberService.java index 8ad20be..555a4e2 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageMemberService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageMemberService.java @@ -63,7 +63,9 @@ public List attachMembers(Long trackPageId, TrackP } } - int nextOrder = trackPageMemberRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).size(); + int nextOrder = DisplayOrders.next( + trackPageMemberRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId), + TrackPageMember::getDisplayOrder); for (Long memberId : memberIds) { trackPageMemberRepository.save(TrackPageMember.builder() .trackPage(trackPage) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageService.java index d61b7be..e057ab8 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageService.java @@ -103,7 +103,8 @@ public AdminTrackPageDetailResponse createTrackPage(TrackPageCreateRequest reque .slug(slug) .displayName(request.displayName()) .tagline(request.tagline()) - .displayOrder((int) trackPageRepository.count()) + .displayOrder(DisplayOrders.next(trackPageRepository.findAllByOrderByDisplayOrderAsc(), + TrackPage::getDisplayOrder)) .published(true) .build(); diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/DisplayOrdersTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/DisplayOrdersTest.java new file mode 100644 index 0000000..521ea8e --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/DisplayOrdersTest.java @@ -0,0 +1,23 @@ +package com.bcsdlab.bcsdinternalapiv2.global; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders; +import java.util.List; +import java.util.function.ToIntFunction; +import org.junit.jupiter.api.Test; + +class DisplayOrdersTest { + + private static final ToIntFunction SELF = Integer::intValue; + + @Test + void 형제가_없으면_0이다() { + assertThat(DisplayOrders.next(List.of(), SELF)).isZero(); + } + + @Test + void 삭제로_빈_번호가_있어도_마지막_순서_다음이다() { + assertThat(DisplayOrders.next(List.of(0, 2), SELF)).isEqualTo(3); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/home/AdminQnaAndRecruitLinkIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/home/AdminQnaAndRecruitLinkIntegrationTest.java index fa64e7a..5406e42 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/home/AdminQnaAndRecruitLinkIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/home/AdminQnaAndRecruitLinkIntegrationTest.java @@ -1,5 +1,7 @@ package com.bcsdlab.bcsdinternalapiv2.home; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -21,6 +23,8 @@ import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -186,4 +190,35 @@ private String login(String studentNumber) throws Exception { .andReturn().getResponse().getContentAsString(); return objectMapper.readTree(body).get("accessToken").asText(); } + + @Test + @DisplayName("Q&A를 지운 뒤 새로 만들면 남은 항목과 순서가 겹치지 않고 맨 뒤에 붙는다") + void 삭제_후_생성한_Q_A는_맨_뒤에_붙는다() throws Exception { + long first = createQna("첫 질문"); + long second = createQna("둘째 질문"); + createQna("셋째 질문"); + + mockMvc.perform(delete("/v1/admin/qna/" + second).header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + long fourth = createQna("넷째 질문"); + + String list = mockMvc.perform(get("/v1/admin/qna").header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + List orders = new ArrayList<>(); + objectMapper.readTree(list).forEach(item -> orders.add(item.get("displayOrder").asInt())); + assertThat(orders).doesNotHaveDuplicates(); + assertThat(objectMapper.readTree(list).get(0).get("id").asLong()).isEqualTo(first); + assertThat(objectMapper.readTree(list).get(2).get("id").asLong()).isEqualTo(fourth); + } + + private long createQna(String question) throws Exception { + String body = mockMvc.perform(post("/v1/admin/qna") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"question\":\"%s\",\"answer\":\"답변\"}".formatted(question))) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(body).get("id").asLong(); + } } From 38df7ee30d52ee72b8e4b1b90ef2de0833365fda Mon Sep 17 00:00:00 2001 From: ff1451 Date: Fri, 16 Oct 2026 16:50:24 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20=EA=B2=8C=EC=9E=84=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=20=EC=9B=B9=ED=9B=85=EC=9D=84=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=A4=91=EC=9D=B8=20=EB=B9=8C=EB=93=9C=EC=97=90?= =?UTF-8?q?=EB=A7=8C=20=EB=B0=98=EC=98=81=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 완료 웹훅이 빌드의 현재 상태를 보지 않아, 다시 보내거나 늦게 도착한 웹훅이 보관된 옛 빌드를 다시 활성화하거나(현재 빌드는 보관됨), 활성 빌드를 FAILED로 내릴 수 있었다. - 상태 전이 규칙을 GameBuild에 둔다: ACTIVE·FAILED 반영은 PROCESSING에서만, 아니면 409. 같은 결과의 재전송(ACTIVE->ACTIVE, FAILED->FAILED)은 아무것도 바꾸지 않고 204. - 같은 빌드에 대한 동시 웹훅이 엇갈리지 않게 빌드 행을 잠그고 읽는다. --- .../game/model/GameBuild.java | 26 +++++++- .../game/repository/GameBuildRepository.java | 8 +++ .../game/service/GameBuildWebhookService.java | 7 ++- .../game/AdminGameBuildIntegrationTest.java | 61 +++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/model/GameBuild.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/model/GameBuild.java index ef82003..37260d7 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/model/GameBuild.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/model/GameBuild.java @@ -1,5 +1,7 @@ package com.bcsdlab.bcsdinternalapiv2.game.model; +import com.bcsdlab.bcsdinternalapiv2.game.exception.GameException; +import com.bcsdlab.bcsdinternalapiv2.game.exception.GameExceptionType; import com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity; import com.bcsdlab.bcsdinternalapiv2.member.model.Member; import jakarta.persistence.Column; @@ -86,17 +88,39 @@ public void updateStatus(GameBuildStatus status) { this.status = status; } - public void applyActive(Integer canvasWidth, Integer canvasHeight, Long storageBytes, String buildFileUrl) { + /** + * 완료 웹훅(ACTIVE)을 반영한다. 업로드 중(PROCESSING)인 빌드만 활성화한다 — 늦게 도착하거나 다시 보낸 웹훅이 + * 보관된 옛 빌드를 되살리지 못하게 한다. 이미 ACTIVE면 재전송으로 보고 아무것도 바꾸지 않는다. + * + * @return 이번 호출로 활성화됐으면 true + */ + public boolean applyActive(Integer canvasWidth, Integer canvasHeight, Long storageBytes, String buildFileUrl) { + if (status == GameBuildStatus.ACTIVE) { + return false; + } + requireProcessing(); this.status = GameBuildStatus.ACTIVE; this.canvasWidth = canvasWidth; this.canvasHeight = canvasHeight; this.storageBytes = storageBytes; this.buildFileUrl = buildFileUrl; this.failureReason = null; + return true; } + /** 실패 웹훅(FAILED)을 반영한다. 업로드 중인 빌드만 실패 처리하고, 이미 FAILED면 아무것도 바꾸지 않는다. */ public void markFailed(String failureReason) { + if (status == GameBuildStatus.FAILED) { + return; + } + requireProcessing(); this.status = GameBuildStatus.FAILED; this.failureReason = failureReason; } + + private void requireProcessing() { + if (status != GameBuildStatus.PROCESSING) { + throw new GameException(GameExceptionType.GAME_BUILD_INVALID_STATE); + } + } } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/repository/GameBuildRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/repository/GameBuildRepository.java index a1de086..7255d94 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/repository/GameBuildRepository.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/repository/GameBuildRepository.java @@ -2,9 +2,13 @@ import com.bcsdlab.bcsdinternalapiv2.game.model.GameBuild; import com.bcsdlab.bcsdinternalapiv2.game.model.GameBuildStatus; +import jakarta.persistence.LockModeType; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface GameBuildRepository extends JpaRepository { @@ -13,4 +17,8 @@ public interface GameBuildRepository extends JpaRepository { List findAllByGame_IdOrderByUploadedAtDesc(Long gameId); List findAllByGame_IdAndStatus(Long gameId, GameBuildStatus status); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select b from GameBuild b where b.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/GameBuildWebhookService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/GameBuildWebhookService.java index 7f57340..3d90332 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/GameBuildWebhookService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/GameBuildWebhookService.java @@ -28,7 +28,7 @@ public void applyResult(Long buildId, String secretHeader, GameBuildWebhookReque throw new GameException(GameExceptionType.GAME_BUILD_WEBHOOK_UNAUTHORIZED); } - GameBuild build = gameBuildRepository.findById(buildId) + GameBuild build = gameBuildRepository.findByIdForUpdate(buildId) .orElseThrow(() -> new GameException(GameExceptionType.GAME_BUILD_NOT_FOUND)); switch (request.status()) { @@ -39,8 +39,11 @@ public void applyResult(Long buildId, String secretHeader, GameBuildWebhookReque } private void applyActive(GameBuild build, GameBuildWebhookRequest request) { - build.applyActive(request.canvasWidth(), request.canvasHeight(), request.storageBytes(), + boolean activated = build.applyActive(request.canvasWidth(), request.canvasHeight(), request.storageBytes(), request.buildFileUrl()); + if (!activated) { + return; + } Long gameId = build.getGame().getId(); gameBuildRepository.findAllByGame_IdAndStatus(gameId, GameBuildStatus.ACTIVE).stream() diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/game/AdminGameBuildIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/game/AdminGameBuildIntegrationTest.java index a2cc288..b46af2a 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/game/AdminGameBuildIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/game/AdminGameBuildIntegrationTest.java @@ -27,6 +27,7 @@ import org.springframework.http.MediaType; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.ResultActions; @TestPropertySource(properties = "app.game-build.secret=test-only-game-build-secret") class AdminGameBuildIntegrationTest extends IntegrationTestSupport { @@ -262,4 +263,64 @@ private String login(String studentNumber) throws Exception { .andReturn().getResponse().getContentAsString(); return objectMapper.readTree(body).get("accessToken").asText(); } + + @Test + @DisplayName("보관된 옛 빌드의 ACTIVE 웹훅이 다시 와도 409이고 현재 활성 빌드는 그대로다") + void 보관된_빌드의_ACTIVE_재전송은_409() throws Exception { + long firstBuildId = createBuild("1.0.0"); + issueUploadToken(firstBuildId); + activateBuild(firstBuildId, "https://bcsdlab.com/games/neon-drift/index.html"); + long secondBuildId = createBuild("1.1.0"); + issueUploadToken(secondBuildId); + activateBuild(secondBuildId, "https://bcsdlab.com/games/neon-drift/index.html"); + + postWebhook(firstBuildId, "{\"status\":\"ACTIVE\"}").andExpect(status().isConflict()); + + mockMvc.perform(get("/v1/games/neon-drift")) + .andExpect(jsonPath("$.activeBuild.version").value("1.1.0")); + } + + @Test + @DisplayName("활성 빌드에 늦게 도착한 FAILED 웹훅은 409이고 빌드는 활성 상태로 남는다") + void 활성_빌드의_늦은_FAILED는_409() throws Exception { + long buildId = createBuild("1.0.0"); + issueUploadToken(buildId); + activateBuild(buildId, "https://bcsdlab.com/games/neon-drift/index.html"); + + postWebhook(buildId, "{\"status\":\"FAILED\",\"failureReason\":\"late\"}") + .andExpect(status().isConflict()); + + mockMvc.perform(get("/v1/admin/games/" + game.getId() + "/builds") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(jsonPath("$[0].status").value("ACTIVE")); + } + + @Test + @DisplayName("같은 ACTIVE 웹훅을 다시 보내면 204이고 아무것도 바뀌지 않는다") + void 같은_ACTIVE_재전송은_멱등하다() throws Exception { + long buildId = createBuild("1.0.0"); + issueUploadToken(buildId); + activateBuild(buildId, "https://bcsdlab.com/games/neon-drift/index.html"); + + activateBuild(buildId, "https://bcsdlab.com/games/neon-drift/other.html"); + + mockMvc.perform(get("/v1/games/neon-drift")) + .andExpect(jsonPath("$.activeBuild.buildFileUrl") + .value("https://bcsdlab.com/games/neon-drift/index.html")); + } + + @Test + @DisplayName("업로드 토큰을 받지 않은 PENDING 빌드에는 ACTIVE 웹훅이 409다") + void PENDING_빌드의_ACTIVE는_409() throws Exception { + long buildId = createBuild("1.0.0"); + + postWebhook(buildId, "{\"status\":\"ACTIVE\"}").andExpect(status().isConflict()); + } + + private ResultActions postWebhook(long buildId, String body) throws Exception { + return mockMvc.perform(post("/v1/games/builds/" + buildId + "/webhook") + .header("X-Game-Build-Secret", gameBuildProperties.secret()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)); + } } From 981d253fb44591dbb0a2d3a31886bfaa710ee57c Mon Sep 17 00:00:00 2001 From: ff1451 Date: Fri, 16 Oct 2026 17:00:03 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=20=ED=95=9C=20?= =?UTF-8?q?=EB=AA=85=EC=9D=98=20Slack=20=EC=82=AC=EC=A7=84=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=EB=A5=BC=20=ED=9A=8C=EC=9B=90=20=ED=96=89=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20=EB=B0=96=EC=97=90=EC=84=9C=20=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit syncProfileImageFromSlack이 회원 행을 FOR UPDATE로 잠근 트랜잭션 안에서 Slack HTTP를 기다려, 그동안 같은 회원의 로그인·토큰 갱신(같은 행을 잠근다)이 막혔다. - SlackProfileSyncService.syncOne으로 옮겨, 잠금 없이 회원을 읽고 트랜잭션 밖에서 Slack을 부른 뒤 저장만 기존 updatePhotoUrl 트랜잭션으로 한다(전체 동기화 syncAll과 같은 방식). - 응답과 에러는 그대로다. --- .../controller/AdminMemberController.java | 2 +- .../service/MemberDirectoryService.java | 12 ---------- .../service/SlackProfileSyncService.java | 13 +++++++++++ .../member/MemberSlackIdIntegrationTest.java | 23 +++++++++++++++++++ 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/controller/AdminMemberController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/controller/AdminMemberController.java index 5122dc2..677d635 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/controller/AdminMemberController.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/controller/AdminMemberController.java @@ -154,7 +154,7 @@ public ResponseEntity updatePhotoUrl(@PathVariable Long memberId, @Override @PostMapping("/{memberId}/photo/slack-sync") public ResponseEntity syncPhotoFromSlack(@PathVariable Long memberId) { - return ResponseEntity.ok(new PhotoUrlResponse(memberDirectoryService.syncProfileImageFromSlack(memberId))); + return ResponseEntity.ok(new PhotoUrlResponse(slackProfileSyncService.syncOne(memberId))); } @Override diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/MemberDirectoryService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/MemberDirectoryService.java index 695810e..2a7bc06 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/MemberDirectoryService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/MemberDirectoryService.java @@ -6,7 +6,6 @@ import com.bcsdlab.bcsdinternalapiv2.member.controller.dto.response.AdminMemberDirectoryResponse; import com.bcsdlab.bcsdinternalapiv2.member.controller.dto.response.MemberDirectoryResponse; import com.bcsdlab.bcsdinternalapiv2.member.controller.dto.response.PhotoPresignedUrlResponse; -import com.bcsdlab.bcsdinternalapiv2.member.client.SlackClient; import com.bcsdlab.bcsdinternalapiv2.member.exception.MemberException; import com.bcsdlab.bcsdinternalapiv2.member.exception.MemberExceptionType; import com.bcsdlab.bcsdinternalapiv2.member.model.AcademicStatus; @@ -43,7 +42,6 @@ public class MemberDirectoryService { private final TrackMasterRepository trackMasterRepository; private final PositionRepository positionRepository; private final PhotoStorageService photoStorageService; - private final SlackClient slackClient; @Transactional(readOnly = true) public MemberDirectoryResponse getDirectory(MemberDirectoryQuery query, Pageable pageable) { @@ -140,16 +138,6 @@ public void updatePhotoUrl(Long memberId, String photoUrl) { member.updateProfileImageUrl(photoUrl); } - @Transactional - public String syncProfileImageFromSlack(Long memberId) { - Member member = memberRepository.findByIdForUpdate(memberId) - .orElseThrow(() -> new MemberException(MemberExceptionType.MEMBER_NOT_FOUND)); - String imageUrl = slackClient.findProfileImageUrlByEmail(member.getEmail()) - .orElseThrow(() -> new MemberException(MemberExceptionType.SLACK_PROFILE_NOT_FOUND)); - member.updateProfileImageUrl(imageUrl); - return imageUrl; - } - private boolean isBlank(String value) { return value == null || value.isBlank(); } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/SlackProfileSyncService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/SlackProfileSyncService.java index ff5f65b..03b59b4 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/SlackProfileSyncService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/SlackProfileSyncService.java @@ -38,6 +38,19 @@ public class SlackProfileSyncService { private final SlackClient slackClient; private final MemberDirectoryService memberDirectoryService; + /** + * 한 회원의 프로필 사진을 Slack(이메일 조회)에서 가져와 저장한다. Slack 호출은 트랜잭션과 행 잠금 밖에서 하고, + * 저장만 짧은 트랜잭션으로 한다 — 잠금을 쥔 채 외부 호출을 기다리면 같은 회원의 로그인·토큰 갱신이 막힌다. + */ + public String syncOne(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberExceptionType.MEMBER_NOT_FOUND)); + String imageUrl = slackClient.findProfileImageUrlByEmail(member.getEmail()) + .orElseThrow(() -> new MemberException(MemberExceptionType.SLACK_PROFILE_NOT_FOUND)); + memberDirectoryService.updatePhotoUrl(memberId, imageUrl); + return imageUrl; + } + public SlackProfileSyncResponse syncAll() { // 가입 전·잠김 회원도 홈페이지에 노출될 수 있으므로 탈퇴한 회원만 제외한다. List members = memberRepository.findAllByStatusNot(MemberStatus.WITHDRAWN); diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberSlackIdIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberSlackIdIntegrationTest.java index 73558ec..f5eccbe 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberSlackIdIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/MemberSlackIdIntegrationTest.java @@ -27,13 +27,16 @@ import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.support.TransactionSynchronizationManager; @TestPropertySource(properties = "app.mail.transport=log") class MemberSlackIdIntegrationTest extends IntegrationTestSupport { @@ -343,4 +346,24 @@ private String login(String studentNumber) throws Exception { .getContentAsString(); return objectMapper.readTree(responseBody).get("accessToken").asText(); } + + @Test + @DisplayName("Slack 사진 동기화는 트랜잭션(회원 행 잠금) 밖에서 Slack을 부르고 사진을 저장한다") + void Slack_사진_동기화는_트랜잭션_밖에서_Slack을_부른다() throws Exception { + Member member = saveMember("20240021", "윤사진", MemberRole.MEMBER); + AtomicBoolean calledInTransaction = new AtomicBoolean(true); + when(slackClient.findProfileImageUrlByEmail(member.getEmail())).thenAnswer(invocation -> { + calledInTransaction.set(TransactionSynchronizationManager.isActualTransactionActive()); + return Optional.of("https://avatars.slack-edge.com/photo.png"); + }); + + mockMvc.perform(post("/v1/admin/members/" + member.getId() + "/photo/slack-sync") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.photoUrl").value("https://avatars.slack-edge.com/photo.png")); + + assertThat(calledInTransaction).isFalse(); + assertThat(memberRepository.findById(member.getId()).orElseThrow().getProfileImageUrl()) + .isEqualTo("https://avatars.slack-edge.com/photo.png"); + } } From 4c20b6effba07c2840c89901783a3ba2ff4a8289 Mon Sep 17 00:00:00 2001 From: ff1451 Date: Fri, 16 Oct 2026 17:02:12 +0900 Subject: [PATCH 07/10] =?UTF-8?q?chore:=20=EB=A7=8C=EB=A3=8C=EB=90=9C=20re?= =?UTF-8?q?fresh=20token=C2=B7=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EC=A0=95=20=ED=86=A0=ED=81=B0=EC=9D=84=20?= =?UTF-8?q?=EB=A7=A4=EC=9D=BC=20=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인과 토큰 갱신마다 refresh_token 행이 하나씩 쌓이고, password_reset_token도 지워지지 않았다. 매일 04:00(KST)에 만료된 지 30일이 지난 토큰을 지운다. 만료된 토큰은 갱신 때 재사용 검사보다 먼저 "만료"로 거절되므로 지워도 재사용 감지에는 영향이 없다. 30일 여유는 만료된 쿠키로 요청한 사용자가 "만료" 안내를 받도록 남겨 둔 것이다. --- .../PasswordResetTokenRepository.java | 5 ++ .../repository/RefreshTokenRepository.java | 5 ++ .../ExpiredTokenCleanupScheduler.java | 41 ++++++++++ .../ExpiredTokenCleanupIntegrationTest.java | 81 +++++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/auth/schedule/ExpiredTokenCleanupScheduler.java create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/ExpiredTokenCleanupIntegrationTest.java diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/auth/repository/PasswordResetTokenRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/auth/repository/PasswordResetTokenRepository.java index 32ad372..fb93962 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/auth/repository/PasswordResetTokenRepository.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/auth/repository/PasswordResetTokenRepository.java @@ -26,4 +26,9 @@ public interface PasswordResetTokenRepository extends JpaRepository만료된 토큰은 갱신 시 재사용 검사보다 먼저 "만료"로 거절되므로 지워도 재사용 감지에는 영향이 없다. + * 다만 만료 직후 지우면 만료된 쿠키로 요청한 사용자가 "만료" 대신 "유효하지 않음" 안내를 받으므로 + * {@link #RETENTION}만큼 여유를 둔다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ExpiredTokenCleanupScheduler { + + /** 로그인 유지(remember-me) 유효 기간과 같다. */ + static final Duration RETENTION = Duration.ofDays(30); + + private final RefreshTokenRepository refreshTokenRepository; + private final PasswordResetTokenRepository passwordResetTokenRepository; + private final Clock clock; + + // 매일 04:00(KST). 사용자가 적은 시간대다. + @Scheduled(cron = "0 0 4 * * *", zone = "Asia/Seoul") + public void deleteExpiredTokens() { + Instant cutoff = Instant.now(clock).minus(RETENTION); + int refreshTokens = refreshTokenRepository.deleteExpiredBefore(cutoff); + int resetTokens = passwordResetTokenRepository.deleteExpiredBefore(cutoff); + log.info("EXPIRED_TOKEN_CLEANUP_DONE: refreshTokens={} passwordResetTokens={} cutoff={}", + refreshTokens, resetTokens, cutoff); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/ExpiredTokenCleanupIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/ExpiredTokenCleanupIntegrationTest.java new file mode 100644 index 0000000..3858a6d --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/ExpiredTokenCleanupIntegrationTest.java @@ -0,0 +1,81 @@ +package com.bcsdlab.bcsdinternalapiv2.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import com.bcsdlab.bcsdinternalapiv2.auth.model.PasswordResetToken; +import com.bcsdlab.bcsdinternalapiv2.auth.model.RefreshToken; +import com.bcsdlab.bcsdinternalapiv2.auth.repository.PasswordResetTokenRepository; +import com.bcsdlab.bcsdinternalapiv2.auth.repository.RefreshTokenRepository; +import com.bcsdlab.bcsdinternalapiv2.auth.schedule.ExpiredTokenCleanupScheduler; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberRole; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; +import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class ExpiredTokenCleanupIntegrationTest extends IntegrationTestSupport { + + @Autowired + private ExpiredTokenCleanupScheduler scheduler; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordResetTokenRepository passwordResetTokenRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Test + void 만료된_지_30일이_지난_토큰만_지운다() { + Long memberId = memberRepository.save(Member.builder() + .studentNumber("20400001") + .name("토큰정리") + .track(trackMasterRepository.findByCode("BACKEND").orElseThrow()) + .generation("24-하") + .memberType(MemberType.REGULAR) + .university("한국기술교육대학교") + .department("컴퓨터공학부") + .email("cleanup@bcsd.club") + .status(MemberStatus.ACTIVE) + .role(MemberRole.MEMBER) + .build()).getId(); + Instant now = Instant.now(); + refreshToken(memberId, "old", now.minus(Duration.ofDays(31))); + refreshToken(memberId, "recently-expired", now.minus(Duration.ofDays(1))); + refreshToken(memberId, "valid", now.plus(Duration.ofDays(1))); + resetToken(memberId, "old", now.minus(Duration.ofDays(31))); + resetToken(memberId, "valid", now.plus(Duration.ofMinutes(30))); + + scheduler.deleteExpiredTokens(); + + assertThat(refreshTokenRepository.findAll()).extracting(RefreshToken::getTokenHash) + .containsExactlyInAnyOrder("recently-expired", "valid"); + assertThat(passwordResetTokenRepository.findAll()).extracting(PasswordResetToken::getTokenHash) + .containsExactly("valid"); + } + + private void refreshToken(Long memberId, String hash, Instant expiresAt) { + refreshTokenRepository.save(RefreshToken.builder() + .memberId(memberId).tokenHash(hash).expiresAt(expiresAt) + .createdAt(expiresAt.minus(Duration.ofDays(1))) + .build()); + } + + private void resetToken(Long memberId, String hash, Instant expiresAt) { + passwordResetTokenRepository.save(PasswordResetToken.builder() + .memberId(memberId).tokenHash(hash).expiresAt(expiresAt).requestedIp("127.0.0.1") + .createdAt(expiresAt.minus(Duration.ofMinutes(30))) + .build()); + } +} From 7f55aa774545b87ce0f61b2d27442eb91c76e711 Mon Sep 17 00:00:00 2001 From: ff1451 Date: Fri, 16 Oct 2026 17:11:56 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=C2=B7=EB=B3=B8=EB=AC=B8=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EB=B0=9C=EC=86=A1=20=EC=88=98=EB=8B=A8=EA=B3=BC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SES와 sendmail 발송기가 같은 제목·텍스트·HTML 템플릿 채우기 코드를 각자 복사해 갖고 있었다. - MailMessages가 메일 내용(MailMessage)을 만들고, TemplateMailSender가 MailSender 메서드를 구현한다. - SES·sendmail 발송기는 deliver(받는 사람, 내용)만 구현한다. - 메일 문구는 글자 하나 바뀌지 않았다(두 발송기의 원래 문자열과 기계적으로 대조). --- .../global/mail/MailMessage.java | 5 ++ .../global/mail/MailMessages.java | 32 ++++++++++++ .../global/mail/SendmailMailSender.java | 32 ++---------- .../global/mail/SesMailSender.java | 35 ++----------- .../global/mail/TemplateMailSender.java | 17 +++++++ .../global/mail/MailMessagesTest.java | 51 +++++++++++++++++++ 6 files changed, 115 insertions(+), 57 deletions(-) create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/MailMessage.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/MailMessages.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/TemplateMailSender.java create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/global/mail/MailMessagesTest.java diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/MailMessage.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/MailMessage.java new file mode 100644 index 0000000..2e0a167 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/MailMessage.java @@ -0,0 +1,5 @@ +package com.bcsdlab.bcsdinternalapiv2.global.mail; + +/** 발송 수단과 무관한 메일 내용. 텍스트와 HTML을 함께 보낸다(multipart/alternative). */ +record MailMessage(String subject, String text, String html) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/MailMessages.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/MailMessages.java new file mode 100644 index 0000000..677eb62 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/MailMessages.java @@ -0,0 +1,32 @@ +package com.bcsdlab.bcsdinternalapiv2.global.mail; + +/** 서비스가 보내는 메일의 제목·본문을 만든다. */ +final class MailMessages { + + private static final String PASSWORD_RESET_HTML_TEMPLATE = MailTemplates.load("password-reset-email.html"); + private static final String ACCOUNT_CREATED_HTML_TEMPLATE = MailTemplates.load("account-creation-email.html"); + + private MailMessages() { + } + + static MailMessage passwordReset(String resetUrl) { + String subject = "[BCSD Internal] 비밀번호 재설정 안내"; + String text = "비밀번호를 재설정하려면 아래 링크를 열어주세요.\n\n" + resetUrl + + "\n\n이 링크는 30분간 유효합니다. 본인이 요청하지 않았다면 이 메일을 무시하세요."; + String html = PASSWORD_RESET_HTML_TEMPLATE.replace("{resetUrl}", resetUrl); + return new MailMessage(subject, text, html); + } + + static MailMessage accountCreated(String studentNumber, String temporaryPassword, String loginUrl) { + String subject = "[BCSD Internal] 계정이 생성되었습니다"; + String text = "학번: " + studentNumber + + "\n임시 비밀번호: " + temporaryPassword + + "\n로그인: " + loginUrl + + "\n\n로그인 후 반드시 비밀번호를 변경해주세요."; + String html = ACCOUNT_CREATED_HTML_TEMPLATE + .replace("{studentNumber}", MailTemplates.escapeHtml(studentNumber)) + .replace("{temporaryPassword}", MailTemplates.escapeHtml(temporaryPassword)) + .replace("{loginUrl}", loginUrl); + return new MailMessage(subject, text, html); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/SendmailMailSender.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/SendmailMailSender.java index dad96fd..d73c06f 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/SendmailMailSender.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/SendmailMailSender.java @@ -16,13 +16,10 @@ @Component @RequiredArgsConstructor @ConditionalOnProperty(name = "app.mail.transport", havingValue = "sendmail") -public class SendmailMailSender implements MailSender { +public class SendmailMailSender extends TemplateMailSender { private static final String MIME_BOUNDARY = "----=_BCSD_Internal_Mail_Boundary"; - private static final String PASSWORD_RESET_HTML_TEMPLATE = MailTemplates.load("password-reset-email.html"); - private static final String ACCOUNT_CREATED_HTML_TEMPLATE = MailTemplates.load("account-creation-email.html"); - private final MailProperties mailProperties; @PostConstruct @@ -34,29 +31,10 @@ void checkSendmailAvailable() { } @Override - public void sendPasswordResetLink(String toEmail, String resetUrl) { - String subject = "[BCSD Internal] 비밀번호 재설정 안내"; - String text = "비밀번호를 재설정하려면 아래 링크를 열어주세요.\n\n" + resetUrl - + "\n\n이 링크는 30분간 유효합니다. 본인이 요청하지 않았다면 이 메일을 무시하세요."; - String html = PASSWORD_RESET_HTML_TEMPLATE.replace("{resetUrl}", resetUrl); - send(toEmail, subject, text, html); - } - - @Override - public void sendAccountCreated(String toEmail, String studentNumber, String temporaryPassword, String loginUrl) { - String subject = "[BCSD Internal] 계정이 생성되었습니다"; - String text = "학번: " + studentNumber - + "\n임시 비밀번호: " + temporaryPassword - + "\n로그인: " + loginUrl - + "\n\n로그인 후 반드시 비밀번호를 변경해주세요."; - String html = ACCOUNT_CREATED_HTML_TEMPLATE - .replace("{studentNumber}", MailTemplates.escapeHtml(studentNumber)) - .replace("{temporaryPassword}", MailTemplates.escapeHtml(temporaryPassword)) - .replace("{loginUrl}", loginUrl); - send(toEmail, subject, text, html); - } - - private void send(String toEmail, String subject, String text, String html) { + protected void deliver(String toEmail, MailMessage mail) { + String subject = mail.subject(); + String text = mail.text(); + String html = mail.html(); String message = "From: " + mailProperties.from() + "\r\n" + "To: " + toEmail + "\r\n" + "Subject: " + subject + "\r\n" diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/SesMailSender.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/SesMailSender.java index e8f2d47..7c126df 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/SesMailSender.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/SesMailSender.java @@ -13,46 +13,21 @@ @Component @RequiredArgsConstructor @ConditionalOnProperty(name = "app.mail.transport", havingValue = "ses") -public class SesMailSender implements MailSender { - - private static final String PASSWORD_RESET_HTML_TEMPLATE = MailTemplates.load("password-reset-email.html"); - private static final String ACCOUNT_CREATED_HTML_TEMPLATE = MailTemplates.load("account-creation-email.html"); +public class SesMailSender extends TemplateMailSender { private final SesEmailClient sesEmailClient; private final MailProperties mailProperties; @Override - public void sendPasswordResetLink(String toEmail, String resetUrl) { - String subject = "[BCSD Internal] 비밀번호 재설정 안내"; - String text = "비밀번호를 재설정하려면 아래 링크를 열어주세요.\n\n" + resetUrl - + "\n\n이 링크는 30분간 유효합니다. 본인이 요청하지 않았다면 이 메일을 무시하세요."; - String html = PASSWORD_RESET_HTML_TEMPLATE.replace("{resetUrl}", resetUrl); - send(toEmail, subject, text, html); - } - - @Override - public void sendAccountCreated(String toEmail, String studentNumber, String temporaryPassword, String loginUrl) { - String subject = "[BCSD Internal] 계정이 생성되었습니다"; - String text = "학번: " + studentNumber - + "\n임시 비밀번호: " + temporaryPassword - + "\n로그인: " + loginUrl - + "\n\n로그인 후 반드시 비밀번호를 변경해주세요."; - String html = ACCOUNT_CREATED_HTML_TEMPLATE - .replace("{studentNumber}", MailTemplates.escapeHtml(studentNumber)) - .replace("{temporaryPassword}", MailTemplates.escapeHtml(temporaryPassword)) - .replace("{loginUrl}", loginUrl); - send(toEmail, subject, text, html); - } - - private void send(String toEmail, String subject, String text, String html) { + protected void deliver(String toEmail, MailMessage mail) { SendEmailRequest request = SendEmailRequest.builder() .source(mailProperties.from()) .destination(Destination.builder().toAddresses(toEmail).build()) .message(Message.builder() - .subject(Content.builder().charset("UTF-8").data(subject).build()) + .subject(Content.builder().charset("UTF-8").data(mail.subject()).build()) .body(Body.builder() - .text(Content.builder().charset("UTF-8").data(text).build()) - .html(Content.builder().charset("UTF-8").data(html).build()) + .text(Content.builder().charset("UTF-8").data(mail.text()).build()) + .html(Content.builder().charset("UTF-8").data(mail.html()).build()) .build()) .build()) .build(); diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/TemplateMailSender.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/TemplateMailSender.java new file mode 100644 index 0000000..0435450 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/mail/TemplateMailSender.java @@ -0,0 +1,17 @@ +package com.bcsdlab.bcsdinternalapiv2.global.mail; + +/** 메일 내용은 {@link MailMessages}가 만들고, 하위 클래스는 발송 수단(SES, sendmail)만 구현한다. */ +abstract class TemplateMailSender implements MailSender { + + @Override + public void sendPasswordResetLink(String toEmail, String resetUrl) { + deliver(toEmail, MailMessages.passwordReset(resetUrl)); + } + + @Override + public void sendAccountCreated(String toEmail, String studentNumber, String temporaryPassword, String loginUrl) { + deliver(toEmail, MailMessages.accountCreated(studentNumber, temporaryPassword, loginUrl)); + } + + protected abstract void deliver(String toEmail, MailMessage mail); +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/mail/MailMessagesTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/mail/MailMessagesTest.java new file mode 100644 index 0000000..5f9f41f --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/mail/MailMessagesTest.java @@ -0,0 +1,51 @@ +package com.bcsdlab.bcsdinternalapiv2.global.mail; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import com.bcsdlab.bcsdinternalapiv2.global.config.MailProperties; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import software.amazon.awssdk.services.ses.model.SendEmailRequest; + +class MailMessagesTest { + + @Test + void 비밀번호_재설정_메일() { + MailMessage mail = MailMessages.passwordReset("https://internal.bcsdlab.com/reset?token=abc"); + + assertThat(mail.subject()).isEqualTo("[BCSD Internal] 비밀번호 재설정 안내"); + assertThat(mail.text()).isEqualTo("비밀번호를 재설정하려면 아래 링크를 열어주세요.\n\n" + + "https://internal.bcsdlab.com/reset?token=abc" + + "\n\n이 링크는 30분간 유효합니다. 본인이 요청하지 않았다면 이 메일을 무시하세요."); + assertThat(mail.html()).contains("https://internal.bcsdlab.com/reset?token=abc").doesNotContain("{resetUrl}"); + } + + @Test + void 계정_생성_메일은_HTML에_임시_비밀번호를_이스케이프한다() { + MailMessage mail = MailMessages.accountCreated("2024123456", "a captor = ArgumentCaptor.forClass(SendEmailRequest.class); + verify(client).send(captor.capture(), eq("user@bcsd.club")); + SendEmailRequest request = captor.getValue(); + assertThat(request.source()).isEqualTo("no-reply@bcsdlab.com"); + assertThat(request.destination().toAddresses()).containsExactly("user@bcsd.club"); + assertThat(request.message().subject().data()).isEqualTo("[BCSD Internal] 비밀번호 재설정 안내"); + assertThat(request.message().body().text().data()).contains("https://x/reset"); + } +} From a5e5b88040af2755f3081bf11852770559b4cc1b Mon Sep 17 00:00:00 2001 From: ff1451 Date: Fri, 16 Oct 2026 17:14:41 +0900 Subject: [PATCH 09/10] =?UTF-8?q?refactor:=20S3=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20URL=20=EC=84=9C=EB=AA=85=EC=9D=84=20S3UploadUrlSign?= =?UTF-8?q?er=20=ED=95=98=EB=82=98=EB=A1=9C=20=EB=AA=A8=EC=9D=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 이미지(AdminImageService)와 회원 사진(PhotoStorageService)이 키 생성·공개 URL 조립· presigned PUT 서명 코드를 각자 갖고 있었다. - global/storage/S3UploadUrlSigner.presignPut(키 접두어, 파일명, 콘텐츠 타입, 크기)로 모은다. - 키 형식({접두어}/{UUID}.{확장자}), 유효 시간(10분), 크기 서명 여부는 그대로다. --- .../global/storage/S3UploadUrlSigner.java | 51 +++++++++++++++++++ .../media/service/AdminImageService.java | 42 +++------------ .../member/service/PhotoStorageService.java | 35 +++---------- .../media/AdminImagePresignSignatureTest.java | 5 +- 4 files changed, 68 insertions(+), 65 deletions(-) create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/global/storage/S3UploadUrlSigner.java diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/storage/S3UploadUrlSigner.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/storage/S3UploadUrlSigner.java new file mode 100644 index 0000000..0ec24f1 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/storage/S3UploadUrlSigner.java @@ -0,0 +1,51 @@ +package com.bcsdlab.bcsdinternalapiv2.global.storage; + +import com.bcsdlab.bcsdinternalapiv2.global.config.S3Properties; +import java.time.Duration; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +/** + * 브라우저가 S3에 직접 PUT할 presigned URL을 만든다(서버는 이미지 바이트를 거치지 않는다). + * 서명은 네트워크 호출 없이 로컬에서 계산한다. + */ +@Component +@RequiredArgsConstructor +public class S3UploadUrlSigner { + + private static final Duration UPLOAD_URL_VALIDITY = Duration.ofMinutes(10); + + private final S3Presigner s3Presigner; + private final S3Properties s3Properties; + + /** + * {@code {keyPrefix}/{UUID}.{확장자}} 키로 PUT URL을 서명한다. contentLength가 null이 아니면 크기도 서명해 + * S3가 다른 크기의 업로드를 거부한다. + */ + public PresignedUpload presignPut(String keyPrefix, String fileName, String contentType, Long contentLength) { + String key = "%s/%s.%s".formatted(keyPrefix, UUID.randomUUID(), extensionOf(fileName)); + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(s3Properties.bucket()) + .key(key) + .contentType(contentType) + .contentLength(contentLength) + .build(); + String uploadUrl = s3Presigner.presignPutObject(PutObjectPresignRequest.builder() + .signatureDuration(UPLOAD_URL_VALIDITY) + .putObjectRequest(putObjectRequest) + .build()) + .url().toString(); + return new PresignedUpload(key, uploadUrl, s3Properties.publicBaseUrl() + "/" + key); + } + + private static String extensionOf(String fileName) { + return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); + } + + public record PresignedUpload(String key, String uploadUrl, String publicUrl) { + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/service/AdminImageService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/service/AdminImageService.java index a258272..46f5997 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/service/AdminImageService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/service/AdminImageService.java @@ -1,6 +1,7 @@ package com.bcsdlab.bcsdinternalapiv2.media.service; -import com.bcsdlab.bcsdinternalapiv2.global.config.S3Properties; +import com.bcsdlab.bcsdinternalapiv2.global.storage.S3UploadUrlSigner; +import com.bcsdlab.bcsdinternalapiv2.global.storage.S3UploadUrlSigner.PresignedUpload; import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.request.PresignedUrlRequest; import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageAssetResponse; import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageCompleteResponse; @@ -10,17 +11,11 @@ import com.bcsdlab.bcsdinternalapiv2.media.model.ImageAsset; import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose; import com.bcsdlab.bcsdinternalapiv2.media.repository.ImageAssetRepository; -import java.time.Duration; -import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; /** * presigned URL 발급 → 브라우저가 S3에 직접 PUT → complete 등록(ADR-009). 서버는 이미지 @@ -32,11 +27,8 @@ @Transactional(readOnly = true) public class AdminImageService { - private static final Duration UPLOAD_URL_VALIDITY = Duration.ofMinutes(10); - private final ImageAssetRepository imageAssetRepository; - private final S3Presigner s3Presigner; - private final S3Properties s3Properties; + private final S3UploadUrlSigner s3UploadUrlSigner; @Transactional public PresignedUrlResponse issuePresignedUrl(PresignedUrlRequest request) { @@ -53,12 +45,12 @@ public PresignedUrlResponse issuePresignedUrl(PresignedUrlRequest request) { @Transactional public PresignedUrlResponse issueUpload(ImagePurpose purpose, String fileName, String contentType, long byteSize, Long uploadedBy, boolean signContentLength) { - String s3Key = "%s/%s.%s".formatted(purpose.name().toLowerCase(), UUID.randomUUID(), extensionOf(fileName)); - String publicUrl = s3Properties.publicBaseUrl() + "/" + s3Key; + PresignedUpload upload = s3UploadUrlSigner.presignPut( + purpose.name().toLowerCase(), fileName, contentType, signContentLength ? byteSize : null); ImageAsset image = imageAssetRepository.save(ImageAsset.builder() - .s3Key(s3Key) - .url(publicUrl) + .s3Key(upload.key()) + .url(upload.publicUrl()) .originalName(fileName) .contentType(contentType) .byteSize(byteSize) @@ -66,8 +58,7 @@ public PresignedUrlResponse issueUpload(ImagePurpose purpose, String fileName, S .uploadedBy(uploadedBy) .build()); - String uploadUrl = presignPutUrl(s3Key, contentType, signContentLength ? byteSize : null); - return new PresignedUrlResponse(image.getId(), uploadUrl, publicUrl); + return new PresignedUrlResponse(image.getId(), upload.uploadUrl(), upload.publicUrl()); } @Transactional @@ -93,23 +84,6 @@ private void requireLibraryPurpose(ImagePurpose purpose) { } } - private String presignPutUrl(String s3Key, String contentType, Long contentLength) { - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(s3Properties.bucket()) - .key(s3Key) - .contentType(contentType) - .contentLength(contentLength) - .build(); - PresignedPutObjectRequest presigned = s3Presigner.presignPutObject(PutObjectPresignRequest.builder() - .signatureDuration(UPLOAD_URL_VALIDITY) - .putObjectRequest(putObjectRequest) - .build()); - return presigned.url().toString(); - } - - private String extensionOf(String fileName) { - return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); - } private ImageAsset findOrThrow(Long id) { return imageAssetRepository.findById(id) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/PhotoStorageService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/PhotoStorageService.java index bb5c5cb..34a5c9e 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/PhotoStorageService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/PhotoStorageService.java @@ -1,16 +1,11 @@ package com.bcsdlab.bcsdinternalapiv2.member.service; -import com.bcsdlab.bcsdinternalapiv2.global.config.S3Properties; +import com.bcsdlab.bcsdinternalapiv2.global.storage.S3UploadUrlSigner; +import com.bcsdlab.bcsdinternalapiv2.global.storage.S3UploadUrlSigner.PresignedUpload; import com.bcsdlab.bcsdinternalapiv2.member.controller.dto.request.PhotoPresignedUrlRequest; import com.bcsdlab.bcsdinternalapiv2.member.controller.dto.response.PhotoPresignedUrlResponse; -import java.time.Duration; -import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; /** * 회원 프로필 사진도 미디어 도메인의 이미지 업로드와 동일하게 presigned URL로 처리한다 — @@ -21,29 +16,11 @@ @RequiredArgsConstructor public class PhotoStorageService { - private static final Duration UPLOAD_URL_VALIDITY = Duration.ofMinutes(10); - - private final S3Presigner s3Presigner; - private final S3Properties s3Properties; + private final S3UploadUrlSigner s3UploadUrlSigner; public PhotoPresignedUrlResponse issuePresignedUrl(Long memberId, PhotoPresignedUrlRequest request) { - String key = "member-photos/%d/%s.%s".formatted(memberId, UUID.randomUUID(), extensionOf(request.fileName())); - String publicUrl = s3Properties.publicBaseUrl() + "/" + key; - - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(s3Properties.bucket()) - .key(key) - .contentType(request.contentType()) - .build(); - PresignedPutObjectRequest presigned = s3Presigner.presignPutObject(PutObjectPresignRequest.builder() - .signatureDuration(UPLOAD_URL_VALIDITY) - .putObjectRequest(putObjectRequest) - .build()); - - return new PhotoPresignedUrlResponse(presigned.url().toString(), publicUrl); - } - - private String extensionOf(String fileName) { - return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); + PresignedUpload upload = s3UploadUrlSigner.presignPut( + "member-photos/" + memberId, request.fileName(), request.contentType(), null); + return new PhotoPresignedUrlResponse(upload.uploadUrl(), upload.publicUrl()); } } diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/media/AdminImagePresignSignatureTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/media/AdminImagePresignSignatureTest.java index aaee70d..ef824ad 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/media/AdminImagePresignSignatureTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/media/AdminImagePresignSignatureTest.java @@ -6,6 +6,7 @@ import static org.mockito.Mockito.when; import com.bcsdlab.bcsdinternalapiv2.global.config.S3Properties; +import com.bcsdlab.bcsdinternalapiv2.global.storage.S3UploadUrlSigner; import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.request.PresignedUrlRequest; import com.bcsdlab.bcsdinternalapiv2.media.model.ImageAsset; import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose; @@ -38,8 +39,8 @@ void setUp() { .build(); ImageAssetRepository repository = mock(ImageAssetRepository.class); when(repository.save(any(ImageAsset.class))).thenAnswer(invocation -> invocation.getArgument(0)); - service = new AdminImageService(repository, presigner, - new S3Properties("us-west-2", "test-bucket", "https://image.example.com")); + service = new AdminImageService(repository, new S3UploadUrlSigner(presigner, + new S3Properties("us-west-2", "test-bucket", "https://image.example.com"))); } @AfterEach From 98fbdf5d1beb8659bf89eabf7b4b68feee7937df Mon Sep 17 00:00:00 2001 From: ff1451 Date: Fri, 16 Oct 2026 17:23:12 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20=EA=B0=99=EC=9D=80=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EC=9D=98=20=EB=8F=99=EC=8B=9C=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=EC=9D=B4=20=ED=95=98=EB=A3=A8=203=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=ED=95=9C=EB=8F=84=EB=A5=BC=20=EB=84=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 하루 한도 검사가 그날 회원의 기존 예약 행만 잠가, 기존 예약이 없는 날에는 같은 회원의 동시 요청(겹치지 않는 2시간 + 2시간)이 둘 다 통과해 4시간이 예약됐다. - 예약 생성 시 회원 행을 잠가 한 회원의 예약 생성을 직렬화한다. - 반복 예약 저장 중 다른 회원과의 동시 충돌(배타 제약 위반)을 단건과 같이 409로 바꾼다 (이전: 일반 400, 전체 롤백은 동일). --- .../service/ReservationService.java | 5 +- .../ReservationConcurrencyTest.java | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/reservation/service/ReservationService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/reservation/service/ReservationService.java index a491d6b..c98aea1 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/reservation/service/ReservationService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/reservation/service/ReservationService.java @@ -53,6 +53,9 @@ public class ReservationService { public ReservationCreateResponse create(Long memberId, ReservationCreateRequest request) { Instant now = Instant.now(clock); validateCommon(request, now); + // 하루 이용 한도는 그날 회원의 기존 예약을 합산해 검사한다. 기존 예약이 없는 날에는 잠글 행이 없어 + // 같은 회원의 동시 요청이 둘 다 통과했으므로, 회원 행을 잠가 한 회원의 예약 생성을 직렬화한다. + memberRepository.findByIdForUpdate(memberId); if (!request.isRepeat()) { checkDailyLimitOrThrow(memberId, request.date(), request.start(), request.end()); @@ -167,7 +170,7 @@ private ReservationCreateResponse createRepeat(Long memberId, ReservationCreateR } Reservation reservation = buildReservation(memberId, group.getId(), date, request.start(), request.end(), request.purpose(), request.headcount()); - created.add(reservationRepository.save(reservation)); + created.add(saveOrTranslateConflict(reservation)); } return ReservationCreateResponse.of(created, skipped); } diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/reservation/ReservationConcurrencyTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/reservation/ReservationConcurrencyTest.java index 4c16874..9889812 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/reservation/ReservationConcurrencyTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/reservation/ReservationConcurrencyTest.java @@ -69,4 +69,54 @@ class ReservationConcurrencyTest extends ReservationIntegrationTestSupport { executor.shutdown(); } } + + @Test + void 같은_회원이_빈_날에_동시에_예약해도_하루_3시간을_넘지_않는다() throws Exception { + String token = createActiveMemberAndLogin("20250703"); + for (int day = 3; day <= 7; day++) { + LocalDate date = LocalDate.now(TimeConfig.SEOUL).plusDays(day); + List bodies = List.of( + """ + {"date":"%s","start":600,"end":720,"purpose":"한도1","headcount":2} + """.formatted(date), + """ + {"date":"%s","start":780,"end":900,"purpose":"한도2","headcount":2} + """.formatted(date)); + + List statuses = postConcurrently(token, bodies); + + assertThat(statuses).containsExactlyInAnyOrder(200, 409); + assertThat(reservationRepository.findByReservationDateAndCancelledAtIsNullOrderByStartMinuteAsc(date)) + .hasSize(1); + } + } + + private List postConcurrently(String token, List bodies) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(bodies.size()); + CountDownLatch readyLatch = new CountDownLatch(bodies.size()); + CountDownLatch startLatch = new CountDownLatch(1); + try { + List> futures = bodies.stream() + .map(body -> executor.submit(() -> { + readyLatch.countDown(); + startLatch.await(); + return mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .post("/v1/reservations") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andReturn().getResponse().getStatus(); + })) + .toList(); + readyLatch.await(); + startLatch.countDown(); + List statuses = new java.util.ArrayList<>(); + for (Future future : futures) { + statuses.add(future.get()); + } + return statuses; + } finally { + executor.shutdown(); + } + } }