Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ AND b.id NOT IN (
FROM battles b
LEFT JOIN rivals r ON b.fk_rival_id = r.id
WHERE (r.fk_first_user_id = :userId OR r.fk_second_user_id = :userId)
AND b.battle_status NOT IN ('REJECTED', 'PENDING')
AND b.battle_status NOT IN ('REJECTED', 'PENDING', 'CANCELED')
Comment thread
hjbin-25 marked this conversation as resolved.
""", nativeQuery = true)
List<BattleJpaEntity> findByUserIdWithOutRejected(@Param("userId") Long userId);

Expand Down Expand Up @@ -115,20 +115,6 @@ AND b.end_at < NOW()
""", nativeQuery = true)
List<BattleJpaEntity> findExpiredInProgressBattles();

/**
* 종료 시각이 지났으나 아직 NOT_STARTED 상태인 배틀 조회 → CANCELED 처리 (스케줄러용)
* soft-delete된 라이벌(fk_rival_id IS NULL)은 제외
*/
@Query(value = """
SELECT b.*
FROM battles b
WHERE b.battle_status = 'NOT_STARTED'
AND b.fk_rival_id IS NOT NULL
AND b.end_at IS NOT NULL
AND b.end_at < NOW()
""", nativeQuery = true)
List<BattleJpaEntity> findExpiredNotStartedBattles();

/**
* 유저 관련 PENDING 상태 배틀 신청 목록 조회
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,6 @@ public List<Battle> findExpiredInProgressBattles() {
.toList();
}

@Override
public List<Battle> findExpiredNotStartedBattles() {
return battleJpaRepository.findExpiredNotStartedBattles()
.stream()
.map(battleJpaMapper::toDomain)
.toList();
}

@Override
public List<Battle> findPendingBattlesByUserId(Long userId) {
return battleJpaRepository.findPendingBattlesByUserId(userId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ com.process.clash.adapter.web.common.ApiResponse<Void> applyBattle(
ApplyBattleDto.Request request
);

@Operation(summary = "배틀 승인", description = "신청받은 배틀을 승인합니다. 승인 후 배틀은 NOT_STARTED 상태가 되며, 시작일 KST 06시에 자동으로 IN_PROGRESS로 전환됩니다.")
@Operation(summary = "배틀 승인", description = "신청받은 배틀을 승인합니다. 승인 즉시 배틀이 IN_PROGRESS 상태로 시작됩니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "승인 성공",
content = @Content(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public static class BattleInfoDoc {
public LocalDate expireDate;

@Schema(description = "결과", example = "WON")
public String result; // WON, LOST, DRAWN, WINNING, LOSING, DRAWING, NOT_STARTED, CANCELED, PENDING
public String result; // WON, LOST, DRAWN, WINNING, LOSING, DRAWING
}

public static class EnemyDoc {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public record BattleInfo(
Long id,
Enemy enemy,
LocalDate expireDate,
String result // WON, LOST, DRAWN, WINNING, LOSING, DRAWING, NOT_STARTED, CANCELED, PENDING
String result // WON, LOST, DRAWN, WINNING, LOSING, DRAWING
) {
public static BattleInfo of(Long id, Enemy enemy, LocalDate expireDate, String result) {
return new BattleInfo(id, enemy, expireDate, result);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,5 @@ public interface BattleRepositoryPort {
void rejectAllActiveBattlesByUserId(Long userId);
void rejectAllActiveBattlesByRivalId(Long rivalId);
List<Battle> findExpiredInProgressBattles();
List<Battle> findExpiredNotStartedBattles();
List<Battle> findPendingBattlesByUserId(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,56 +32,52 @@ public class BattleFinishService {

public void finishExpiredBattles() {
List<Battle> expiredInProgress = battleRepositoryPort.findExpiredInProgressBattles();
List<Battle> expiredNotStarted = battleRepositoryPort.findExpiredNotStartedBattles();

if (expiredInProgress.isEmpty()) {
return;
}

List<Battle> battlesToUpdate = new ArrayList<>();

if (!expiredInProgress.isEmpty()) {
// 라이벌 일괄 조회
Set<Long> rivalIds = expiredInProgress.stream()
.map(Battle::rivalId)
.collect(Collectors.toSet());
Map<Long, Rival> rivalMap = rivalRepositoryPort.findByIdIn(rivalIds).stream()
.collect(Collectors.toMap(Rival::id, r -> r));

// 유저별 배틀 그룹화 후 평균 exp 일괄 조회
Map<Long, List<Battle>> battlesByUserId = new HashMap<>();
for (Battle battle : expiredInProgress) {
Rival rival = rivalMap.get(battle.rivalId());
if (rival == null) continue;
battlesByUserId.computeIfAbsent(rival.firstUserId(), k -> new ArrayList<>()).add(battle);
battlesByUserId.computeIfAbsent(rival.secondUserId(), k -> new ArrayList<>()).add(battle);
}
// 라이벌 일괄 조회
Set<Long> rivalIds = expiredInProgress.stream()
.map(Battle::rivalId)
.collect(Collectors.toSet());
Map<Long, Rival> rivalMap = rivalRepositoryPort.findByIdIn(rivalIds).stream()
.collect(Collectors.toMap(Rival::id, r -> r));

// 유저별 배틀 그룹화 후 평균 exp 일괄 조회
Map<Long, List<Battle>> battlesByUserId = new HashMap<>();
for (Battle battle : expiredInProgress) {
Rival rival = rivalMap.get(battle.rivalId());
if (rival == null) continue;
battlesByUserId.computeIfAbsent(rival.firstUserId(), k -> new ArrayList<>()).add(battle);
battlesByUserId.computeIfAbsent(rival.secondUserId(), k -> new ArrayList<>()).add(battle);
}

Map<Long, Map<Long, Double>> avgExpByUserAndBattle = new HashMap<>();
for (Map.Entry<Long, List<Battle>> entry : battlesByUserId.entrySet()) {
avgExpByUserAndBattle.put(
entry.getKey(),
userExpHistoryRepositoryPort.findAverageExpForBattles(entry.getKey(), entry.getValue())
);
}
Map<Long, Map<Long, Double>> avgExpByUserAndBattle = new HashMap<>();
for (Map.Entry<Long, List<Battle>> entry : battlesByUserId.entrySet()) {
avgExpByUserAndBattle.put(
entry.getKey(),
userExpHistoryRepositoryPort.findAverageExpForBattles(entry.getKey(), entry.getValue())
);
}

for (Battle battle : expiredInProgress) {
try {
Rival rival = Optional.ofNullable(rivalMap.get(battle.rivalId()))
.orElseThrow(() -> new IllegalStateException("Rival not found. rivalId=" + battle.rivalId()));
Long winnerId = determineWinner(battle.id(), rival.firstUserId(), rival.secondUserId(), avgExpByUserAndBattle);
battlesToUpdate.add(battle.finishWithWinner(winnerId));
} catch (Exception e) {
log.error("배틀 종료 처리 중 오류 발생. battleId={}", battle.id(), e);
}
for (Battle battle : expiredInProgress) {
try {
Rival rival = Optional.ofNullable(rivalMap.get(battle.rivalId()))
.orElseThrow(() -> new IllegalStateException("Rival not found. rivalId=" + battle.rivalId()));
Long winnerId = determineWinner(battle.id(), rival.firstUserId(), rival.secondUserId(), avgExpByUserAndBattle);
battlesToUpdate.add(battle.finishWithWinner(winnerId));
} catch (Exception e) {
log.error("배틀 종료 처리 중 오류 발생. battleId={}", battle.id(), e);
}
}

expiredNotStarted.stream()
.map(Battle::cancel)
.forEach(battlesToUpdate::add);

if (!battlesToUpdate.isEmpty()) {
battleRepositoryPort.saveAll(battlesToUpdate);
}

log.info("Expired battles processed. done={}, canceled={}", expiredInProgress.size(), expiredNotStarted.size());
log.info("Expired battles processed. done={}", battlesToUpdate.size());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,6 @@ public class FindAllBattleInfoService implements FindAllBattleInfoUseCase {
private static final String RESULT_WINNING = "WINNING";
private static final String RESULT_LOSING = "LOSING";
private static final String RESULT_DRAWING = "DRAWING";
private static final String RESULT_NOT_STARTED = "NOT_STARTED";
private static final String RESULT_CANCELED = "CANCELED";
private static final String RESULT_PENDING = "PENDING";

private final BattleRepositoryPort battleRepositoryPort;
private final BattleFinishService battleFinishService;
Expand Down Expand Up @@ -215,16 +212,6 @@ private String determineResult(
}
}

// 시작 전 배틀
if (status == BattleStatus.NOT_STARTED) {
return RESULT_NOT_STARTED;
}

// 취소된 배틀
if (status == BattleStatus.CANCELED) {
return RESULT_CANCELED;
}

throw new IllegalStateException("Unexpected battle status: " + status);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

public enum BattleStatus {
IN_PROGRESS,
NOT_STARTED,
PENDING,
DONE,
REJECTED,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- 배틀 승인 즉시 IN_PROGRESS로 시작되도록 변경함에 따라
-- 기존에 남아 있는 NOT_STARTED 상태 배틀을 CANCELED로 일괄 전환
UPDATE battles
SET battle_status = 'CANCELED'
WHERE battle_status = 'NOT_STARTED';
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ void finishExpiredBattles_transitionsInProgressToDone() {
RivalLinkingStatus.ACCEPTED, FIRST_USER_ID, SECOND_USER_ID);

when(battleRepositoryPort.findExpiredInProgressBattles()).thenReturn(List.of(inProgressBattle));
when(battleRepositoryPort.findExpiredNotStartedBattles()).thenReturn(List.of());
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));
Expand All @@ -79,59 +78,10 @@ void finishExpiredBattles_transitionsInProgressToDone() {
assertThat(captor.getValue()).allMatch(b -> b.battleStatus() == BattleStatus.DONE);
}

@Test
@DisplayName("종료 시각이 지난 NOT_STARTED 배틀을 CANCELED로 전환한다")
void finishExpiredBattles_transitionsNotStartedToCanceled() {
Instant startedAt = Instant.now().minus(8, ChronoUnit.DAYS);
Instant endAt = Instant.now().minus(1, ChronoUnit.DAYS);
Battle notStartedBattle = new Battle(2L, Instant.now(), Instant.now(),
startedAt, endAt, 7, BattleStatus.NOT_STARTED, null, RIVAL_ID, FIRST_USER_ID);

when(battleRepositoryPort.findExpiredInProgressBattles()).thenReturn(List.of());
when(battleRepositoryPort.findExpiredNotStartedBattles()).thenReturn(List.of(notStartedBattle));

battleFinishService.finishExpiredBattles();

ArgumentCaptor<List<Battle>> captor = ArgumentCaptor.forClass(List.class);
verify(battleRepositoryPort).saveAll(captor.capture());
assertThat(captor.getValue()).allMatch(b -> b.battleStatus() == BattleStatus.CANCELED);
}

@Test
@DisplayName("IN_PROGRESS와 NOT_STARTED 만료 배틀을 한 번의 saveAll로 처리한다")
void finishExpiredBattles_savesAllInSingleCall() {
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);
Battle notStartedBattle = new Battle(2L, Instant.now(), Instant.now(),
startedAt, endAt, 7, BattleStatus.NOT_STARTED, null, RIVAL_ID, FIRST_USER_ID);

Rival rival = new Rival(RIVAL_ID, Instant.now(), Instant.now(),
RivalLinkingStatus.ACCEPTED, FIRST_USER_ID, SECOND_USER_ID);

when(battleRepositoryPort.findExpiredInProgressBattles()).thenReturn(List.of(inProgressBattle));
when(battleRepositoryPort.findExpiredNotStartedBattles()).thenReturn(List.of(notStartedBattle));
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));

battleFinishService.finishExpiredBattles();

ArgumentCaptor<List<Battle>> captor = ArgumentCaptor.forClass(List.class);
verify(battleRepositoryPort).saveAll(captor.capture());
assertThat(captor.getValue()).hasSize(2)
.anyMatch(b -> b.battleStatus() == BattleStatus.DONE)
.anyMatch(b -> b.battleStatus() == BattleStatus.CANCELED);
}

@Test
@DisplayName("종료할 배틀이 없으면 saveAll을 호출하지 않는다")
void finishExpiredBattles_doesNotCallSaveAll_whenNoBattlesToProcess() {
when(battleRepositoryPort.findExpiredInProgressBattles()).thenReturn(List.of());
when(battleRepositoryPort.findExpiredNotStartedBattles()).thenReturn(List.of());

battleFinishService.finishExpiredBattles();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,26 +147,6 @@ void execute_returnsDrawing_whenInProgressAndTied() {
assertThat(result.battles().get(0).result()).isEqualTo("DRAWING");
}

@Test
@DisplayName("NOT_STARTED 배틀은 NOT_STARTED를 반환한다")
void execute_returnsNotStarted_whenNotStarted() {
stubCommonDependencies(List.of(battle(BattleStatus.NOT_STARTED, null)));

FindAllBattleInfoData.Result result = findAllBattleInfoService.execute(command());

assertThat(result.battles().get(0).result()).isEqualTo("NOT_STARTED");
}

@Test
@DisplayName("CANCELED 배틀은 CANCELED를 반환한다")
void execute_returnsCanceled_whenCanceled() {
stubCommonDependencies(List.of(battle(BattleStatus.CANCELED, null)));

FindAllBattleInfoData.Result result = findAllBattleInfoService.execute(command());

assertThat(result.battles().get(0).result()).isEqualTo("CANCELED");
}

@Test
@DisplayName("상대방 rankTier가 NONE이면 expTier를 tier로 반환한다")
void execute_returnsEnemyTierAsExpTier_whenRankTierIsNone() {
Expand Down
Loading