From 9d4a280314fb3ae4ab7d79c66148c8affebefc66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Wed, 15 Apr 2026 09:22:13 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=8A=B9?= =?UTF-8?q?=EB=A6=AC,=20=EB=AC=B4=EC=8A=B9=EB=B6=80,=20=ED=8C=A8=EB=B0=B0?= =?UTF-8?q?=EC=8B=9C=20=EC=BF=A0=ED=82=A4=20=EC=A7=80=EA=B8=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../battle/service/BattleFinishService.java | 119 ++++++++++++ .../common/enums/GoodsActingCategory.java | 5 +- .../UserSectionProgressJpaRepositoryTest.java | 2 +- .../service/BattleFinishServiceTest.java | 179 +++++++++++++++++- 4 files changed, 302 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/process/clash/application/compete/rival/battle/service/BattleFinishService.java b/src/main/java/com/process/clash/application/compete/rival/battle/service/BattleFinishService.java index 13e4d94d4..b41a4050c 100644 --- a/src/main/java/com/process/clash/application/compete/rival/battle/service/BattleFinishService.java +++ b/src/main/java/com/process/clash/application/compete/rival/battle/service/BattleFinishService.java @@ -2,9 +2,14 @@ import com.process.clash.application.compete.rival.battle.port.out.BattleRepositoryPort; import com.process.clash.application.compete.rival.rival.port.out.RivalRepositoryPort; +import com.process.clash.application.user.user.port.out.UserRepositoryPort; import com.process.clash.application.user.userexphistory.port.out.UserExpHistoryRepositoryPort; +import com.process.clash.application.user.usergoodshistory.port.out.UserGoodsHistoryRepositoryPort; +import com.process.clash.domain.common.enums.GoodsActingCategory; import com.process.clash.domain.rival.battle.entity.Battle; import com.process.clash.domain.rival.rival.entity.Rival; +import com.process.clash.domain.user.user.entity.User; +import com.process.clash.domain.user.usergoodshistory.entity.UserGoodsHistory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -14,6 +19,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -26,9 +32,17 @@ @Slf4j public class BattleFinishService { + private static final Map WINNER_COOKIE_BY_DURATION = Map.of( + 3, 300, + 5, 500, + 7, 700 + ); + private final BattleRepositoryPort battleRepositoryPort; private final RivalRepositoryPort rivalRepositoryPort; private final UserExpHistoryRepositoryPort userExpHistoryRepositoryPort; + private final UserRepositoryPort userRepositoryPort; + private final UserGoodsHistoryRepositoryPort userGoodsHistoryRepositoryPort; public void finishExpiredBattles() { List expiredInProgress = battleRepositoryPort.findExpiredInProgressBattles(); @@ -76,6 +90,7 @@ public void finishExpiredBattles() { if (!battlesToUpdate.isEmpty()) { battleRepositoryPort.saveAll(battlesToUpdate); + grantBatchBattleRewards(battlesToUpdate, rivalMap); } log.info("Expired battles processed. done={}", battlesToUpdate.size()); } @@ -114,6 +129,12 @@ public Battle finishSingleIfExpired(Battle battle) { Long winnerId = determineWinner(latest.id(), rival.firstUserId(), rival.secondUserId(), avgExpByUserAndBattle); Battle finished = latest.finishWithWinner(winnerId); Battle saved = battleRepositoryPort.save(finished); + if (winnerId != null) { + Long loserId = rival.firstUserId().equals(winnerId) ? rival.secondUserId() : rival.firstUserId(); + grantBattleRewards(saved.duration(), winnerId, loserId); + } else { + grantDrawRewards(saved.duration(), rival.firstUserId(), rival.secondUserId()); + } log.info("Battle lazily finished on query. battleId={}, winnerId={}", saved.id(), winnerId); return saved; } catch (Exception e) { @@ -131,4 +152,102 @@ private Long determineWinner(Long battleId, Long firstUserId, Long secondUserId, if (secondExp > firstExp) return secondUserId; return null; // 무승부 } + + private void grantBatchBattleRewards(List finishedBattles, Map rivalMap) { + Set userIds = new HashSet<>(); + for (Battle battle : finishedBattles) { + Rival rival = rivalMap.get(battle.rivalId()); + if (rival == null) continue; + userIds.add(rival.firstUserId()); + userIds.add(rival.secondUserId()); + } + if (userIds.isEmpty()) return; + + Map userMap = userRepositoryPort.findByIdIn(userIds).stream() + .collect(Collectors.toMap(User::id, u -> u)); + + List historyToSave = new ArrayList<>(); + + for (Battle battle : finishedBattles) { + Rival rival = rivalMap.get(battle.rivalId()); + if (rival == null) continue; + + int winnerCookie = WINNER_COOKIE_BY_DURATION.getOrDefault(battle.duration(), 0); + int loserCookie = winnerCookie / 2; + + if (battle.winnerId() != null) { + Long loserId = rival.firstUserId().equals(battle.winnerId()) + ? rival.secondUserId() + : rival.firstUserId(); + + User winner = userMap.get(battle.winnerId()); + User loser = userMap.get(loserId); + + if (winner != null) { + userMap.put(winner.id(), winner.addCookie(winnerCookie)); + historyToSave.add(new UserGoodsHistory(null, null, GoodsActingCategory.BATTLE_WIN_REWARD, winnerCookie, null, winner.id())); + } + if (loser != null) { + userMap.put(loser.id(), loser.addCookie(loserCookie)); + historyToSave.add(new UserGoodsHistory(null, null, GoodsActingCategory.BATTLE_LOSE_REWARD, loserCookie, null, loser.id())); + } + } else { + int drawCookie = (winnerCookie + loserCookie) / 2; + User first = userMap.get(rival.firstUserId()); + User second = userMap.get(rival.secondUserId()); + + if (first != null) { + userMap.put(first.id(), first.addCookie(drawCookie)); + historyToSave.add(new UserGoodsHistory(null, null, GoodsActingCategory.BATTLE_DRAW_REWARD, drawCookie, null, first.id())); + } + if (second != null) { + userMap.put(second.id(), second.addCookie(drawCookie)); + historyToSave.add(new UserGoodsHistory(null, null, GoodsActingCategory.BATTLE_DRAW_REWARD, drawCookie, null, second.id())); + } + } + } + + userRepositoryPort.saveAll(new ArrayList<>(userMap.values())); + userGoodsHistoryRepositoryPort.saveAll(historyToSave); + } + + private void grantBattleRewards(int duration, Long winnerId, Long loserId) { + int winnerCookie = WINNER_COOKIE_BY_DURATION.getOrDefault(duration, 0); + int loserCookie = winnerCookie / 2; + + List usersToSave = new ArrayList<>(); + List historyToSave = new ArrayList<>(); + + userRepositoryPort.findById(winnerId).ifPresent(winner -> { + usersToSave.add(winner.addCookie(winnerCookie)); + historyToSave.add(new UserGoodsHistory(null, null, GoodsActingCategory.BATTLE_WIN_REWARD, winnerCookie, null, winnerId)); + }); + userRepositoryPort.findById(loserId).ifPresent(loser -> { + usersToSave.add(loser.addCookie(loserCookie)); + historyToSave.add(new UserGoodsHistory(null, null, GoodsActingCategory.BATTLE_LOSE_REWARD, loserCookie, null, loserId)); + }); + + userRepositoryPort.saveAll(usersToSave); + userGoodsHistoryRepositoryPort.saveAll(historyToSave); + } + + private void grantDrawRewards(int duration, Long firstUserId, Long secondUserId) { + int winnerCookie = WINNER_COOKIE_BY_DURATION.getOrDefault(duration, 0); + int drawCookie = (winnerCookie + winnerCookie / 2) / 2; + + List usersToSave = new ArrayList<>(); + List historyToSave = new ArrayList<>(); + + userRepositoryPort.findById(firstUserId).ifPresent(user -> { + usersToSave.add(user.addCookie(drawCookie)); + historyToSave.add(new UserGoodsHistory(null, null, GoodsActingCategory.BATTLE_DRAW_REWARD, drawCookie, null, firstUserId)); + }); + userRepositoryPort.findById(secondUserId).ifPresent(user -> { + usersToSave.add(user.addCookie(drawCookie)); + historyToSave.add(new UserGoodsHistory(null, null, GoodsActingCategory.BATTLE_DRAW_REWARD, drawCookie, null, secondUserId)); + }); + + userRepositoryPort.saveAll(usersToSave); + userGoodsHistoryRepositoryPort.saveAll(historyToSave); + } } \ No newline at end of file diff --git a/src/main/java/com/process/clash/domain/common/enums/GoodsActingCategory.java b/src/main/java/com/process/clash/domain/common/enums/GoodsActingCategory.java index e1beae04a..429add0d9 100644 --- a/src/main/java/com/process/clash/domain/common/enums/GoodsActingCategory.java +++ b/src/main/java/com/process/clash/domain/common/enums/GoodsActingCategory.java @@ -4,5 +4,8 @@ public enum GoodsActingCategory { PURCHASE, SEASON_REWARD, ROADMAP_V2_CHAPTER_REWARD, - ROADMAP_V2_SECTION_REWARD + ROADMAP_V2_SECTION_REWARD, + BATTLE_WIN_REWARD, + BATTLE_LOSE_REWARD, + BATTLE_DRAW_REWARD } diff --git a/src/test/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepositoryTest.java b/src/test/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepositoryTest.java index b80c43acb..5673a2ac3 100644 --- a/src/test/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepositoryTest.java +++ b/src/test/java/com/process/clash/adapter/persistence/roadmap/sectionprogress/UserSectionProgressJpaRepositoryTest.java @@ -273,7 +273,7 @@ void findRankingsWithMyRank_keepsSameRankWhenTied() { private UserJpaEntity persistUser(String username, String name) { Instant now = Instant.now(); User user = new User(null, now, now, username, username + "@gmail.com", name, "password", - Role.USER, "", 0, 0, Major.NONE, UserStatus.PENDING, null, false, RankTier.NONE, ExpTier.UNRANKED); + Role.USER, "", 0, 0, Major.NONE, UserStatus.PENDING, null, false, RankTier.NONE, ExpTier.UNRANKED, 0); UserJpaEntity entity = userJpaMapper.toJpaEntity(user); em.persist(entity); return entity; diff --git a/src/test/java/com/process/clash/application/compete/rival/battle/service/BattleFinishServiceTest.java b/src/test/java/com/process/clash/application/compete/rival/battle/service/BattleFinishServiceTest.java index df9e326aa..480fdd53b 100644 --- a/src/test/java/com/process/clash/application/compete/rival/battle/service/BattleFinishServiceTest.java +++ b/src/test/java/com/process/clash/application/compete/rival/battle/service/BattleFinishServiceTest.java @@ -2,11 +2,16 @@ import com.process.clash.application.compete.rival.battle.port.out.BattleRepositoryPort; import com.process.clash.application.compete.rival.rival.port.out.RivalRepositoryPort; +import com.process.clash.application.user.user.port.out.UserRepositoryPort; import com.process.clash.application.user.userexphistory.port.out.UserExpHistoryRepositoryPort; +import com.process.clash.application.user.usergoodshistory.port.out.UserGoodsHistoryRepositoryPort; +import com.process.clash.domain.common.enums.GoodsActingCategory; import com.process.clash.domain.rival.battle.entity.Battle; import com.process.clash.domain.rival.battle.enums.BattleStatus; 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.usergoodshistory.entity.UserGoodsHistory; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -19,6 +24,7 @@ import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -42,6 +48,12 @@ class BattleFinishServiceTest { @Mock private UserExpHistoryRepositoryPort userExpHistoryRepositoryPort; + @Mock + private UserRepositoryPort userRepositoryPort; + + @Mock + private UserGoodsHistoryRepositoryPort userGoodsHistoryRepositoryPort; + private BattleFinishService battleFinishService; @BeforeEach @@ -49,7 +61,9 @@ void setUp() { battleFinishService = new BattleFinishService( battleRepositoryPort, rivalRepositoryPort, - userExpHistoryRepositoryPort + userExpHistoryRepositoryPort, + userRepositoryPort, + userGoodsHistoryRepositoryPort ); } @@ -64,12 +78,16 @@ void finishExpiredBattles_transitionsInProgressToDone() { Rival rival = new Rival(RIVAL_ID, Instant.now(), Instant.now(), RivalLinkingStatus.ACCEPTED, FIRST_USER_ID, SECOND_USER_ID); + User winner = mockUser(FIRST_USER_ID, 0); + User loser = mockUser(SECOND_USER_ID, 0); + when(battleRepositoryPort.findExpiredInProgressBattles()).thenReturn(List.of(inProgressBattle)); when(rivalRepositoryPort.findByIdIn(Set.of(RIVAL_ID))).thenReturn(List.of(rival)); when(userExpHistoryRepositoryPort.findAverageExpForBattles(eq(FIRST_USER_ID), anyList())) .thenReturn(Map.of(1L, 10.0)); when(userExpHistoryRepositoryPort.findAverageExpForBattles(eq(SECOND_USER_ID), anyList())) .thenReturn(Map.of(1L, 5.0)); + when(userRepositoryPort.findByIdIn(anySet())).thenReturn(List.of(winner, loser)); battleFinishService.finishExpiredBattles(); @@ -78,6 +96,158 @@ void finishExpiredBattles_transitionsInProgressToDone() { assertThat(captor.getValue()).allMatch(b -> b.battleStatus() == BattleStatus.DONE); } + @Test + @DisplayName("7일 배틀 종료 시 승자에게 700, 패자에게 350 쿠키를 지급한다") + void finishExpiredBattles_grants700CookiesToWinnerAnd350ToLoser_for7DayBattle() { + Instant startedAt = Instant.now().minus(8, ChronoUnit.DAYS); + Instant endAt = Instant.now().minus(1, ChronoUnit.DAYS); + Battle inProgressBattle = new Battle(1L, Instant.now(), Instant.now(), + startedAt, endAt, 7, BattleStatus.IN_PROGRESS, null, RIVAL_ID, FIRST_USER_ID); + + Rival rival = new Rival(RIVAL_ID, Instant.now(), Instant.now(), + RivalLinkingStatus.ACCEPTED, FIRST_USER_ID, SECOND_USER_ID); + + User winner = mockUser(FIRST_USER_ID, 0); + User loser = mockUser(SECOND_USER_ID, 0); + + when(battleRepositoryPort.findExpiredInProgressBattles()).thenReturn(List.of(inProgressBattle)); + when(rivalRepositoryPort.findByIdIn(Set.of(RIVAL_ID))).thenReturn(List.of(rival)); + when(userExpHistoryRepositoryPort.findAverageExpForBattles(eq(FIRST_USER_ID), anyList())) + .thenReturn(Map.of(1L, 10.0)); + when(userExpHistoryRepositoryPort.findAverageExpForBattles(eq(SECOND_USER_ID), anyList())) + .thenReturn(Map.of(1L, 5.0)); + when(userRepositoryPort.findByIdIn(anySet())).thenReturn(List.of(winner, loser)); + + battleFinishService.finishExpiredBattles(); + + ArgumentCaptor> historyCaptor = ArgumentCaptor.forClass(List.class); + verify(userGoodsHistoryRepositoryPort).saveAll(historyCaptor.capture()); + + List histories = historyCaptor.getValue(); + assertThat(histories).hasSize(2); + assertThat(histories).anySatisfy(h -> { + assertThat(h.userId()).isEqualTo(FIRST_USER_ID); + assertThat(h.goodsActingCategory()).isEqualTo(GoodsActingCategory.BATTLE_WIN_REWARD); + assertThat(h.variation()).isEqualTo(700); + }); + assertThat(histories).anySatisfy(h -> { + assertThat(h.userId()).isEqualTo(SECOND_USER_ID); + assertThat(h.goodsActingCategory()).isEqualTo(GoodsActingCategory.BATTLE_LOSE_REWARD); + assertThat(h.variation()).isEqualTo(350); + }); + } + + @Test + @DisplayName("5일 배틀 종료 시 승자에게 500, 패자에게 250 쿠키를 지급한다") + void finishExpiredBattles_grants500CookiesToWinnerAnd250ToLoser_for5DayBattle() { + Instant startedAt = Instant.now().minus(6, ChronoUnit.DAYS); + Instant endAt = Instant.now().minus(1, ChronoUnit.DAYS); + Battle inProgressBattle = new Battle(2L, Instant.now(), Instant.now(), + startedAt, endAt, 5, BattleStatus.IN_PROGRESS, null, RIVAL_ID, FIRST_USER_ID); + + Rival rival = new Rival(RIVAL_ID, Instant.now(), Instant.now(), + RivalLinkingStatus.ACCEPTED, FIRST_USER_ID, SECOND_USER_ID); + + User winner = mockUser(FIRST_USER_ID, 0); + User loser = mockUser(SECOND_USER_ID, 0); + + when(battleRepositoryPort.findExpiredInProgressBattles()).thenReturn(List.of(inProgressBattle)); + when(rivalRepositoryPort.findByIdIn(Set.of(RIVAL_ID))).thenReturn(List.of(rival)); + when(userExpHistoryRepositoryPort.findAverageExpForBattles(eq(FIRST_USER_ID), anyList())) + .thenReturn(Map.of(2L, 10.0)); + when(userExpHistoryRepositoryPort.findAverageExpForBattles(eq(SECOND_USER_ID), anyList())) + .thenReturn(Map.of(2L, 5.0)); + when(userRepositoryPort.findByIdIn(anySet())).thenReturn(List.of(winner, loser)); + + battleFinishService.finishExpiredBattles(); + + ArgumentCaptor> historyCaptor = ArgumentCaptor.forClass(List.class); + verify(userGoodsHistoryRepositoryPort).saveAll(historyCaptor.capture()); + + List histories = historyCaptor.getValue(); + assertThat(histories).anySatisfy(h -> { + assertThat(h.userId()).isEqualTo(FIRST_USER_ID); + assertThat(h.variation()).isEqualTo(500); + }); + assertThat(histories).anySatisfy(h -> { + assertThat(h.userId()).isEqualTo(SECOND_USER_ID); + assertThat(h.variation()).isEqualTo(250); + }); + } + + @Test + @DisplayName("3일 배틀 종료 시 승자에게 300, 패자에게 150 쿠키를 지급한다") + void finishExpiredBattles_grants300CookiesToWinnerAnd150ToLoser_for3DayBattle() { + Instant startedAt = Instant.now().minus(4, ChronoUnit.DAYS); + Instant endAt = Instant.now().minus(1, ChronoUnit.DAYS); + Battle inProgressBattle = new Battle(3L, Instant.now(), Instant.now(), + startedAt, endAt, 3, BattleStatus.IN_PROGRESS, null, RIVAL_ID, FIRST_USER_ID); + + Rival rival = new Rival(RIVAL_ID, Instant.now(), Instant.now(), + RivalLinkingStatus.ACCEPTED, FIRST_USER_ID, SECOND_USER_ID); + + User winner = mockUser(FIRST_USER_ID, 0); + User loser = mockUser(SECOND_USER_ID, 0); + + when(battleRepositoryPort.findExpiredInProgressBattles()).thenReturn(List.of(inProgressBattle)); + when(rivalRepositoryPort.findByIdIn(Set.of(RIVAL_ID))).thenReturn(List.of(rival)); + when(userExpHistoryRepositoryPort.findAverageExpForBattles(eq(FIRST_USER_ID), anyList())) + .thenReturn(Map.of(3L, 10.0)); + when(userExpHistoryRepositoryPort.findAverageExpForBattles(eq(SECOND_USER_ID), anyList())) + .thenReturn(Map.of(3L, 5.0)); + when(userRepositoryPort.findByIdIn(anySet())).thenReturn(List.of(winner, loser)); + + battleFinishService.finishExpiredBattles(); + + ArgumentCaptor> historyCaptor = ArgumentCaptor.forClass(List.class); + verify(userGoodsHistoryRepositoryPort).saveAll(historyCaptor.capture()); + + List histories = historyCaptor.getValue(); + assertThat(histories).anySatisfy(h -> { + assertThat(h.userId()).isEqualTo(FIRST_USER_ID); + assertThat(h.variation()).isEqualTo(300); + }); + assertThat(histories).anySatisfy(h -> { + assertThat(h.userId()).isEqualTo(SECOND_USER_ID); + assertThat(h.variation()).isEqualTo(150); + }); + } + + @Test + @DisplayName("7일 무승부 배틀은 양측 모두에게 525 쿠키를 지급한다") + void finishExpiredBattles_grants525CookiesToBoth_whenDraw_for7DayBattle() { + Instant startedAt = Instant.now().minus(8, ChronoUnit.DAYS); + Instant endAt = Instant.now().minus(1, ChronoUnit.DAYS); + Battle inProgressBattle = new Battle(1L, Instant.now(), Instant.now(), + startedAt, endAt, 7, BattleStatus.IN_PROGRESS, null, RIVAL_ID, FIRST_USER_ID); + + Rival rival = new Rival(RIVAL_ID, Instant.now(), Instant.now(), + RivalLinkingStatus.ACCEPTED, FIRST_USER_ID, SECOND_USER_ID); + + User first = mockUser(FIRST_USER_ID, 0); + User second = mockUser(SECOND_USER_ID, 0); + + when(battleRepositoryPort.findExpiredInProgressBattles()).thenReturn(List.of(inProgressBattle)); + when(rivalRepositoryPort.findByIdIn(Set.of(RIVAL_ID))).thenReturn(List.of(rival)); + when(userExpHistoryRepositoryPort.findAverageExpForBattles(eq(FIRST_USER_ID), anyList())) + .thenReturn(Map.of(1L, 10.0)); + when(userExpHistoryRepositoryPort.findAverageExpForBattles(eq(SECOND_USER_ID), anyList())) + .thenReturn(Map.of(1L, 10.0)); + when(userRepositoryPort.findByIdIn(anySet())).thenReturn(List.of(first, second)); + + battleFinishService.finishExpiredBattles(); + + ArgumentCaptor> historyCaptor = ArgumentCaptor.forClass(List.class); + verify(userGoodsHistoryRepositoryPort).saveAll(historyCaptor.capture()); + + List histories = historyCaptor.getValue(); + assertThat(histories).hasSize(2); + assertThat(histories).allSatisfy(h -> { + assertThat(h.goodsActingCategory()).isEqualTo(GoodsActingCategory.BATTLE_DRAW_REWARD); + assertThat(h.variation()).isEqualTo(525); // (700 + 350) / 2 + }); + } + @Test @DisplayName("종료할 배틀이 없으면 saveAll을 호출하지 않는다") void finishExpiredBattles_doesNotCallSaveAll_whenNoBattlesToProcess() { @@ -87,4 +257,11 @@ void finishExpiredBattles_doesNotCallSaveAll_whenNoBattlesToProcess() { verify(battleRepositoryPort, never()).saveAll(any()); } + + private User mockUser(Long id, int totalCookie) { + User user = mock(User.class); + when(user.id()).thenReturn(id); + when(user.addCookie(anyInt())).thenReturn(user); + return user; + } } \ No newline at end of file From 7587280ae32f2707e5481a72a1fe9904bd5776fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Wed, 15 Apr 2026 09:28:08 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=B6=9C=EC=84=9D=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20=EC=BF=A0=ED=82=A4=20=EC=A7=80=EA=B8=89=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MarkAttendanceService.java | 13 +++- .../common/enums/GoodsActingCategory.java | 4 +- .../service/MarkAttendanceServiceTest.java | 60 ++++++++++++++++++- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java b/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java index dc84ac206..abe9511f9 100644 --- a/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java +++ b/src/main/java/com/process/clash/application/user/userattendance/service/MarkAttendanceService.java @@ -6,8 +6,11 @@ import com.process.clash.application.user.userattendance.exception.exception.conflict.AlreadyAttendedException; import com.process.clash.application.user.userattendance.port.in.MarkAttendanceUseCase; import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; +import com.process.clash.application.user.usergoodshistory.port.out.UserGoodsHistoryRepositoryPort; +import com.process.clash.domain.common.enums.GoodsActingCategory; import com.process.clash.domain.user.user.entity.User; import com.process.clash.domain.user.userattendance.entity.UserAttendance; +import com.process.clash.domain.user.usergoodshistory.entity.UserGoodsHistory; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,6 +29,7 @@ public class MarkAttendanceService implements MarkAttendanceUseCase { private final UserAttendanceRepositoryPort userAttendanceRepositoryPort; private final UserRepositoryPort userRepositoryPort; + private final UserGoodsHistoryRepositoryPort userGoodsHistoryRepositoryPort; @Override public MarkAttendanceData.Result execute(MarkAttendanceData.Command command) { @@ -45,12 +49,15 @@ public MarkAttendanceData.Result execute(MarkAttendanceData.Command command) { User user = userRepositoryPort.findByIdForUpdate(userId) .orElseThrow(UserNotFoundException::new); - int earnedCookies = isFullWeekCompleted(userId, attendanceDate) - ? WEEKLY_COOKIE_REWARD - : DAILY_COOKIE_REWARD; + boolean isFullWeek = isFullWeekCompleted(userId, attendanceDate); + int earnedCookies = isFullWeek ? WEEKLY_COOKIE_REWARD : DAILY_COOKIE_REWARD; + GoodsActingCategory category = isFullWeek + ? GoodsActingCategory.ATTENDANCE_WEEKLY_REWARD + : GoodsActingCategory.ATTENDANCE_DAILY_REWARD; User updated = user.incrementAttendanceStreak().addCookie(earnedCookies); userRepositoryPort.save(updated); + userGoodsHistoryRepositoryPort.save(new UserGoodsHistory(null, null, category, earnedCookies, null, userId)); return new MarkAttendanceData.Result(updated.currentAttendanceStreak(), earnedCookies); } diff --git a/src/main/java/com/process/clash/domain/common/enums/GoodsActingCategory.java b/src/main/java/com/process/clash/domain/common/enums/GoodsActingCategory.java index 429add0d9..ae020ebed 100644 --- a/src/main/java/com/process/clash/domain/common/enums/GoodsActingCategory.java +++ b/src/main/java/com/process/clash/domain/common/enums/GoodsActingCategory.java @@ -7,5 +7,7 @@ public enum GoodsActingCategory { ROADMAP_V2_SECTION_REWARD, BATTLE_WIN_REWARD, BATTLE_LOSE_REWARD, - BATTLE_DRAW_REWARD + BATTLE_DRAW_REWARD, + ATTENDANCE_DAILY_REWARD, + ATTENDANCE_WEEKLY_REWARD } diff --git a/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java b/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java index daa27b6bd..dc82bba91 100644 --- a/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java +++ b/src/test/java/com/process/clash/application/user/userattendance/service/MarkAttendanceServiceTest.java @@ -7,6 +7,8 @@ import com.process.clash.application.user.userattendance.exception.exception.conflict.AlreadyAttendedException; import com.process.clash.application.user.userattendance.exception.exception.notfound.UserAttendanceNotFoundException; import com.process.clash.application.user.userattendance.port.out.UserAttendanceRepositoryPort; +import com.process.clash.application.user.usergoodshistory.port.out.UserGoodsHistoryRepositoryPort; +import com.process.clash.domain.common.enums.GoodsActingCategory; import com.process.clash.domain.common.enums.Major; import com.process.clash.domain.user.user.entity.User; import com.process.clash.domain.user.user.enums.Role; @@ -33,6 +35,7 @@ import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.any; @ExtendWith(MockitoExtension.class) class MarkAttendanceServiceTest { @@ -43,6 +46,9 @@ class MarkAttendanceServiceTest { @Mock private UserRepositoryPort userRepositoryPort; + @Mock + private UserGoodsHistoryRepositoryPort userGoodsHistoryRepositoryPort; + private MarkAttendanceService markAttendanceService; private final Long USER_ID = 1L; @@ -50,7 +56,7 @@ class MarkAttendanceServiceTest { @BeforeEach void setUp() { - markAttendanceService = new MarkAttendanceService(userAttendanceRepositoryPort, userRepositoryPort); + markAttendanceService = new MarkAttendanceService(userAttendanceRepositoryPort, userRepositoryPort, userGoodsHistoryRepositoryPort); } @Test @@ -164,6 +170,58 @@ void execute_givesWeeklyBonusCookies_whenFullWeekCompleted() { } } + @Test + @DisplayName("일일 출석 시 ATTENDANCE_DAILY_REWARD 카테고리로 쿠키 지급 기록을 남긴다") + void execute_savesAttendanceDailyRewardHistory_whenDailyAttendance() { + try (MockedStatic mockedStatic = mockStatic(UserAttendance.class)) { + mockedStatic.when(UserAttendance::currentAttendanceDate).thenReturn(FIXED_MONDAY); + + MarkAttendanceData.Command command = new MarkAttendanceData.Command(new Actor(USER_ID)); + User user = createUser(USER_ID, 0); + UserAttendance notAttended = new UserAttendance(1L, Instant.now(), Instant.now(), USER_ID, FIXED_MONDAY, false); + + when(userAttendanceRepositoryPort.findByUserIdAndAttendanceDate(USER_ID, FIXED_MONDAY)) + .thenReturn(Optional.of(notAttended)); + when(userRepositoryPort.findByIdForUpdate(USER_ID)) + .thenReturn(Optional.of(user)); + + markAttendanceService.execute(command); + + verify(userGoodsHistoryRepositoryPort).save(argThat(h -> + h.userId().equals(USER_ID) + && h.goodsActingCategory() == GoodsActingCategory.ATTENDANCE_DAILY_REWARD + && h.variation() == 300 + )); + } + } + + @Test + @DisplayName("주간 출석 완료 시 ATTENDANCE_WEEKLY_REWARD 카테고리로 쿠키 지급 기록을 남긴다") + void execute_savesAttendanceWeeklyRewardHistory_whenWeeklyAttendance() { + try (MockedStatic mockedStatic = mockStatic(UserAttendance.class)) { + mockedStatic.when(UserAttendance::currentAttendanceDate).thenReturn(FIXED_SATURDAY); + + MarkAttendanceData.Command command = new MarkAttendanceData.Command(new Actor(USER_ID)); + User user = createUser(USER_ID, 6); + UserAttendance notAttended = new UserAttendance(1L, Instant.now(), Instant.now(), USER_ID, FIXED_SATURDAY, false); + + when(userAttendanceRepositoryPort.findByUserIdAndAttendanceDate(USER_ID, FIXED_SATURDAY)) + .thenReturn(Optional.of(notAttended)); + when(userRepositoryPort.findByIdForUpdate(USER_ID)) + .thenReturn(Optional.of(user)); + when(userAttendanceRepositoryPort.countAttendedByUserIdBetween(USER_ID, FIXED_SUNDAY, FIXED_SATURDAY)) + .thenReturn(7L); + + markAttendanceService.execute(command); + + verify(userGoodsHistoryRepositoryPort).save(argThat(h -> + h.userId().equals(USER_ID) + && h.goodsActingCategory() == GoodsActingCategory.ATTENDANCE_WEEKLY_REWARD + && h.variation() == 1000 + )); + } + } + @Test @DisplayName("토요일에 이번 주 7일 미달 출석 시 일일 300 쿠키만 지급한다") void execute_givesOnlyDailyCookies_whenWeekNotFullyCompleted() { From 9ab1f2afc448b9cd3ac0372f4213933e6c9d6a17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EC=A0=95=EB=B9=88?= Date: Wed, 15 Apr 2026 09:57:19 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8B=80=20=EB=B3=B4?= =?UTF-8?q?=EC=83=81=20=EC=A7=80=EA=B8=89=20=EC=8B=9C=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=EC=9D=B4=EC=8A=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rival/battle/service/BattleFinishService.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/process/clash/application/compete/rival/battle/service/BattleFinishService.java b/src/main/java/com/process/clash/application/compete/rival/battle/service/BattleFinishService.java index b41a4050c..4b244509a 100644 --- a/src/main/java/com/process/clash/application/compete/rival/battle/service/BattleFinishService.java +++ b/src/main/java/com/process/clash/application/compete/rival/battle/service/BattleFinishService.java @@ -218,11 +218,11 @@ private void grantBattleRewards(int duration, Long winnerId, Long loserId) { List usersToSave = new ArrayList<>(); List historyToSave = new ArrayList<>(); - userRepositoryPort.findById(winnerId).ifPresent(winner -> { + userRepositoryPort.findByIdForUpdate(winnerId).ifPresent(winner -> { usersToSave.add(winner.addCookie(winnerCookie)); historyToSave.add(new UserGoodsHistory(null, null, GoodsActingCategory.BATTLE_WIN_REWARD, winnerCookie, null, winnerId)); }); - userRepositoryPort.findById(loserId).ifPresent(loser -> { + userRepositoryPort.findByIdForUpdate(loserId).ifPresent(loser -> { usersToSave.add(loser.addCookie(loserCookie)); historyToSave.add(new UserGoodsHistory(null, null, GoodsActingCategory.BATTLE_LOSE_REWARD, loserCookie, null, loserId)); }); @@ -233,16 +233,17 @@ private void grantBattleRewards(int duration, Long winnerId, Long loserId) { private void grantDrawRewards(int duration, Long firstUserId, Long secondUserId) { int winnerCookie = WINNER_COOKIE_BY_DURATION.getOrDefault(duration, 0); - int drawCookie = (winnerCookie + winnerCookie / 2) / 2; + int loserCookie = winnerCookie / 2; + int drawCookie = (winnerCookie + loserCookie) / 2; List usersToSave = new ArrayList<>(); List historyToSave = new ArrayList<>(); - userRepositoryPort.findById(firstUserId).ifPresent(user -> { + userRepositoryPort.findByIdForUpdate(firstUserId).ifPresent(user -> { usersToSave.add(user.addCookie(drawCookie)); historyToSave.add(new UserGoodsHistory(null, null, GoodsActingCategory.BATTLE_DRAW_REWARD, drawCookie, null, firstUserId)); }); - userRepositoryPort.findById(secondUserId).ifPresent(user -> { + userRepositoryPort.findByIdForUpdate(secondUserId).ifPresent(user -> { usersToSave.add(user.addCookie(drawCookie)); historyToSave.add(new UserGoodsHistory(null, null, GoodsActingCategory.BATTLE_DRAW_REWARD, drawCookie, null, secondUserId)); });