From a67fefe5fb9fea533b5e64d02c83445e5d1fb7c7 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Tue, 26 May 2026 14:16:52 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?:=20feat=20:=20=EC=97=B0=EB=A3=8C=20=ED=99=98=EC=9C=A8=2030?= =?UTF-8?q?=EB=B6=84=3D1=EC=97=B0=EB=A3=8C=20+=20=EC=9E=94=EC=97=AC?= =?UTF-8?q?=EB=B6=84=20=EC=9D=B4=EC=9B=94(pendingMinutes)=20=E2=80=94=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=A0=95=EC=B1=85=20=EB=AF=B8=EB=9F=AC?= =?UTF-8?q?=EB=A7=81,=20UserFuel.chargeFromStudy=20+=20FuelService.chargeF?= =?UTF-8?q?romStudy=20=EC=B6=94=EA=B0=80,=20TimerSessionService=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EB=B6=80=20=EB=B3=80=EA=B2=BD=20#25?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fuel/dto/FuelChargeFromStudyResult.java | 17 +++ .../study/fuel/entity/UserFuel.java | 37 +++++++ .../study/fuel/service/FuelService.java | 71 +++++++++++++ .../timer/service/TimerSessionService.java | 22 ++-- .../study/fuel/entity/UserFuelTest.java | 84 +++++++++++++++ .../study/fuel/service/FuelServiceTest.java | 100 ++++++++++++++++++ .../service/TimerSessionServiceTest.java | 57 +++++++--- .../timer/TimerSessionControllerTest.java | 8 +- docs/api-specs/03_timer.md | 31 ++++-- docs/api-specs/04_fuel.md | 4 +- 10 files changed, 397 insertions(+), 34 deletions(-) create mode 100644 SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelChargeFromStudyResult.java diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelChargeFromStudyResult.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelChargeFromStudyResult.java new file mode 100644 index 0000000..26f191c --- /dev/null +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/dto/FuelChargeFromStudyResult.java @@ -0,0 +1,17 @@ +package com.elipair.spacestudyship.study.fuel.dto; + +/** + * 공부 세션에서 파생된 연료 충전 결과. + * + * 환율: 30분 = 1 연료. 30분 미만 잔여분은 {@code newPendingMinutes}에 누적되어 + * 다음 세션과 합산된다. + * + * @param amount 이번 호출로 충전된 연료 통 수 (0 이상) + * @param newPendingMinutes 충전 후 남은 잔여 분 (0~29) + * @param currentFuel 충전 후의 현재 연료 잔량 + */ +public record FuelChargeFromStudyResult( + int amount, + int newPendingMinutes, + int currentFuel +) {} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/UserFuel.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/UserFuel.java index e693176..c118d08 100644 --- a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/UserFuel.java +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/entity/UserFuel.java @@ -45,6 +45,11 @@ public class UserFuel extends BaseTimeEntity { @Column(name = "pending_minutes", nullable = false) private Integer pendingMinutes; + /** + * 공부 환율: 30분 = 1 연료. + */ + public static final int MINUTES_PER_FUEL = 30; + public static UserFuel initialize(Long userId) { return UserFuel.builder() .userId(userId) @@ -61,6 +66,38 @@ public void charge(int amount) { this.totalCharged += amount; } + /** + * 공부 세션으로부터 연료를 충전한다. + * + * 잔여분({@link #pendingMinutes})과 이번 세션의 공부 시간을 합산하여 + * {@value #MINUTES_PER_FUEL}분 단위로 끊어 충전하고, 나머지는 다음 세션을 위해 + * 잔여분으로 이월한다. 정수 연료가 발생할 때만 {@code currentFuel} / + * {@code totalCharged}가 증가한다. + * + * @param studyMinutes 이번 세션의 공부 시간(분), 0 이하 입력은 {@link ErrorCode#INVALID_INPUT_VALUE} + * @return 충전된 연료 통 수와 갱신된 잔여 분 + */ + public ChargeFromStudyResult chargeFromStudy(int studyMinutes) { + if (studyMinutes <= 0) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + int totalMinutes = this.pendingMinutes + studyMinutes; + int amount = totalMinutes / MINUTES_PER_FUEL; + int newPending = totalMinutes % MINUTES_PER_FUEL; + + this.pendingMinutes = newPending; + if (amount > 0) { + this.currentFuel += amount; + this.totalCharged += amount; + } + return new ChargeFromStudyResult(amount, newPending); + } + + /** + * {@link #chargeFromStudy(int)} 결과 — Entity 내부 계산 결과를 서비스에 전달하기 위한 값 객체. + */ + public record ChargeFromStudyResult(int amount, int newPendingMinutes) {} + public void consume(int amount) { if (amount <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); if (this.currentFuel < amount) { diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java index c7afc69..93902f5 100644 --- a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java @@ -4,6 +4,7 @@ import com.elipair.spacestudyship.common.exception.ErrorCode; import com.elipair.spacestudyship.study.fuel.constant.FuelReason; import com.elipair.spacestudyship.study.fuel.constant.TransactionType; +import com.elipair.spacestudyship.study.fuel.dto.FuelChargeFromStudyResult; import com.elipair.spacestudyship.study.fuel.dto.FuelResponse; import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionListResponse; import com.elipair.spacestudyship.study.fuel.dto.FuelTransactionResponse; @@ -132,6 +133,76 @@ public FuelTransactionResponse consume( return FuelTransactionResponse.from(tx); } + /** + * 타이머 세션 완료로 인한 연료 충전. + * + * 환율: 30분 = 1 연료 (잔여분은 {@link UserFuel#pendingMinutes}로 이월). + * idempotency 키는 {@code sessionId} (= {@link FuelTransaction#getId()}). 동일 sessionId + * 재호출 시 기존 transaction을 그대로 반환하고 잔량은 변경하지 않는다. + * + * 30분 미만이라 충전 통 수가 0이 나오면 {@code fuel_transactions} INSERT는 건너뛰고 + * {@code user_fuel.pendingMinutes}만 갱신한다 (transaction-less pending 누적). + */ + @Transactional + public FuelChargeFromStudyResult chargeFromStudy( + Long userId, int studyMinutes, String sessionId) { + + if (studyMinutes <= 0) throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + + Optional existing = transactionRepository.findById(sessionId); + if (existing.isPresent()) { + return idempotentReturnFromStudy(existing.get(), userId, sessionId); + } + + UserFuel fuel = userFuelRepository.findByUserIdForUpdate(userId) + .orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED)); + + // 락 획득 후 race 재확인 (다른 트랜잭션이 동일 sessionId로 먼저 INSERT했을 가능성) + Optional raced = transactionRepository.findById(sessionId); + if (raced.isPresent()) { + return idempotentReturnFromStudy(raced.get(), userId, sessionId); + } + + UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(studyMinutes); + + if (result.amount() > 0) { + FuelTransaction tx = FuelTransaction.of( + sessionId, userId, TransactionType.CHARGE, + result.amount(), FuelReason.STUDY_SESSION, + sessionId, fuel.getCurrentFuel()); + transactionRepository.save(tx); + } + + log.info("[Fuel] 공부 세션 충전 | userId={}, studyMinutes={}, amount={}, " + + "pendingMinutes={}, balanceAfter={}, sessionId={}", + userId, studyMinutes, result.amount(), + result.newPendingMinutes(), fuel.getCurrentFuel(), sessionId); + return new FuelChargeFromStudyResult( + result.amount(), result.newPendingMinutes(), fuel.getCurrentFuel()); + } + + private FuelChargeFromStudyResult idempotentReturnFromStudy( + FuelTransaction tx, Long userId, String sessionId) { + if (!tx.getUserId().equals(userId)) { + throw new CustomException(ErrorCode.INVALID_INPUT_VALUE); + } + UserFuel fuel = userFuelRepository.findByUserId(userId) + .orElseThrow(() -> new CustomException(ErrorCode.FUEL_NOT_INITIALIZED)); + log.info("[Fuel] 공부 세션 idempotent skip | userId={}, sessionId={}", userId, sessionId); + return new FuelChargeFromStudyResult( + tx.getAmount(), fuel.getPendingMinutes(), fuel.getCurrentFuel()); + } + + /** + * 특정 sessionId에 대응하는 충전 transaction의 amount를 조회한다. + * Timer 도메인이 dedup 응답을 만들 때 사용 (transaction이 없으면 0). + */ + public int findChargedAmountBySessionId(String sessionId) { + return transactionRepository.findById(sessionId) + .map(FuelTransaction::getAmount) + .orElse(0); + } + @Transactional public void initialize(Long userId) { if (userFuelRepository.existsByUserId(userId)) { diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java index 07d596f..08bf107 100644 --- a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java @@ -2,7 +2,7 @@ import com.elipair.spacestudyship.common.exception.CustomException; import com.elipair.spacestudyship.common.exception.ErrorCode; -import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.dto.FuelChargeFromStudyResult; import com.elipair.spacestudyship.study.fuel.service.FuelService; import com.elipair.spacestudyship.study.timer.dto.*; import com.elipair.spacestudyship.study.timer.entity.TimerSession; @@ -61,7 +61,8 @@ public TimerSessionCreateResponse create( if (existing.isPresent()) { log.info("[Timer] idempotent skip | userId={}, key={}, sessionId={}", userId, normalizedKey, existing.get().getId()); - return buildResponse(existing.get(), existing.get().getDurationMinutes()); + int existingFuelCharged = fuelService.findChargedAmountBySessionId(existing.get().getId()); + return buildResponse(existing.get(), existingFuelCharged); } } @@ -88,21 +89,26 @@ public TimerSessionCreateResponse create( if (raced.isPresent()) { log.info("[Timer] idempotent race resolved | userId={}, key={}", userId, normalizedKey); - return buildResponse(raced.get(), raced.get().getDurationMinutes()); + int racedFuelCharged = fuelService.findChargedAmountBySessionId(raced.get().getId()); + return buildResponse(raced.get(), racedFuelCharged); } } throw e; } - int fuelCharged = request.durationMinutes(); - fuelService.charge(userId, fuelCharged, FuelReason.STUDY_SESSION, sessionId, sessionId); + // 30분 = 1연료 환산. 잔여분은 user_fuel.pendingMinutes에 이월되어 다음 세션과 합산. + FuelChargeFromStudyResult fuelResult = fuelService.chargeFromStudy( + userId, request.durationMinutes(), sessionId); + int fuelCharged = fuelResult.amount(); + // Todo actualMinutes는 실제 공부 분(durationMinutes)으로 누적 (연료 환산과 무관) if (request.todoId() != null) { - todoService.addActualMinutes(userId, request.todoId(), fuelCharged); + todoService.addActualMinutes(userId, request.todoId(), request.durationMinutes()); } - log.info("[Timer] 세션 저장 | userId={}, sessionId={}, duration={}분, todoId={}", - userId, sessionId, fuelCharged, request.todoId()); + log.info("[Timer] 세션 저장 | userId={}, sessionId={}, studyMinutes={}, fuelCharged={}, pendingMinutes={}, todoId={}", + userId, sessionId, request.durationMinutes(), fuelCharged, + fuelResult.newPendingMinutes(), request.todoId()); return buildResponse(session, fuelCharged); } diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/entity/UserFuelTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/entity/UserFuelTest.java index 44cef8a..c2bc484 100644 --- a/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/entity/UserFuelTest.java +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/entity/UserFuelTest.java @@ -99,4 +99,88 @@ void consume_insufficient_throws() { .isInstanceOf(CustomException.class) .extracting("errorCode").isEqualTo(ErrorCode.INSUFFICIENT_FUEL); } + + // ---------- chargeFromStudy (30분 = 1연료) ---------- + + @Test + @DisplayName("chargeFromStudy: 30분 정확히 → 1연료 충전, pending=0") + void chargeFromStudy_exactly30_charges1() { + UserFuel fuel = UserFuel.initialize(1L); + + UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(30); + + assertThat(result.amount()).isEqualTo(1); + assertThat(result.newPendingMinutes()).isZero(); + assertThat(fuel.getCurrentFuel()).isEqualTo(1); + assertThat(fuel.getTotalCharged()).isEqualTo(1); + assertThat(fuel.getPendingMinutes()).isZero(); + } + + @Test + @DisplayName("chargeFromStudy: 25분 → 0연료, pending=25 누적 (transaction 없이 잔여분만 이월)") + void chargeFromStudy_under30_pendingOnly() { + UserFuel fuel = UserFuel.initialize(1L); + + UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(25); + + assertThat(result.amount()).isZero(); + assertThat(result.newPendingMinutes()).isEqualTo(25); + assertThat(fuel.getCurrentFuel()).isZero(); + assertThat(fuel.getTotalCharged()).isZero(); + assertThat(fuel.getPendingMinutes()).isEqualTo(25); + } + + @Test + @DisplayName("chargeFromStudy: 90분 → 3연료, pending=0") + void chargeFromStudy_multiple_3() { + UserFuel fuel = UserFuel.initialize(1L); + + UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(90); + + assertThat(result.amount()).isEqualTo(3); + assertThat(result.newPendingMinutes()).isZero(); + assertThat(fuel.getCurrentFuel()).isEqualTo(3); + } + + @Test + @DisplayName("chargeFromStudy: pending 25 + 20분 → 1연료, pending=15 (잔여분 이월 누적)") + void chargeFromStudy_pendingCarriedOver() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.chargeFromStudy(25); // pending=25 + + UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(20); + + assertThat(result.amount()).isEqualTo(1); + assertThat(result.newPendingMinutes()).isEqualTo(15); + assertThat(fuel.getCurrentFuel()).isEqualTo(1); + assertThat(fuel.getPendingMinutes()).isEqualTo(15); + } + + @Test + @DisplayName("chargeFromStudy: pending 15 + 50분 → 2연료, pending=5") + void chargeFromStudy_pendingPlusLargeStudy() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.chargeFromStudy(25); + fuel.chargeFromStudy(20); // 누적 currentFuel=1, pending=15 + + UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(50); + + assertThat(result.amount()).isEqualTo(2); + assertThat(result.newPendingMinutes()).isEqualTo(5); + assertThat(fuel.getCurrentFuel()).isEqualTo(3); // 0+1+2 + assertThat(fuel.getTotalCharged()).isEqualTo(3); + } + + @Test + @DisplayName("chargeFromStudy: studyMinutes<=0이면 INVALID_INPUT_VALUE") + void chargeFromStudy_nonPositive_throws() { + UserFuel fuel = UserFuel.initialize(1L); + + assertThatThrownBy(() -> fuel.chargeFromStudy(0)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + assertThatThrownBy(() -> fuel.chargeFromStudy(-5)) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } } diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java index c9463d7..211ab8d 100644 --- a/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/fuel/service/FuelServiceTest.java @@ -353,4 +353,104 @@ void consume_fuelNotInitialized_throws() { .isInstanceOf(CustomException.class) .extracting("errorCode").isEqualTo(ErrorCode.FUEL_NOT_INITIALIZED); } + + // ---------- chargeFromStudy (30분 = 1연료, 잔여분 이월) ---------- + + @Test + @DisplayName("chargeFromStudy: 90분 → amount=3, pending=0. fuel_transactions INSERT 1회") + void chargeFromStudy_90min_charges3() { + UserFuel fuel = UserFuel.initialize(1L); + given(transactionRepository.findById("sess-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.of(fuel)); + + var result = fuelService.chargeFromStudy(1L, 90, "sess-1"); + + assertThat(result.amount()).isEqualTo(3); + assertThat(result.newPendingMinutes()).isZero(); + assertThat(result.currentFuel()).isEqualTo(3); + assertThat(fuel.getCurrentFuel()).isEqualTo(3); + assertThat(fuel.getPendingMinutes()).isZero(); + + ArgumentCaptor cap = ArgumentCaptor.forClass(FuelTransaction.class); + verify(transactionRepository).save(cap.capture()); + FuelTransaction tx = cap.getValue(); + assertThat(tx.getId()).isEqualTo("sess-1"); + assertThat(tx.getAmount()).isEqualTo(3); + assertThat(tx.getType()).isEqualTo(TransactionType.CHARGE); + assertThat(tx.getReason()).isEqualTo(FuelReason.STUDY_SESSION); + assertThat(tx.getReferenceId()).isEqualTo("sess-1"); + } + + @Test + @DisplayName("chargeFromStudy: 25분 → amount=0, pending=25. transaction 미생성 (pending만 누적)") + void chargeFromStudy_25min_noTransactionPendingOnly() { + UserFuel fuel = UserFuel.initialize(1L); + given(transactionRepository.findById("sess-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.of(fuel)); + + var result = fuelService.chargeFromStudy(1L, 25, "sess-1"); + + assertThat(result.amount()).isZero(); + assertThat(result.newPendingMinutes()).isEqualTo(25); + assertThat(result.currentFuel()).isZero(); + assertThat(fuel.getPendingMinutes()).isEqualTo(25); + + verify(transactionRepository, never()).save(any()); + } + + @Test + @DisplayName("chargeFromStudy: 동일 sessionId 재호출 → idempotent skip, fuel 변경 없음") + void chargeFromStudy_idempotent() { + UserFuel fuel = UserFuel.initialize(1L); + fuel.charge(3); // 사전 상태: 이미 3연료 있음 + FuelTransaction existing = FuelTransaction.of( + "sess-1", 1L, TransactionType.CHARGE, 3, + FuelReason.STUDY_SESSION, "sess-1", 3); + given(transactionRepository.findById("sess-1")).willReturn(Optional.of(existing)); + given(userFuelRepository.findByUserId(1L)).willReturn(Optional.of(fuel)); + + var result = fuelService.chargeFromStudy(1L, 90, "sess-1"); + + assertThat(result.amount()).isEqualTo(3); + assertThat(result.currentFuel()).isEqualTo(3); + verify(userFuelRepository, never()).findByUserIdForUpdate(any()); + verify(transactionRepository, never()).save(any()); + } + + @Test + @DisplayName("chargeFromStudy: studyMinutes<=0 → INVALID_INPUT_VALUE") + void chargeFromStudy_nonPositive_throws() { + assertThatThrownBy(() -> fuelService.chargeFromStudy(1L, 0, "sess-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + assertThatThrownBy(() -> fuelService.chargeFromStudy(1L, -5, "sess-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.INVALID_INPUT_VALUE); + } + + @Test + @DisplayName("chargeFromStudy: UserFuel 미초기화 시 FUEL_NOT_INITIALIZED") + void chargeFromStudy_fuelNotInitialized_throws() { + given(transactionRepository.findById("sess-1")).willReturn(Optional.empty()); + given(userFuelRepository.findByUserIdForUpdate(1L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> fuelService.chargeFromStudy(1L, 60, "sess-1")) + .isInstanceOf(CustomException.class) + .extracting("errorCode").isEqualTo(ErrorCode.FUEL_NOT_INITIALIZED); + } + + // ---------- findChargedAmountBySessionId ---------- + + @Test + @DisplayName("findChargedAmountBySessionId: transaction 있으면 amount, 없으면 0") + void findChargedAmountBySessionId() { + FuelTransaction tx = FuelTransaction.of( + "sess-1", 1L, TransactionType.CHARGE, 3, + FuelReason.STUDY_SESSION, "sess-1", 3); + given(transactionRepository.findById("sess-1")).willReturn(Optional.of(tx)); + given(transactionRepository.findById("sess-2")).willReturn(Optional.empty()); + + assertThat(fuelService.findChargedAmountBySessionId("sess-1")).isEqualTo(3); + assertThat(fuelService.findChargedAmountBySessionId("sess-2")).isZero(); + } } diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java index 09e8065..8c2ebff 100644 --- a/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java @@ -2,7 +2,7 @@ import com.elipair.spacestudyship.common.exception.CustomException; import com.elipair.spacestudyship.common.exception.ErrorCode; -import com.elipair.spacestudyship.study.fuel.constant.FuelReason; +import com.elipair.spacestudyship.study.fuel.dto.FuelChargeFromStudyResult; import com.elipair.spacestudyship.study.fuel.service.FuelService; import com.elipair.spacestudyship.study.timer.dto.TimerSessionCreateRequest; import com.elipair.spacestudyship.study.timer.dto.TimerSessionCreateResponse; @@ -139,15 +139,20 @@ void validate_exactlyAtSkewBoundary_passes() { Instant.parse("2026-05-25T12:05:00Z"), Instant.parse("2026-05-25T13:00:00Z"), 30); + given(fuelService.chargeFromStudy(eq(1L), eq(30), anyString())) + .willReturn(new FuelChargeFromStudyResult(1, 0, 1)); TimerSessionCreateResponse res = service.create(1L, req, null); assertThat(res.session().durationMinutes()).isEqualTo(30); } @Test - @DisplayName("create 정상: 세션 저장 + Fuel 충전 + (todoId 없으므로) Todo 미호출") + @DisplayName("create 정상: 세션 저장 + Fuel chargeFromStudy(60분 → 2연료) + (todoId 없으므로) Todo 미호출") void create_noTodo_chargesFuel_doesNotTouchTodo() { TimerSessionCreateRequest req = validRequest(60); + // 60분 = 2연료 (30분=1연료 환산), pending 0 + given(fuelService.chargeFromStudy(eq(1L), eq(60), anyString())) + .willReturn(new FuelChargeFromStudyResult(2, 0, 2)); TimerSessionCreateResponse res = service.create(1L, req, null); @@ -160,24 +165,43 @@ void create_noTodo_chargesFuel_doesNotTouchTodo() { assertThat(saved.getIdempotencyKey()).isNull(); assertThat(saved.getId()).isNotBlank(); - verify(fuelService).charge( - eq(1L), eq(60), eq(FuelReason.STUDY_SESSION), - eq(saved.getId()), eq(saved.getId())); + verify(fuelService).chargeFromStudy(eq(1L), eq(60), eq(saved.getId())); verifyNoInteractions(todoService); - assertThat(res.fuelCharged()).isEqualTo(60); + assertThat(res.fuelCharged()).isEqualTo(2); assertThat(res.session().id()).isEqualTo(saved.getId()); } @Test - @DisplayName("create 정상: todoId 있으면 TodoService.addActualMinutes 호출") + @DisplayName("create 정상: 25분 세션 → fuelCharged=0 (pending 누적), Todo는 25분 그대로 누적") + void create_under30Min_noFuelChargedButPendingAccumulated() { + TimerSessionCreateRequest req = new TimerSessionCreateRequest( + "todo-1", "수학", + Instant.parse("2026-05-25T01:00:00Z"), + Instant.parse("2026-05-25T01:25:00Z"), + 25); + // 25분 → amount=0, pending=25 (30분 미만이라 fuel transaction 생성 안 됨) + given(fuelService.chargeFromStudy(eq(1L), eq(25), anyString())) + .willReturn(new FuelChargeFromStudyResult(0, 25, 0)); + + TimerSessionCreateResponse res = service.create(1L, req, null); + + assertThat(res.fuelCharged()).isZero(); + // Todo는 실제 공부 분 그대로 누적 (연료 환산과 무관) + verify(todoService).addActualMinutes(eq(1L), eq("todo-1"), eq(25)); + } + + @Test + @DisplayName("create 정상: todoId 있으면 TodoService.addActualMinutes 호출 (studyMinutes 그대로)") void create_withTodo_callsAddActualMinutes() { TimerSessionCreateRequest req = new TimerSessionCreateRequest( "todo-1", "수학", Instant.parse("2026-05-25T01:00:00Z"), Instant.parse("2026-05-25T02:00:00Z"), 60); + given(fuelService.chargeFromStudy(eq(1L), eq(60), anyString())) + .willReturn(new FuelChargeFromStudyResult(2, 0, 2)); service.create(1L, req, null); @@ -185,7 +209,7 @@ void create_withTodo_callsAddActualMinutes() { } @Test - @DisplayName("Idempotency-Key dedup: 동일 키 재요청 시 기존 세션 반환, fuel/todo 호출 0회") + @DisplayName("Idempotency-Key dedup: 동일 키 재요청 시 기존 세션 반환 + fuel transaction 조회로 fuelCharged 복구") void idempotency_dedup_returnsExisting() { TimerSession existing = TimerSession.of( "existing-id", 1L, null, null, @@ -194,19 +218,25 @@ void idempotency_dedup_returnsExisting() { 60, "idem-1"); given(sessionRepository.findByUserIdAndIdempotencyKey(1L, "idem-1")) .willReturn(Optional.of(existing)); + // dedup 시 fuelCharged는 기존 fuel transaction 조회로 복구 (60분 → 2연료) + given(fuelService.findChargedAmountBySessionId("existing-id")).willReturn(2); TimerSessionCreateResponse res = service.create(1L, validRequest(60), "idem-1"); verify(sessionRepository, never()).saveAndFlush(any()); - verifyNoInteractions(fuelService); + // chargeFromStudy는 호출 안 됨 (실제 충전·중복 차단) + verify(fuelService, never()).chargeFromStudy(anyLong(), anyInt(), anyString()); verifyNoInteractions(todoService); assertThat(res.session().id()).isEqualTo("existing-id"); - assertThat(res.fuelCharged()).isEqualTo(60); + assertThat(res.fuelCharged()).isEqualTo(2); } @Test @DisplayName("Idempotency-Key 정규화: blank → null로 취급 (dedup 안 함)") void idempotency_blank_normalizedToNull() { + given(fuelService.chargeFromStudy(eq(1L), eq(60), anyString())) + .willReturn(new FuelChargeFromStudyResult(2, 0, 2)); + service.create(1L, validRequest(60), " "); verify(sessionRepository, never()).findByUserIdAndIdempotencyKey(anyLong(), any()); @@ -216,7 +246,7 @@ void idempotency_blank_normalizedToNull() { } @Test - @DisplayName("Idempotency race: saveAndFlush 시 DataIntegrityViolation → 재조회 후 기존 반환") + @DisplayName("Idempotency race: saveAndFlush 시 DataIntegrityViolation → 재조회 후 기존 반환 + fuel transaction 조회") void idempotency_race_resolvedByReSelect() { given(sessionRepository.findByUserIdAndIdempotencyKey(1L, "idem-1")) .willReturn(Optional.empty()) @@ -227,11 +257,14 @@ void idempotency_race_resolvedByReSelect() { 60, "idem-1"))); given(sessionRepository.saveAndFlush(any(TimerSession.class))) .willThrow(new DataIntegrityViolationException("unique violation")); + given(fuelService.findChargedAmountBySessionId("racer-id")).willReturn(2); TimerSessionCreateResponse res = service.create(1L, validRequest(60), "idem-1"); assertThat(res.session().id()).isEqualTo("racer-id"); - verifyNoInteractions(fuelService); + assertThat(res.fuelCharged()).isEqualTo(2); + // race 복구 후에는 신규 chargeFromStudy 호출 안 함 + verify(fuelService, never()).chargeFromStudy(anyLong(), anyInt(), anyString()); verifyNoInteractions(todoService); } diff --git a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java index 3a2908c..23cb504 100644 --- a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java +++ b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java @@ -75,7 +75,7 @@ void setUp() { } @Test - @DisplayName("POST /api/timer-sessions — 201, { session, fuelCharged }") + @DisplayName("POST /api/timer-sessions — 201, { session, fuelCharged } (90분 → 3연료, 30분=1연료 환산)") void create_201() throws Exception { TimerSessionResponse sessionRes = new TimerSessionResponse( "sess-1", "todo-1", "수학", @@ -83,7 +83,7 @@ void create_201() throws Exception { Instant.parse("2026-05-25T02:30:00Z"), 90); given(service.create(eq(1L), any(TimerSessionCreateRequest.class), any())) - .willReturn(new TimerSessionCreateResponse(sessionRes, 90)); + .willReturn(new TimerSessionCreateResponse(sessionRes, 3)); String body = """ { @@ -101,7 +101,7 @@ void create_201() throws Exception { .andExpect(status().isCreated()) .andExpect(jsonPath("$.session.id").value("sess-1")) .andExpect(jsonPath("$.session.durationMinutes").value(90)) - .andExpect(jsonPath("$.fuelCharged").value(90)); + .andExpect(jsonPath("$.fuelCharged").value(3)); } @Test @@ -113,7 +113,7 @@ void create_idempotencyKeyPassThrough() throws Exception { Instant.parse("2026-05-25T02:00:00Z"), 60); given(service.create(eq(1L), any(), eq("idem-abc"))) - .willReturn(new TimerSessionCreateResponse(sessionRes, 60)); + .willReturn(new TimerSessionCreateResponse(sessionRes, 2)); String body = """ {"startedAt":"2026-05-25T01:00:00Z","endedAt":"2026-05-25T02:00:00Z","durationMinutes":60} diff --git a/docs/api-specs/03_timer.md b/docs/api-specs/03_timer.md index 8f33346..d8e933e 100644 --- a/docs/api-specs/03_timer.md +++ b/docs/api-specs/03_timer.md @@ -155,14 +155,14 @@ GET /api/timer-sessions?todoId=todo-uuid-5678 "endedAt": "2026-04-16T10:30:00Z", "durationMinutes": 90 }, - "fuelCharged": 90 + "fuelCharged": 3 } ``` | 필드 | 타입 | 설명 | |------|------|------| | `session` | Object | 저장된 세션 (서버 생성 ID 포함) | -| `fuelCharged` | Integer | 서버에서 검증 후 실제 충전된 연료량 | +| `fuelCharged` | Integer | 서버에서 검증 후 실제 충전된 연료 **통 수** (30분=1연료 환산 결과). 30분 미만은 0 가능 | ### Error @@ -183,16 +183,31 @@ GET /api/timer-sessions?todoId=todo-uuid-5678 4. startedAt이 미래가 아닌지 확인 5. 검증 통과 시: - 세션 DB 저장 - - 연료 충전: fuelCharged = durationMinutes (1분 = 1연료) - - Fuel 거래 내역 생성 (type: charge, reason: STUDY_SESSION, referenceId: sessionId) -6. Todo에 actualMinutes 누적 업데이트 (todoId가 있는 경우) + - 연료 충전: 30분 단위로 끊어서 충전, 잔여분은 pendingMinutes에 이월 + - Fuel 거래 내역 생성 (단, 충전 amount > 0 일 때만 INSERT — 30분 미만이면 transaction 없음) + - type: charge, reason: STUDY_SESSION, referenceId: sessionId +6. Todo에 actualMinutes 누적 업데이트 (todoId가 있는 경우, studyMinutes 그대로 누적) ``` ### 연료 충전 규칙 -- 기본: **1분 공부 = 1 연료** -- 서버에서 `durationMinutes`를 재검증하여 충전량 결정 -- 클라이언트가 보낸 값과 서버 계산값이 다를 수 있음 (조작 방지) +- 환율: **30분 공부 = 1 연료** (정수 절삭) +- **잔여분 이월**: 30분 미만 잔여 분은 `user_fuel.pendingMinutes`에 누적되어 다음 세션과 합산 +- `fuelCharged` 응답값은 이번 호출로 새로 충전된 통 수 (0 가능) +- 서버에서 `durationMinutes`를 재검증하여 충전량 결정 (조작 방지) + +#### 환산 예시 (사용자 누적) + +| 호출 | 이번 세션 | 직전 pending | 이번 totalMinutes | fuelCharged(amount) | newPending | 누적 currentFuel | +|------|-----------|--------------|-------------------|---------------------|------------|------------------| +| 1 | 25분 | 0 | 25 | **0** | 25 | 0 | +| 2 | 20분 | 25 | 45 | **1** | 15 | 1 | +| 3 | 50분 | 15 | 65 | **2** | 5 | 3 | +| 4 | 90분 | 5 | 95 | **3** | 5 | 6 | + +#### Todo `actualMinutes` 누적 +- 연료 환산과 무관하게 **실제 공부 분(`durationMinutes`)을 그대로 누적** +- "내가 오늘 73분 공부했다"는 사용자 인지값을 손실 없이 보존 --- diff --git a/docs/api-specs/04_fuel.md b/docs/api-specs/04_fuel.md index 591bbaf..4bad296 100644 --- a/docs/api-specs/04_fuel.md +++ b/docs/api-specs/04_fuel.md @@ -40,7 +40,7 @@ "currentFuel": 350, "totalCharged": 1200, "totalConsumed": 850, - "pendingMinutes": 0, + "pendingMinutes": 15, "lastUpdatedAt": "2026-04-16T10:30:00Z" } ``` @@ -50,7 +50,7 @@ | `currentFuel` | Integer | 현재 보유 연료 (`totalCharged - totalConsumed`) | | `totalCharged` | Integer | 누적 충전량 | | `totalConsumed` | Integer | 누적 소비량 | -| `pendingMinutes` | Integer | 아직 서버에 동기화되지 않은 공부 시간 (분). 현재 사용 안 함, 향후 확장용 | +| `pendingMinutes` | Integer | 다음 1연료까지 남은 누적 공부 분 (0~29). 30분 미만 잔여분이 다음 세션과 합산되어 이월됨 | | `lastUpdatedAt` | String | 마지막 연료 변동 시각 (ISO 8601 UTC) | --- From ef94f92a49d5ea70432c9d05b7811f4ee7d2695d Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Tue, 26 May 2026 16:57:32 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?:=20docs=20:=20chargeFromStudy=20idempotency=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EB=AA=85=ED=99=95=ED=99=94=20(amount=3D0=20?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=EB=8A=94=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20sessionId=20=EC=9C=A0=EC=9D=BC=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5=20=EC=B1=85=EC=9E=84)=20#25?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../study/fuel/service/FuelService.java | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java index 93902f5..c3e0837 100644 --- a/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/fuel/service/FuelService.java @@ -137,11 +137,22 @@ public FuelTransactionResponse consume( * 타이머 세션 완료로 인한 연료 충전. * * 환율: 30분 = 1 연료 (잔여분은 {@link UserFuel#pendingMinutes}로 이월). - * idempotency 키는 {@code sessionId} (= {@link FuelTransaction#getId()}). 동일 sessionId - * 재호출 시 기존 transaction을 그대로 반환하고 잔량은 변경하지 않는다. * - * 30분 미만이라 충전 통 수가 0이 나오면 {@code fuel_transactions} INSERT는 건너뛰고 - * {@code user_fuel.pendingMinutes}만 갱신한다 (transaction-less pending 누적). + *

Idempotency 계약

+ * Idempotency 키는 {@code sessionId} (= {@link FuelTransaction#getId()}). + *
    + *
  • amount > 0 (30분 이상 합산): 동일 sessionId 재호출 시 기존 + * {@code fuel_transactions} row를 조회해 idempotent skip한다. + * 잔량/pending 변경 없음.
  • + *
  • amount = 0 (30분 미만 합산): {@code fuel_transactions} INSERT가 발생하지 + * 않으므로 fuel-side에서 idempotency를 보장하지 못한다. + * 호출자는 sessionId의 유일성을 직접 보장해야 한다 (예: timer-side의 + * {@code Idempotency-Key} 헤더 기반 dedup + 매 호출 신규 UUID 생성). + * 동일 sessionId 재호출 시 {@code pendingMinutes}가 중복 누적된다.
  • + *
+ * + * 현재 단일 호출자({@code TimerSessionService.create})가 매 호출마다 신규 UUID를 생성하므로 + * 위 amount=0 시나리오는 발생하지 않는다. 향후 직접 호출자를 추가할 때는 이 계약을 확인하라. */ @Transactional public FuelChargeFromStudyResult chargeFromStudy( @@ -165,6 +176,10 @@ public FuelChargeFromStudyResult chargeFromStudy( UserFuel.ChargeFromStudyResult result = fuel.chargeFromStudy(studyMinutes); + // amount=0이면 transaction을 만들지 않는다 — pending만 갱신. + // fuel_transactions의 chk_fuel_tx_amount_positive 제약으로 amount=0 INSERT 불가능하기도 하고, + // 거래 내역(GET /api/fuel/transactions)에 0연료 노이즈 row 노출을 피하기 위함. + // 대신 sessionId 유일성은 호출자(TimerSessionService)가 보장해야 한다. (위 Javadoc 참조) if (result.amount() > 0) { FuelTransaction tx = FuelTransaction.of( sessionId, userId, TransactionType.CHARGE,