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..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
@@ -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,91 @@ public FuelTransactionResponse consume(
return FuelTransactionResponse.from(tx);
}
+ /**
+ * 타이머 세션 완료로 인한 연료 충전.
+ *
+ * 환율: 30분 = 1 연료 (잔여분은 {@link UserFuel#pendingMinutes}로 이월).
+ *
+ *
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(
+ 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);
+
+ // 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,
+ 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) |
---