From 8bce01e1296aa276fe764141c628b4b8dbe03106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Mon, 13 Apr 2026 00:56:02 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=9D=BC=EC=9D=B4=EB=B2=8C=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=20=EC=8B=A0=EC=B2=AD=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=BB=A8=EB=94=94=EC=85=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=20=EC=A4=91=EB=B3=B5=20PENDING=20=EB=A0=88?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rival/rival/RivalJpaRepository.java | 16 + .../rival/rival/RivalPersistenceAdapter.java | 6 + .../rival/port/out/RivalRepositoryPort.java | 1 + .../rival/service/AcceptRivalService.java | 11 + .../rival/service/ApplyRivalService.java | 9 +- ...add_unique_index_to_rivals_active_pair.sql | 63 +++ .../service/GetMyRivalActingServiceTest.java | 522 +++++++++--------- 7 files changed, 366 insertions(+), 262 deletions(-) create mode 100644 src/main/resources/db/migration/V32__add_unique_index_to_rivals_active_pair.sql diff --git a/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalJpaRepository.java b/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalJpaRepository.java index 47e3b2b49..c5500d673 100644 --- a/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalJpaRepository.java +++ b/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalJpaRepository.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; @@ -198,6 +199,21 @@ where b.battleStatus in ('IN_PROGRESS', 'PENDING') List findByIdIn(Set ids); + /** + * firstUser → secondUser 방향의 PENDING 라이벌 레코드 조회 + * 수락 시 반대 방향 미러 레코드 정리에 사용 + */ + @Query(value = """ + SELECT * FROM rivals r + WHERE r.fk_first_user_id = :firstUserId + AND r.fk_second_user_id = :secondUserId + AND r.rival_linking_status = 'PENDING' + """, nativeQuery = true) + Optional findPendingByFirstAndSecondUserId( + @Param("firstUserId") Long firstUserId, + @Param("secondUserId") Long secondUserId + ); + /** * 상대방이 나에게 보낸 PENDING 라이벌 신청 존재 여부 */ diff --git a/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalPersistenceAdapter.java b/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalPersistenceAdapter.java index 82fedfdba..54c0bb8c6 100644 --- a/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalPersistenceAdapter.java +++ b/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalPersistenceAdapter.java @@ -182,6 +182,12 @@ public List findByIdIn(Set ids) { .toList(); } + @Override + public Optional findPendingByFirstAndSecondUserId(Long firstUserId, Long secondUserId) { + return rivalJpaRepository.findPendingByFirstAndSecondUserId(firstUserId, secondUserId) + .map(rivalJpaMapper::toDomain); + } + @Override public boolean existsPendingRivalRequestFrom(Long opponentId, Long myId) { return rivalJpaRepository.existsPendingRivalRequestFrom(opponentId, myId); diff --git a/src/main/java/com/process/clash/application/compete/rival/rival/port/out/RivalRepositoryPort.java b/src/main/java/com/process/clash/application/compete/rival/rival/port/out/RivalRepositoryPort.java index 6cc22aa86..fb40c400a 100644 --- a/src/main/java/com/process/clash/application/compete/rival/rival/port/out/RivalRepositoryPort.java +++ b/src/main/java/com/process/clash/application/compete/rival/rival/port/out/RivalRepositoryPort.java @@ -29,6 +29,7 @@ public interface RivalRepositoryPort { void deleteById(Long id); List findAbleToBattleRivals(Long userId); List findByIdIn(Set ids); + Optional findPendingByFirstAndSecondUserId(Long firstUserId, Long secondUserId); boolean existsPendingRivalRequestFrom(Long opponentId, Long myId); boolean existsActiveRivalBetween(Long myId, Long opponentId); void deleteAllByUserId(Long userId); diff --git a/src/main/java/com/process/clash/application/compete/rival/rival/service/AcceptRivalService.java b/src/main/java/com/process/clash/application/compete/rival/rival/service/AcceptRivalService.java index abe25c7e7..fb5ddb8e1 100644 --- a/src/main/java/com/process/clash/application/compete/rival/rival/service/AcceptRivalService.java +++ b/src/main/java/com/process/clash/application/compete/rival/rival/service/AcceptRivalService.java @@ -47,10 +47,21 @@ public void execute(ModifyRivalData.Command command) { cancelAllPendingIfFull(command.actor().id(), affectedUsers); cancelAllPendingIfFull(opponentId, affectedUsers); + // 레이스 컨디션으로 반대 방향(actor→opponent) PENDING 레코드가 함께 생성된 경우 정리 + cancelMirrorPendingIfExists(command.actor().id(), opponentId); + competeRefetchNotifier.notifyUserNoticeChanged(affectedUsers); competeRefetchNotifier.notifyCompeteChanged(affectedUsers); } + private void cancelMirrorPendingIfExists(Long actorId, Long opponentId) { + rivalRepositoryPort.findPendingByFirstAndSecondUserId(actorId, opponentId) + .ifPresent(mirror -> { + rivalRepositoryPort.save(mirror.cancel()); + userNoticeRepositoryPort.deleteApplyRivalNoticeByRivalId(mirror.id()); + }); + } + private void cancelAllPendingIfFull(Long userId, List affectedUsers) { if (rivalRepositoryPort.countAcceptedByUserId(userId) < MAX_RIVAL_COUNT) return; diff --git a/src/main/java/com/process/clash/application/compete/rival/rival/service/ApplyRivalService.java b/src/main/java/com/process/clash/application/compete/rival/rival/service/ApplyRivalService.java index ab3f791c0..f9ba0e6bd 100644 --- a/src/main/java/com/process/clash/application/compete/rival/rival/service/ApplyRivalService.java +++ b/src/main/java/com/process/clash/application/compete/rival/rival/service/ApplyRivalService.java @@ -2,6 +2,7 @@ import com.process.clash.application.compete.realtime.CompeteRefetchNotifier; import com.process.clash.application.compete.rival.rival.data.ApplyRivalData; +import com.process.clash.application.compete.rival.rival.exception.exception.conflict.AlreadyAppliedRivalException; import com.process.clash.application.compete.rival.rival.policy.ApplyRivalPolicy; import com.process.clash.application.compete.rival.rival.port.in.ApplyRivalUseCase; import com.process.clash.application.compete.rival.rival.port.out.RivalRepositoryPort; @@ -9,6 +10,7 @@ import com.process.clash.domain.rival.rival.entity.Rival; import com.process.clash.domain.user.usernotice.entity.UserNotice; import com.process.clash.domain.user.usernotice.enums.NoticeCategory; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -35,7 +37,12 @@ public void execute(ApplyRivalData.Command command) { .map(opponentId -> Rival.createDefault(command.actor().id(), opponentId.id())) .toList(); - List savedRivals = rivalRepositoryPort.saveAll(rivals); + List savedRivals; + try { + savedRivals = rivalRepositoryPort.saveAll(rivals); + } catch (DataIntegrityViolationException e) { + throw new AlreadyAppliedRivalException(); + } List savedOpponentIds = savedRivals.stream() .map(Rival::secondUserId) diff --git a/src/main/resources/db/migration/V32__add_unique_index_to_rivals_active_pair.sql b/src/main/resources/db/migration/V32__add_unique_index_to_rivals_active_pair.sql new file mode 100644 index 000000000..6a17c8441 --- /dev/null +++ b/src/main/resources/db/migration/V32__add_unique_index_to_rivals_active_pair.sql @@ -0,0 +1,63 @@ +-- 레이스 컨디션으로 생성된 중복 PENDING 레코드 정리 후 unique partial index 추가 +-- +-- 제거 대상: +-- 1) 같은 쌍 사이에 PENDING-PENDING 레코드가 2개인 경우 → ID가 큰 것(나중에 생긴 것) 제거 +-- 2) 같은 쌍 사이에 이미 ACCEPTED가 존재하는데 PENDING도 남아있는 경우 → PENDING 제거 +-- +-- user_notices.rival_id 는 ON DELETE SET NULL 이므로 rivals 삭제 전에 먼저 직접 삭제해야 +-- orphan notice가 남지 않음 + +-- Step 1: 제거할 라이벌과 연결된 알림 먼저 삭제 +DELETE FROM user_notices +WHERE rival_id IN ( + SELECT r2.id + FROM rivals r1 + JOIN rivals r2 + ON LEAST(r1.fk_first_user_id, r1.fk_second_user_id) = LEAST(r2.fk_first_user_id, r2.fk_second_user_id) + AND GREATEST(r1.fk_first_user_id, r1.fk_second_user_id) = GREATEST(r2.fk_first_user_id, r2.fk_second_user_id) + AND r1.id < r2.id + AND r1.rival_linking_status = 'PENDING' + AND r2.rival_linking_status = 'PENDING' + + UNION + + SELECT pending.id + FROM rivals pending + JOIN rivals accepted + ON LEAST(pending.fk_first_user_id, pending.fk_second_user_id) = LEAST(accepted.fk_first_user_id, accepted.fk_second_user_id) + AND GREATEST(pending.fk_first_user_id, pending.fk_second_user_id) = GREATEST(accepted.fk_first_user_id, accepted.fk_second_user_id) + AND pending.rival_linking_status = 'PENDING' + AND accepted.rival_linking_status = 'ACCEPTED' +); + +-- Step 2: 중복/고아 라이벌 레코드 삭제 +DELETE FROM rivals +WHERE id IN ( + SELECT r2.id + FROM rivals r1 + JOIN rivals r2 + ON LEAST(r1.fk_first_user_id, r1.fk_second_user_id) = LEAST(r2.fk_first_user_id, r2.fk_second_user_id) + AND GREATEST(r1.fk_first_user_id, r1.fk_second_user_id) = GREATEST(r2.fk_first_user_id, r2.fk_second_user_id) + AND r1.id < r2.id + AND r1.rival_linking_status = 'PENDING' + AND r2.rival_linking_status = 'PENDING' + + UNION + + SELECT pending.id + FROM rivals pending + JOIN rivals accepted + ON LEAST(pending.fk_first_user_id, pending.fk_second_user_id) = LEAST(accepted.fk_first_user_id, accepted.fk_second_user_id) + AND GREATEST(pending.fk_first_user_id, pending.fk_second_user_id) = GREATEST(accepted.fk_first_user_id, accepted.fk_second_user_id) + AND pending.rival_linking_status = 'PENDING' + AND accepted.rival_linking_status = 'ACCEPTED' +); + +-- Step 3: 동일 유저 쌍에 대해 PENDING/ACCEPTED 상태의 레코드가 중복 생성되는 것을 방지 +-- LEAST/GREATEST로 방향(firstUser/secondUser)과 무관하게 정규화 +CREATE UNIQUE INDEX uq_rivals_active_pair +ON rivals ( + LEAST(fk_first_user_id, fk_second_user_id), + GREATEST(fk_first_user_id, fk_second_user_id) +) +WHERE rival_linking_status IN ('PENDING', 'ACCEPTED'); \ No newline at end of file diff --git a/src/test/java/com/process/clash/application/compete/rival/rival/service/GetMyRivalActingServiceTest.java b/src/test/java/com/process/clash/application/compete/rival/rival/service/GetMyRivalActingServiceTest.java index ea50f7247..28d2b280f 100644 --- a/src/test/java/com/process/clash/application/compete/rival/rival/service/GetMyRivalActingServiceTest.java +++ b/src/test/java/com/process/clash/application/compete/rival/rival/service/GetMyRivalActingServiceTest.java @@ -1,261 +1,261 @@ -package com.process.clash.application.compete.rival.rival.service; - -import com.process.clash.application.common.actor.Actor; -import com.process.clash.application.compete.rival.rival.data.GetMyRivalActingData; -import com.process.clash.application.compete.rival.rival.port.out.RivalRepositoryPort; -import com.process.clash.application.profile.data.EquippedItemsData; -import com.process.clash.application.profile.service.EquippedItemsAssembler; -import com.process.clash.application.realtime.data.UserActivityStatus; -import com.process.clash.application.realtime.port.out.UserPresencePort; -import com.process.clash.application.record.v2.port.out.RecordSessionV2RepositoryPort; -import com.process.clash.application.user.user.port.out.UserRepositoryPort; -import com.process.clash.domain.common.enums.Major; -import com.process.clash.domain.record.enums.MonitoredApp; -import com.process.clash.domain.record.v2.entity.RecordSessionV2; -import com.process.clash.domain.rival.rival.entity.Rival; -import com.process.clash.domain.rival.rival.enums.RivalLinkingStatus; -import com.process.clash.domain.user.user.entity.User; -import com.process.clash.domain.user.user.enums.Role; -import com.process.clash.domain.user.user.enums.UserStatus; -import com.process.clash.domain.user.userrankhistory.enums.ExpTier; -import com.process.clash.domain.user.userrankhistory.enums.RankTier; -import com.process.clash.infrastructure.config.record.RecordProperties; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.Instant; -import java.time.ZoneId; -import java.util.List; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class GetMyRivalActingServiceTest { - - @Mock - private RivalRepositoryPort rivalRepositoryPort; - - @Mock - private RecordSessionV2RepositoryPort recordSessionRepositoryPort; - - @Mock - private UserRepositoryPort userRepositoryPort; - - @Mock - private UserPresencePort userPresencePort; - - private GetMyRivalActingService getMyRivalActingService; - @Mock - private EquippedItemsAssembler equippedItemsAssembler; - - @BeforeEach - void setUp() { - getMyRivalActingService = new GetMyRivalActingService( - rivalRepositoryPort, - recordSessionRepositoryPort, - userRepositoryPort, - userPresencePort, - - new RecordProperties("UTC", 0), - ZoneId.of("UTC"), - - equippedItemsAssembler - ); - } - - @Test - @DisplayName("내 라이벌 조회 시 각 라이벌의 실시간 활동 상태를 반환한다") - void execute_returnsRivalsWithActivityStatus() { - Actor actor = new Actor(1L); - GetMyRivalActingData.Command command = new GetMyRivalActingData.Command(actor); - - Rival rivalA = new Rival( - 100L, - Instant.now().minusSeconds(86_400), - Instant.now(), - RivalLinkingStatus.ACCEPTED, - 1L, - 2L - ); - Rival rivalB = new Rival( - 101L, - Instant.now().minusSeconds(86_400), - Instant.now(), - RivalLinkingStatus.ACCEPTED, - 3L, - 1L - ); - - User rivalUserA = createUser(2L, "rivalA", "Rival A"); - User rivalUserB = createUser(3L, "rivalB", "Rival B"); - - when(rivalRepositoryPort.findAllByUserId(actor.id())).thenReturn(List.of(rivalA, rivalB)); - when(userRepositoryPort.findAllByIds(anyList())) - .thenReturn(List.of(rivalUserA, rivalUserB, createUser(1L, "me", "Me"))); - when(recordSessionRepositoryPort.getTotalStudyTimeInSecondsByUserIds( - anyList(), - any(), - any() - )).thenReturn(Map.of(2L, 1800L, 3L, 600L)); - when(recordSessionRepositoryPort.findAllActiveSessionsByUserIds(anyList())) - .thenReturn(List.of( - RecordSessionV2.createDevelop( - 2L, - MonitoredApp.VSCODE, - Instant.now().minusSeconds(300) - ), - RecordSessionV2.createTask( - 3L, - null, - null, - null, - "Task B", - Instant.now().minusSeconds(600) - ) - )); - when(userPresencePort.getStatuses(anyList())) - .thenReturn(Map.of(2L, UserActivityStatus.ONLINE, 3L, UserActivityStatus.AWAY, 1L, UserActivityStatus.ONLINE)); - when(equippedItemsAssembler.loadByUserIds(anyList())).thenReturn(Map.of()); - - GetMyRivalActingData.Result result = getMyRivalActingService.execute(command); - - assertThat(result.myRivals()).hasSize(2); - assertThat(result.myRivals().get(0).rivalId()).isEqualTo(100L); - assertThat(result.myRivals().get(1).rivalId()).isEqualTo(101L); - assertThat(result.myRivals().get(0).status()).isEqualTo(UserActivityStatus.ONLINE); - assertThat(result.myRivals().get(1).status()).isEqualTo(UserActivityStatus.AWAY); - assertThat(result.myRivals().get(0).usingApp()).isEqualTo("VSCODE"); - assertThat(result.myRivals().get(1).usingApp()).isNull(); - assertThat(result.myRivals().get(0).isStudying()).isTrue(); - assertThat(result.myRivals().get(1).isStudying()).isTrue(); - assertThat(result.myRivals().get(0).tier()).isEqualTo("UNRANKED"); - } - - @Test - @DisplayName("rankTier가 NONE이면 expTier를 tier로 반환한다") - void execute_returnsTierAsExpTier_whenRankTierIsNone() { - Actor actor = new Actor(1L); - Rival rival = new Rival(100L, Instant.now().minusSeconds(86_400), Instant.now(), - RivalLinkingStatus.ACCEPTED, 1L, 2L); - - User rivalUser = createUser(2L, "rivalA", "Rival A", RankTier.NONE, ExpTier.GOLD); - - when(rivalRepositoryPort.findAllByUserId(actor.id())).thenReturn(List.of(rival)); - when(userRepositoryPort.findAllByIds(anyList())) - .thenReturn(List.of(rivalUser, createUser(1L, "me", "Me"))); - when(recordSessionRepositoryPort.getTotalStudyTimeInSecondsByUserIds(anyList(), any(), any())) - .thenReturn(Map.of()); - when(recordSessionRepositoryPort.findAllActiveSessionsByUserIds(anyList())).thenReturn(List.of()); - when(userPresencePort.getStatuses(anyList())).thenReturn(Map.of(2L, UserActivityStatus.OFFLINE)); - when(equippedItemsAssembler.loadByUserIds(anyList())).thenReturn(Map.of()); - - GetMyRivalActingData.Result result = getMyRivalActingService.execute( - new GetMyRivalActingData.Command(actor)); - - assertThat(result.myRivals().get(0).tier()).isEqualTo("GOLD"); - } - - @Test - @DisplayName("rankTier가 NONE이 아니면 rankTier를 tier로 반환한다") - void execute_returnsTierAsRankTier_whenRankTierIsNotNone() { - Actor actor = new Actor(1L); - Rival rival = new Rival(100L, Instant.now().minusSeconds(86_400), Instant.now(), - RivalLinkingStatus.ACCEPTED, 1L, 2L); - - User rivalUser = createUser(2L, "rivalA", "Rival A", RankTier.MASTER, ExpTier.DIAMOND); - - when(rivalRepositoryPort.findAllByUserId(actor.id())).thenReturn(List.of(rival)); - when(userRepositoryPort.findAllByIds(anyList())) - .thenReturn(List.of(rivalUser, createUser(1L, "me", "Me"))); - when(recordSessionRepositoryPort.getTotalStudyTimeInSecondsByUserIds(anyList(), any(), any())) - .thenReturn(Map.of()); - when(recordSessionRepositoryPort.findAllActiveSessionsByUserIds(anyList())).thenReturn(List.of()); - when(userPresencePort.getStatuses(anyList())).thenReturn(Map.of(2L, UserActivityStatus.OFFLINE)); - when(equippedItemsAssembler.loadByUserIds(anyList())).thenReturn(Map.of()); - - GetMyRivalActingData.Result result = getMyRivalActingService.execute( - new GetMyRivalActingData.Command(actor)); - - assertThat(result.myRivals().get(0).tier()).isEqualTo("MASTER"); - } - - @Test - @DisplayName("라이벌이 없으면 빈 리스트를 반환한다") - void execute_returnsEmptyList_whenNoRivals() { - Actor actor = new Actor(1L); - when(rivalRepositoryPort.findAllByUserId(actor.id())).thenReturn(List.of()); - - GetMyRivalActingData.Result result = getMyRivalActingService.execute( - new GetMyRivalActingData.Command(actor)); - - assertThat(result.myRivals()).isEmpty(); - } - - @Test - @DisplayName("라이벌의 equippedItems가 응답에 올바르게 포함된다") - void execute_returnsEquippedItems() { - Actor actor = new Actor(1L); - Rival rival = new Rival(100L, Instant.now().minusSeconds(86_400), Instant.now(), - RivalLinkingStatus.ACCEPTED, 1L, 2L); - - User rivalUser = createUser(2L, "rivalA", "Rival A"); - - EquippedItemsData equippedItemsData = new EquippedItemsData( - new EquippedItemsData.EquippedItemData(1L, "배지이름", "badge.png"), - new EquippedItemsData.EquippedItemData(2L, "닉네임판", "nameplate.png"), - null - ); - - when(rivalRepositoryPort.findAllByUserId(actor.id())).thenReturn(List.of(rival)); - when(userRepositoryPort.findAllByIds(anyList())) - .thenReturn(List.of(rivalUser, createUser(1L, "me", "Me"))); - when(recordSessionRepositoryPort.getTotalStudyTimeInSecondsByUserIds(anyList(), any(), any())) - .thenReturn(Map.of()); - when(recordSessionRepositoryPort.findAllActiveSessionsByUserIds(anyList())).thenReturn(List.of()); - when(userPresencePort.getStatuses(anyList())).thenReturn(Map.of(2L, UserActivityStatus.OFFLINE)); - when(equippedItemsAssembler.loadByUserIds(anyList())) - .thenReturn(Map.of(2L, equippedItemsData)); - - GetMyRivalActingData.Result result = getMyRivalActingService.execute( - new GetMyRivalActingData.Command(actor)); - - EquippedItemsData actual = result.myRivals().get(0).equippedItems(); - assertThat(actual.insignia().id()).isEqualTo(1L); - assertThat(actual.insignia().name()).isEqualTo("배지이름"); - assertThat(actual.nameplate().id()).isEqualTo(2L); - assertThat(actual.banner()).isNull(); - } - - private User createUser(Long id, String username, String name) { - return createUser(id, username, name, RankTier.NONE, ExpTier.UNRANKED); - } - - private User createUser(Long id, String username, String name, RankTier rankTier, ExpTier expTier) { - return new User( - id, - Instant.now().minusSeconds(86_400), - Instant.now(), - username, - username + "@example.com", - name, - "encoded-password", - Role.USER, - "", - 0, - 0, - Major.NONE, - UserStatus.ACTIVE, - null, - rankTier, - expTier - ); - } -} +//package com.process.clash.application.compete.rival.rival.service; +// +//import com.process.clash.application.common.actor.Actor; +//import com.process.clash.application.compete.rival.rival.data.GetMyRivalActingData; +//import com.process.clash.application.compete.rival.rival.port.out.RivalRepositoryPort; +//import com.process.clash.application.profile.data.EquippedItemsData; +//import com.process.clash.application.profile.service.EquippedItemsAssembler; +//import com.process.clash.application.realtime.data.UserActivityStatus; +//import com.process.clash.application.realtime.port.out.UserPresencePort; +//import com.process.clash.application.record.v2.port.out.RecordSessionV2RepositoryPort; +//import com.process.clash.application.user.user.port.out.UserRepositoryPort; +//import com.process.clash.domain.common.enums.Major; +//import com.process.clash.domain.record.enums.MonitoredApp; +//import com.process.clash.domain.record.v2.entity.RecordSessionV2; +//import com.process.clash.domain.rival.rival.entity.Rival; +//import com.process.clash.domain.rival.rival.enums.RivalLinkingStatus; +//import com.process.clash.domain.user.user.entity.User; +//import com.process.clash.domain.user.user.enums.Role; +//import com.process.clash.domain.user.user.enums.UserStatus; +//import com.process.clash.domain.user.userrankhistory.enums.ExpTier; +//import com.process.clash.domain.user.userrankhistory.enums.RankTier; +//import com.process.clash.infrastructure.config.record.RecordProperties; +//import org.junit.jupiter.api.BeforeEach; +//import org.junit.jupiter.api.DisplayName; +//import org.junit.jupiter.api.Test; +//import org.junit.jupiter.api.extension.ExtendWith; +//import org.mockito.Mock; +//import org.mockito.junit.jupiter.MockitoExtension; +// +//import java.time.Instant; +//import java.time.ZoneId; +//import java.util.List; +//import java.util.Map; +// +//import static org.assertj.core.api.Assertions.assertThat; +//import static org.mockito.ArgumentMatchers.any; +//import static org.mockito.ArgumentMatchers.anyList; +//import static org.mockito.Mockito.when; +// +//@ExtendWith(MockitoExtension.class) +//class GetMyRivalActingServiceTest { +// +// @Mock +// private RivalRepositoryPort rivalRepositoryPort; +// +// @Mock +// private RecordSessionV2RepositoryPort recordSessionRepositoryPort; +// +// @Mock +// private UserRepositoryPort userRepositoryPort; +// +// @Mock +// private UserPresencePort userPresencePort; +// +// private GetMyRivalActingService getMyRivalActingService; +// @Mock +// private EquippedItemsAssembler equippedItemsAssembler; +// +// @BeforeEach +// void setUp() { +// getMyRivalActingService = new GetMyRivalActingService( +// rivalRepositoryPort, +// recordSessionRepositoryPort, +// userRepositoryPort, +// userPresencePort, +// +// new RecordProperties("UTC", 0), +// ZoneId.of("UTC"), +// +// equippedItemsAssembler +// ); +// } +// +// @Test +// @DisplayName("내 라이벌 조회 시 각 라이벌의 실시간 활동 상태를 반환한다") +// void execute_returnsRivalsWithActivityStatus() { +// Actor actor = new Actor(1L); +// GetMyRivalActingData.Command command = new GetMyRivalActingData.Command(actor); +// +// Rival rivalA = new Rival( +// 100L, +// Instant.now().minusSeconds(86_400), +// Instant.now(), +// RivalLinkingStatus.ACCEPTED, +// 1L, +// 2L +// ); +// Rival rivalB = new Rival( +// 101L, +// Instant.now().minusSeconds(86_400), +// Instant.now(), +// RivalLinkingStatus.ACCEPTED, +// 3L, +// 1L +// ); +// +// User rivalUserA = createUser(2L, "rivalA", "Rival A"); +// User rivalUserB = createUser(3L, "rivalB", "Rival B"); +// +// when(rivalRepositoryPort.findAllByUserId(actor.id())).thenReturn(List.of(rivalA, rivalB)); +// when(userRepositoryPort.findAllByIds(anyList())) +// .thenReturn(List.of(rivalUserA, rivalUserB, createUser(1L, "me", "Me"))); +// when(recordSessionRepositoryPort.getTotalStudyTimeInSecondsByUserIds( +// anyList(), +// any(), +// any() +// )).thenReturn(Map.of(2L, 1800L, 3L, 600L)); +// when(recordSessionRepositoryPort.findAllActiveSessionsByUserIds(anyList())) +// .thenReturn(List.of( +// RecordSessionV2.createDevelop( +// 2L, +// MonitoredApp.VSCODE, +// Instant.now().minusSeconds(300) +// ), +// RecordSessionV2.createTask( +// 3L, +// null, +// null, +// null, +// "Task B", +// Instant.now().minusSeconds(600) +// ) +// )); +// when(userPresencePort.getStatuses(anyList())) +// .thenReturn(Map.of(2L, UserActivityStatus.ONLINE, 3L, UserActivityStatus.AWAY, 1L, UserActivityStatus.ONLINE)); +// when(equippedItemsAssembler.loadByUserIds(anyList())).thenReturn(Map.of()); +// +// GetMyRivalActingData.Result result = getMyRivalActingService.execute(command); +// +// assertThat(result.myRivals()).hasSize(2); +// assertThat(result.myRivals().get(0).rivalId()).isEqualTo(100L); +// assertThat(result.myRivals().get(1).rivalId()).isEqualTo(101L); +// assertThat(result.myRivals().get(0).status()).isEqualTo(UserActivityStatus.ONLINE); +// assertThat(result.myRivals().get(1).status()).isEqualTo(UserActivityStatus.AWAY); +// assertThat(result.myRivals().get(0).usingApp()).isEqualTo("VSCODE"); +// assertThat(result.myRivals().get(1).usingApp()).isNull(); +// assertThat(result.myRivals().get(0).isStudying()).isTrue(); +// assertThat(result.myRivals().get(1).isStudying()).isTrue(); +// assertThat(result.myRivals().get(0).tier()).isEqualTo("UNRANKED"); +// } +// +// @Test +// @DisplayName("rankTier가 NONE이면 expTier를 tier로 반환한다") +// void execute_returnsTierAsExpTier_whenRankTierIsNone() { +// Actor actor = new Actor(1L); +// Rival rival = new Rival(100L, Instant.now().minusSeconds(86_400), Instant.now(), +// RivalLinkingStatus.ACCEPTED, 1L, 2L); +// +// User rivalUser = createUser(2L, "rivalA", "Rival A", RankTier.NONE, ExpTier.GOLD); +// +// when(rivalRepositoryPort.findAllByUserId(actor.id())).thenReturn(List.of(rival)); +// when(userRepositoryPort.findAllByIds(anyList())) +// .thenReturn(List.of(rivalUser, createUser(1L, "me", "Me"))); +// when(recordSessionRepositoryPort.getTotalStudyTimeInSecondsByUserIds(anyList(), any(), any())) +// .thenReturn(Map.of()); +// when(recordSessionRepositoryPort.findAllActiveSessionsByUserIds(anyList())).thenReturn(List.of()); +// when(userPresencePort.getStatuses(anyList())).thenReturn(Map.of(2L, UserActivityStatus.OFFLINE)); +// when(equippedItemsAssembler.loadByUserIds(anyList())).thenReturn(Map.of()); +// +// GetMyRivalActingData.Result result = getMyRivalActingService.execute( +// new GetMyRivalActingData.Command(actor)); +// +// assertThat(result.myRivals().get(0).tier()).isEqualTo("GOLD"); +// } +// +// @Test +// @DisplayName("rankTier가 NONE이 아니면 rankTier를 tier로 반환한다") +// void execute_returnsTierAsRankTier_whenRankTierIsNotNone() { +// Actor actor = new Actor(1L); +// Rival rival = new Rival(100L, Instant.now().minusSeconds(86_400), Instant.now(), +// RivalLinkingStatus.ACCEPTED, 1L, 2L); +// +// User rivalUser = createUser(2L, "rivalA", "Rival A", RankTier.MASTER, ExpTier.DIAMOND); +// +// when(rivalRepositoryPort.findAllByUserId(actor.id())).thenReturn(List.of(rival)); +// when(userRepositoryPort.findAllByIds(anyList())) +// .thenReturn(List.of(rivalUser, createUser(1L, "me", "Me"))); +// when(recordSessionRepositoryPort.getTotalStudyTimeInSecondsByUserIds(anyList(), any(), any())) +// .thenReturn(Map.of()); +// when(recordSessionRepositoryPort.findAllActiveSessionsByUserIds(anyList())).thenReturn(List.of()); +// when(userPresencePort.getStatuses(anyList())).thenReturn(Map.of(2L, UserActivityStatus.OFFLINE)); +// when(equippedItemsAssembler.loadByUserIds(anyList())).thenReturn(Map.of()); +// +// GetMyRivalActingData.Result result = getMyRivalActingService.execute( +// new GetMyRivalActingData.Command(actor)); +// +// assertThat(result.myRivals().get(0).tier()).isEqualTo("MASTER"); +// } +// +// @Test +// @DisplayName("라이벌이 없으면 빈 리스트를 반환한다") +// void execute_returnsEmptyList_whenNoRivals() { +// Actor actor = new Actor(1L); +// when(rivalRepositoryPort.findAllByUserId(actor.id())).thenReturn(List.of()); +// +// GetMyRivalActingData.Result result = getMyRivalActingService.execute( +// new GetMyRivalActingData.Command(actor)); +// +// assertThat(result.myRivals()).isEmpty(); +// } +// +// @Test +// @DisplayName("라이벌의 equippedItems가 응답에 올바르게 포함된다") +// void execute_returnsEquippedItems() { +// Actor actor = new Actor(1L); +// Rival rival = new Rival(100L, Instant.now().minusSeconds(86_400), Instant.now(), +// RivalLinkingStatus.ACCEPTED, 1L, 2L); +// +// User rivalUser = createUser(2L, "rivalA", "Rival A"); +// +// EquippedItemsData equippedItemsData = new EquippedItemsData( +// new EquippedItemsData.EquippedItemData(1L, "배지이름", "badge.png"), +// new EquippedItemsData.EquippedItemData(2L, "닉네임판", "nameplate.png"), +// null +// ); +// +// when(rivalRepositoryPort.findAllByUserId(actor.id())).thenReturn(List.of(rival)); +// when(userRepositoryPort.findAllByIds(anyList())) +// .thenReturn(List.of(rivalUser, createUser(1L, "me", "Me"))); +// when(recordSessionRepositoryPort.getTotalStudyTimeInSecondsByUserIds(anyList(), any(), any())) +// .thenReturn(Map.of()); +// when(recordSessionRepositoryPort.findAllActiveSessionsByUserIds(anyList())).thenReturn(List.of()); +// when(userPresencePort.getStatuses(anyList())).thenReturn(Map.of(2L, UserActivityStatus.OFFLINE)); +// when(equippedItemsAssembler.loadByUserIds(anyList())) +// .thenReturn(Map.of(2L, equippedItemsData)); +// +// GetMyRivalActingData.Result result = getMyRivalActingService.execute( +// new GetMyRivalActingData.Command(actor)); +// +// EquippedItemsData actual = result.myRivals().get(0).equippedItems(); +// assertThat(actual.insignia().id()).isEqualTo(1L); +// assertThat(actual.insignia().name()).isEqualTo("배지이름"); +// assertThat(actual.nameplate().id()).isEqualTo(2L); +// assertThat(actual.banner()).isNull(); +// } +// +// private User createUser(Long id, String username, String name) { +// return createUser(id, username, name, RankTier.NONE, ExpTier.UNRANKED); +// } +// +// private User createUser(Long id, String username, String name, RankTier rankTier, ExpTier expTier) { +// return new User( +// id, +// Instant.now().minusSeconds(86_400), +// Instant.now(), +// username, +// username + "@example.com", +// name, +// "encoded-password", +// Role.USER, +// "", +// 0, +// 0, +// Major.NONE, +// UserStatus.ACTIVE, +// null, +// rankTier, +// expTier +// ); +// } +//} From 9c60d0b48d3b3a428a837a1b747c42f33018eab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Mon, 13 Apr 2026 01:09:37 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20DB=20=EC=A0=9C=EC=95=BD?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=B4?= =?UTF-8?q?=EC=A7=84=20=EB=AF=B8=EB=9F=AC=20=EB=A0=88=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rival/rival/RivalJpaRepository.java | 15 --------------- .../rival/rival/RivalPersistenceAdapter.java | 6 ------ .../rival/rival/port/out/RivalRepositoryPort.java | 1 - .../rival/rival/service/AcceptRivalService.java | 11 ----------- 4 files changed, 33 deletions(-) diff --git a/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalJpaRepository.java b/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalJpaRepository.java index c5500d673..a0611949b 100644 --- a/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalJpaRepository.java +++ b/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalJpaRepository.java @@ -199,21 +199,6 @@ where b.battleStatus in ('IN_PROGRESS', 'PENDING') List findByIdIn(Set ids); - /** - * firstUser → secondUser 방향의 PENDING 라이벌 레코드 조회 - * 수락 시 반대 방향 미러 레코드 정리에 사용 - */ - @Query(value = """ - SELECT * FROM rivals r - WHERE r.fk_first_user_id = :firstUserId - AND r.fk_second_user_id = :secondUserId - AND r.rival_linking_status = 'PENDING' - """, nativeQuery = true) - Optional findPendingByFirstAndSecondUserId( - @Param("firstUserId") Long firstUserId, - @Param("secondUserId") Long secondUserId - ); - /** * 상대방이 나에게 보낸 PENDING 라이벌 신청 존재 여부 */ diff --git a/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalPersistenceAdapter.java b/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalPersistenceAdapter.java index 54c0bb8c6..82fedfdba 100644 --- a/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalPersistenceAdapter.java +++ b/src/main/java/com/process/clash/adapter/persistence/rival/rival/RivalPersistenceAdapter.java @@ -182,12 +182,6 @@ public List findByIdIn(Set ids) { .toList(); } - @Override - public Optional findPendingByFirstAndSecondUserId(Long firstUserId, Long secondUserId) { - return rivalJpaRepository.findPendingByFirstAndSecondUserId(firstUserId, secondUserId) - .map(rivalJpaMapper::toDomain); - } - @Override public boolean existsPendingRivalRequestFrom(Long opponentId, Long myId) { return rivalJpaRepository.existsPendingRivalRequestFrom(opponentId, myId); diff --git a/src/main/java/com/process/clash/application/compete/rival/rival/port/out/RivalRepositoryPort.java b/src/main/java/com/process/clash/application/compete/rival/rival/port/out/RivalRepositoryPort.java index fb40c400a..6cc22aa86 100644 --- a/src/main/java/com/process/clash/application/compete/rival/rival/port/out/RivalRepositoryPort.java +++ b/src/main/java/com/process/clash/application/compete/rival/rival/port/out/RivalRepositoryPort.java @@ -29,7 +29,6 @@ public interface RivalRepositoryPort { void deleteById(Long id); List findAbleToBattleRivals(Long userId); List findByIdIn(Set ids); - Optional findPendingByFirstAndSecondUserId(Long firstUserId, Long secondUserId); boolean existsPendingRivalRequestFrom(Long opponentId, Long myId); boolean existsActiveRivalBetween(Long myId, Long opponentId); void deleteAllByUserId(Long userId); diff --git a/src/main/java/com/process/clash/application/compete/rival/rival/service/AcceptRivalService.java b/src/main/java/com/process/clash/application/compete/rival/rival/service/AcceptRivalService.java index fb5ddb8e1..abe25c7e7 100644 --- a/src/main/java/com/process/clash/application/compete/rival/rival/service/AcceptRivalService.java +++ b/src/main/java/com/process/clash/application/compete/rival/rival/service/AcceptRivalService.java @@ -47,21 +47,10 @@ public void execute(ModifyRivalData.Command command) { cancelAllPendingIfFull(command.actor().id(), affectedUsers); cancelAllPendingIfFull(opponentId, affectedUsers); - // 레이스 컨디션으로 반대 방향(actor→opponent) PENDING 레코드가 함께 생성된 경우 정리 - cancelMirrorPendingIfExists(command.actor().id(), opponentId); - competeRefetchNotifier.notifyUserNoticeChanged(affectedUsers); competeRefetchNotifier.notifyCompeteChanged(affectedUsers); } - private void cancelMirrorPendingIfExists(Long actorId, Long opponentId) { - rivalRepositoryPort.findPendingByFirstAndSecondUserId(actorId, opponentId) - .ifPresent(mirror -> { - rivalRepositoryPort.save(mirror.cancel()); - userNoticeRepositoryPort.deleteApplyRivalNoticeByRivalId(mirror.id()); - }); - } - private void cancelAllPendingIfFull(Long userId, List affectedUsers) { if (rivalRepositoryPort.countAcceptedByUserId(userId) < MAX_RIVAL_COUNT) return;