Skip to content

Commit cf3573c

Browse files
authored
#180 [Fix] 지난 배틀 사전투표 크레딧 차감 로직 추가 (#181)
1 parent 8265d6f commit cf3573c

6 files changed

Lines changed: 135 additions & 5 deletions

File tree

src/main/java/com/swyp/picke/domain/user/enums/CreditType.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
@Getter
66
public enum CreditType {
77
BATTLE_VOTE(5), // 배틀 참여 보상: 사후 투표 완료 시 즉시 지급
8+
BATTLE_ENTRY(-10), // 지난 배틀 이용 비용: 사전 투표 최초 진입 시 차감
89
MAJORITY_WIN(10), // 다수결 보상: 월요일 배치, 2주 전 배틀 TOP≥10 대상
910
BEST_COMMENT(50), // 베댓 보상: 월요일 배치, 2주 전 배틀 좋아요 1위
1011
WEEKLY_CHARGE(40), // 주간 자동 충전: 매주 월요일 00:00 활성 사용자 전체

src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,9 @@ public interface UserRepository extends JpaRepository<User, Long> {
2121
@Query("update User u set u.credit = u.credit + :amount where u.id = :id")
2222
int incrementCredit(@Param("id") Long id, @Param("amount") int amount);
2323

24+
@Modifying(clearAutomatically = true, flushAutomatically = true)
25+
@Query("update User u set u.credit = u.credit - :amount where u.id = :id and u.credit >= :amount")
26+
int decrementCreditIfEnough(@Param("id") Long id, @Param("amount") int amount);
27+
2428
List<User> findAllByStatus(UserStatus status);
2529
}

src/main/java/com/swyp/picke/domain/user/service/CreditService.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public void addCredit(Long userId, CreditType creditType, Long referenceId) {
5050
* CreditType의 기본 포인트가 아닌 가변 포인트가 필요한 경우(FREE_CHARGE 랜덤 박스 등)에서 사용.
5151
* 예: creditService.addCredit(userId, CreditType.FREE_CHARGE, 15, boxId);
5252
*
53-
* 적립이 성공하면 User.credit 캐시를 동기 증감하여 {@link #getTotalPoints}가 전체 히스토리를 재집계하지 않도록 한다.
53+
* 적립/차감이 성공하면 User.credit 캐시를 동기 증감하여 {@link #getTotalPoints}가 전체 히스토리를 재집계하지 않도록 한다.
5454
* (user, creditType, referenceId) 중복 시 조용히 무시(멱등).
5555
*/
5656
@Transactional
@@ -75,7 +75,15 @@ public void addCredit(Long userId, CreditType creditType, int amount, Long refer
7575
}
7676
throw new CustomException(ErrorCode.CREDIT_SAVE_FAILED);
7777
}
78-
if (userRepository.incrementCredit(userId, amount) == 0) {
78+
79+
int updated = amount >= 0
80+
? userRepository.incrementCredit(userId, amount)
81+
: userRepository.decrementCreditIfEnough(userId, -amount);
82+
83+
if (updated == 0) {
84+
if (amount < 0) {
85+
throw new CustomException(ErrorCode.CREDIT_NOT_ENOUGH);
86+
}
7987
throw new CustomException(ErrorCode.USER_NOT_FOUND);
8088
}
8189
}

src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
import com.swyp.picke.domain.vote.sse.VoteUpdatedEvent;
2222
import com.swyp.picke.global.common.exception.CustomException;
2323
import com.swyp.picke.global.common.exception.ErrorCode;
24+
import java.time.LocalDate;
2425
import java.time.LocalDateTime;
26+
import java.time.ZoneId;
2527
import java.util.List;
2628
import java.util.Optional;
2729
import lombok.RequiredArgsConstructor;
@@ -34,6 +36,8 @@
3436
@Transactional(readOnly = true)
3537
public class BattleVoteServiceImpl implements BattleVoteService {
3638

39+
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
40+
3741
private final BattleVoteRepository battleVoteRepository;
3842
private final BattleService battleService;
3943
private final BattleOptionRepository battleOptionRepository;
@@ -122,6 +126,9 @@ public VoteResultResponse preVote(Long battleId, Long userId, VoteRequest reques
122126
vote = existingVote.get();
123127
vote.updatePreVote(option);
124128
} else {
129+
if (shouldChargeBattleEntryCredit(battle)) {
130+
creditService.addCredit(user.getId(), CreditType.BATTLE_ENTRY, battle.getId());
131+
}
125132
vote = BattleVote.createPreVote(user, battle, option);
126133
battleVoteRepository.save(vote);
127134
battle.addParticipant();
@@ -191,4 +198,9 @@ public void completeTts(Long battleId, Long userId) {
191198

192199
userBattleService.upsertStep(user, battle, UserBattleStep.POST_VOTE);
193200
}
194-
}
201+
202+
private boolean shouldChargeBattleEntryCredit(Battle battle) {
203+
LocalDate today = LocalDate.now(KST);
204+
return battle.getTargetDate() == null || !battle.getTargetDate().isEqual(today);
205+
}
206+
}

src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,35 @@ void addCredit_duplicateInsert_ignoresConflict() {
106106
verify(userRepository, never()).incrementCredit(1L, 5);
107107
}
108108

109+
@Test
110+
@DisplayName("차감 시 잔액이 충분하면 credit 캐시를 감소시킨다")
111+
void addCredit_negativeAmount_decrementsCreditWhenEnough() {
112+
User user = newUser(1L, 20);
113+
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
114+
when(userRepository.decrementCreditIfEnough(1L, 10)).thenReturn(1);
115+
116+
creditService.addCredit(1L, CreditType.BATTLE_ENTRY, -10, 99L);
117+
118+
verify(userRepository).decrementCreditIfEnough(1L, 10);
119+
verify(userRepository, never()).incrementCredit(1L, -10);
120+
}
121+
122+
@Test
123+
@DisplayName("차감 시 잔액이 부족하면 CREDIT_NOT_ENOUGH 를 던진다")
124+
void addCredit_negativeAmount_throwsWhenInsufficient() {
125+
User user = newUser(1L, 5);
126+
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
127+
when(userRepository.decrementCreditIfEnough(1L, 10)).thenReturn(0);
128+
129+
assertThatThrownBy(() -> creditService.addCredit(1L, CreditType.BATTLE_ENTRY, -10, 99L))
130+
.isInstanceOf(CustomException.class)
131+
.extracting("errorCode")
132+
.isEqualTo(ErrorCode.CREDIT_NOT_ENOUGH);
133+
134+
verify(userRepository).decrementCreditIfEnough(1L, 10);
135+
verify(userRepository, never()).incrementCredit(1L, -10);
136+
}
137+
109138
@Test
110139
@DisplayName("중복이 아닌 데이터 무결성 오류는 CREDIT_SAVE_FAILED 로 재기동하고 캐시도 증가시키지 않는다")
111140
void addCredit_nonDuplicateIntegrityFailure_rethrows() {
@@ -122,6 +151,7 @@ void addCredit_nonDuplicateIntegrityFailure_rethrows() {
122151
.isEqualTo(ErrorCode.CREDIT_SAVE_FAILED);
123152

124153
verify(userRepository, never()).incrementCredit(1L, 10);
154+
verify(userRepository, never()).decrementCreditIfEnough(1L, 10);
125155
}
126156

127157
@Test

src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,19 @@
1919
import com.swyp.picke.domain.vote.dto.response.VoteResultResponse;
2020
import com.swyp.picke.domain.vote.entity.BattleVote;
2121
import com.swyp.picke.domain.vote.repository.BattleVoteRepository;
22+
import java.time.LocalDate;
2223
import java.util.Optional;
2324
import org.junit.jupiter.api.DisplayName;
2425
import org.junit.jupiter.api.Test;
2526
import org.junit.jupiter.api.extension.ExtendWith;
2627
import org.mockito.InjectMocks;
2728
import org.mockito.Mock;
2829
import org.mockito.junit.jupiter.MockitoExtension;
30+
import org.springframework.context.ApplicationEventPublisher;
2931
import org.springframework.test.util.ReflectionTestUtils;
3032

3133
import static org.assertj.core.api.Assertions.assertThat;
34+
import static org.mockito.Mockito.never;
3235
import static org.mockito.Mockito.verify;
3336
import static org.mockito.Mockito.when;
3437

@@ -53,13 +56,84 @@ class BattleVoteServiceImplTest {
5356
@Mock
5457
private CreditService creditService;
5558

59+
@Mock
60+
private ApplicationEventPublisher eventPublisher;
61+
5662
@InjectMocks
5763
private BattleVoteServiceImpl battleVoteService;
5864

65+
@Test
66+
@DisplayName("오늘 배틀이 아니면 최초 사전 투표 시 BATTLE_ENTRY 크레딧을 차감한다")
67+
void preVote_chargesBattleEntryCreditForPastBattle() {
68+
Battle battle = battle(100L, LocalDate.now().minusDays(1));
69+
User user = user(10L);
70+
BattleOption option = option(201L, battle, BattleOptionLabel.A);
71+
72+
when(battleService.findById(100L)).thenReturn(battle);
73+
when(userRepository.findById(10L)).thenReturn(Optional.of(user));
74+
when(battleOptionRepository.findById(201L)).thenReturn(Optional.of(option));
75+
when(battleVoteRepository.findByBattleAndUser(battle, user)).thenReturn(Optional.empty());
76+
when(userBattleService.getUserBattleStatus(user, battle))
77+
.thenReturn(new UserBattleStatusResponse(100L, UserBattleStep.NONE));
78+
79+
VoteResultResponse response = battleVoteService.preVote(100L, 10L, new VoteRequest(201L));
80+
81+
assertThat(response.status()).isEqualTo(UserBattleStep.PRE_VOTE);
82+
verify(creditService).addCredit(10L, CreditType.BATTLE_ENTRY, 100L);
83+
verify(userBattleService).upsertStep(user, battle, UserBattleStep.PRE_VOTE);
84+
}
85+
86+
@Test
87+
@DisplayName("오늘 배틀이면 최초 사전 투표 시 크레딧을 차감하지 않는다")
88+
void preVote_doesNotChargeBattleEntryCreditForTodayBattle() {
89+
Battle battle = battle(100L, LocalDate.now());
90+
User user = user(10L);
91+
BattleOption option = option(201L, battle, BattleOptionLabel.A);
92+
93+
when(battleService.findById(100L)).thenReturn(battle);
94+
when(userRepository.findById(10L)).thenReturn(Optional.of(user));
95+
when(battleOptionRepository.findById(201L)).thenReturn(Optional.of(option));
96+
when(battleVoteRepository.findByBattleAndUser(battle, user)).thenReturn(Optional.empty());
97+
when(userBattleService.getUserBattleStatus(user, battle))
98+
.thenReturn(new UserBattleStatusResponse(100L, UserBattleStep.NONE));
99+
100+
VoteResultResponse response = battleVoteService.preVote(100L, 10L, new VoteRequest(201L));
101+
102+
assertThat(response.status()).isEqualTo(UserBattleStep.PRE_VOTE);
103+
verify(creditService, never()).addCredit(10L, CreditType.BATTLE_ENTRY, 100L);
104+
}
105+
106+
@Test
107+
@DisplayName("이미 사전 투표한 배틀이면 옵션 변경 시 추가 차감하지 않는다")
108+
void preVote_doesNotChargeAgainWhenVoteAlreadyExists() {
109+
Battle battle = battle(100L, LocalDate.now().minusDays(1));
110+
User user = user(10L);
111+
BattleOption oldOption = option(200L, battle, BattleOptionLabel.B);
112+
BattleOption newOption = option(201L, battle, BattleOptionLabel.A);
113+
BattleVote vote = BattleVote.builder()
114+
.user(user)
115+
.battle(battle)
116+
.preVoteOption(oldOption)
117+
.build();
118+
119+
when(battleService.findById(100L)).thenReturn(battle);
120+
when(userRepository.findById(10L)).thenReturn(Optional.of(user));
121+
when(battleOptionRepository.findById(201L)).thenReturn(Optional.of(newOption));
122+
when(battleVoteRepository.findByBattleAndUser(battle, user)).thenReturn(Optional.of(vote));
123+
when(userBattleService.getUserBattleStatus(user, battle))
124+
.thenReturn(new UserBattleStatusResponse(100L, UserBattleStep.PRE_VOTE));
125+
126+
VoteResultResponse response = battleVoteService.preVote(100L, 10L, new VoteRequest(201L));
127+
128+
assertThat(response.status()).isEqualTo(UserBattleStep.PRE_VOTE);
129+
assertThat(vote.getPreVoteOption()).isEqualTo(newOption);
130+
verify(creditService, never()).addCredit(10L, CreditType.BATTLE_ENTRY, 100L);
131+
}
132+
59133
@Test
60134
@DisplayName("사후 투표 완료 시 참여 보상 크레딧을 지급한다")
61135
void postVote_rewardsBattleParticipationCredit() {
62-
Battle battle = battle(100L);
136+
Battle battle = battle(100L, null);
63137
User user = user(10L);
64138
BattleOption preOption = option(201L, battle, BattleOptionLabel.A);
65139
BattleOption postOption = option(202L, battle, BattleOptionLabel.B);
@@ -86,10 +160,11 @@ void postVote_rewardsBattleParticipationCredit() {
86160
verify(creditService).addCredit(10L, CreditType.BATTLE_VOTE, 300L);
87161
}
88162

89-
private Battle battle(Long id) {
163+
private Battle battle(Long id, LocalDate targetDate) {
90164
Battle battle = Battle.builder()
91165
.title("battle")
92166
.summary("summary")
167+
.targetDate(targetDate)
93168
.status(BattleStatus.PUBLISHED)
94169
.build();
95170
ReflectionTestUtils.setField(battle, "id", id);

0 commit comments

Comments
 (0)